uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Template processor - handles file copying and Handlebars substitution
3
+ */
4
+
5
+ import fs from 'node:fs/promises'
6
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import Handlebars from 'handlebars'
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
+
13
+ // Cache for compiled templates
14
+ const templateCache = new Map()
15
+
16
+ // Track if partials have been registered
17
+ let partialsRegistered = false
18
+
19
+ // Store for version data (set by registerVersions)
20
+ let versionData = {}
21
+
22
+ // Default fallback version when a package version is unknown
23
+ const DEFAULT_FALLBACK_VERSION = '^0.1.0'
24
+
25
+ /**
26
+ * Register version data for the {{version}} helper
27
+ *
28
+ * @param {Object} versions - Map of package names to version specs
29
+ */
30
+ export function registerVersions(versions) {
31
+ versionData = versions || {}
32
+ }
33
+
34
+ /**
35
+ * Register Handlebars partials from the partials directory
36
+ * Partials are available as {{> partial-name}} in templates
37
+ */
38
+ function registerPartials() {
39
+ if (partialsRegistered) return
40
+
41
+ const partialsDir = path.join(__dirname, '..', '..', 'partials')
42
+
43
+ if (!existsSync(partialsDir)) {
44
+ partialsRegistered = true
45
+ return
46
+ }
47
+
48
+ try {
49
+ const files = readdirSync(partialsDir)
50
+
51
+ for (const file of files) {
52
+ if (file.endsWith('.hbs') || file.endsWith('.md')) {
53
+ const partialName = file.replace(/\.(hbs|md)$/, '')
54
+ const partialPath = path.join(partialsDir, file)
55
+ const partialContent = readFileSync(partialPath, 'utf8')
56
+
57
+ Handlebars.registerPartial(partialName, partialContent)
58
+ }
59
+ }
60
+
61
+ partialsRegistered = true
62
+ } catch (err) {
63
+ console.warn('Warning: Could not register partials:', err.message)
64
+ partialsRegistered = true
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Handlebars helper to get a package version
70
+ * Usage: {{version "@uniweb/build"}} or {{version "build"}}
71
+ */
72
+ Handlebars.registerHelper('version', function (packageName) {
73
+ // Try exact match first
74
+ if (versionData[packageName]) {
75
+ return versionData[packageName]
76
+ }
77
+
78
+ // Try with @uniweb/ prefix
79
+ if (!packageName.startsWith('@') && versionData[`@uniweb/${packageName}`]) {
80
+ return versionData[`@uniweb/${packageName}`]
81
+ }
82
+
83
+ return DEFAULT_FALLBACK_VERSION
84
+ })
85
+
86
+ // Text file extensions that should be processed for variables
87
+ const TEXT_EXTENSIONS = new Set([
88
+ '.js',
89
+ '.jsx',
90
+ '.ts',
91
+ '.tsx',
92
+ '.mjs',
93
+ '.cjs',
94
+ '.json',
95
+ '.yml',
96
+ '.yaml',
97
+ '.md',
98
+ '.mdx',
99
+ '.html',
100
+ '.htm',
101
+ '.css',
102
+ '.scss',
103
+ '.less',
104
+ '.txt',
105
+ '.xml',
106
+ '.svg',
107
+ '.vue',
108
+ '.astro'
109
+ ])
110
+
111
+ /**
112
+ * Check if string contains unresolved Handlebars placeholders
113
+ */
114
+ function findUnresolvedPlaceholders(content) {
115
+ const patterns = [
116
+ /\{\{([^#/}>][^}]*)\}\}/g // {{variable}} - not blocks or partials
117
+ ]
118
+
119
+ const unresolved = []
120
+ for (const pattern of patterns) {
121
+ const matches = content.matchAll(pattern)
122
+ for (const match of matches) {
123
+ const varName = match[1].trim()
124
+ // Skip helpers and expressions with spaces (likely intentional)
125
+ if (!varName.includes(' ')) {
126
+ unresolved.push(varName)
127
+ }
128
+ }
129
+ }
130
+ return [...new Set(unresolved)]
131
+ }
132
+
133
+ /**
134
+ * Load and compile a Handlebars template with caching
135
+ */
136
+ async function loadTemplate(templatePath) {
137
+ // Ensure partials are registered before first template load
138
+ registerPartials()
139
+
140
+ if (templateCache.has(templatePath)) {
141
+ return templateCache.get(templatePath)
142
+ }
143
+
144
+ const template = await fs.readFile(templatePath, 'utf8')
145
+ const compiled = Handlebars.compile(template)
146
+ templateCache.set(templatePath, compiled)
147
+ return compiled
148
+ }
149
+
150
+ /**
151
+ * Process a single file - either copy or apply Handlebars
152
+ */
153
+ async function processFile(sourcePath, targetPath, data, options = {}) {
154
+ const isHbs = sourcePath.endsWith('.hbs')
155
+ const ext = path.extname(isHbs ? sourcePath.slice(0, -4) : sourcePath)
156
+ const isTextFile = TEXT_EXTENSIONS.has(ext)
157
+
158
+ if (isHbs) {
159
+ // Process Handlebars template
160
+ const template = await loadTemplate(sourcePath)
161
+ const content = template(data)
162
+
163
+ // Check for unresolved placeholders
164
+ const unresolved = findUnresolvedPlaceholders(content)
165
+ if (unresolved.length > 0 && options.onWarning) {
166
+ options.onWarning(
167
+ `Unresolved placeholders in ${path.basename(targetPath)}: ${unresolved.join(', ')}`
168
+ )
169
+ }
170
+
171
+ await fs.writeFile(targetPath, content)
172
+ } else if (isTextFile) {
173
+ // Process text files for simple variable replacements
174
+ let content = await fs.readFile(sourcePath, 'utf8')
175
+ // Simple {{var}} replacement without full Handlebars
176
+ for (const [key, value] of Object.entries(data)) {
177
+ if (typeof value === 'string') {
178
+ content = content.replaceAll(`{{${key}}}`, value)
179
+ }
180
+ }
181
+ await fs.writeFile(targetPath, content)
182
+ } else {
183
+ // Binary or non-template file - just copy
184
+ await fs.copyFile(sourcePath, targetPath)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Rename a leading single underscore to a dot: `_gitignore` → `.gitignore`,
190
+ * `_vscode` → `.vscode`.
191
+ *
192
+ * Dotfiles live in the template source under a `_`-prefixed name for two
193
+ * reasons: (1) so they aren't swept up by the template repo's own git ignore
194
+ * rules, and (2) — critically — so they survive `npm publish`. npm strips any
195
+ * file literally named `.gitignore` from the package tarball (it's treated as
196
+ * an ignore-source, not shippable content), so a template that needs to ship a
197
+ * `.gitignore` MUST store it as `_gitignore` and rename it at scaffold time.
198
+ * `.gitkeep` and other dotfiles ship fine; `.gitignore` is the trap.
199
+ *
200
+ * `__name` (double underscore) is left untouched, matching the prior
201
+ * directory-only behavior.
202
+ */
203
+ function dotfileRename(name) {
204
+ return name.startsWith('_') && !name.startsWith('__')
205
+ ? `.${name.slice(1)}`
206
+ : name
207
+ }
208
+
209
+ /**
210
+ * Compute a template file's scaffolded output name: strip the `.hbs`
211
+ * Handlebars suffix, then apply {@link dotfileRename}. So `_gitignore` →
212
+ * `.gitignore` and `_env.local.hbs` → `.env.local`.
213
+ */
214
+ function templateOutputName(sourceName) {
215
+ const base = sourceName.endsWith('.hbs')
216
+ ? sourceName.slice(0, -4)
217
+ : sourceName
218
+ return dotfileRename(base)
219
+ }
220
+
221
+ /**
222
+ * Copy a directory structure recursively, processing templates
223
+ *
224
+ * @param {string} sourcePath - Source template directory
225
+ * @param {string} targetPath - Destination directory
226
+ * @param {Object} data - Template variables
227
+ * @param {Object} options - Processing options
228
+ * @param {Function} options.onWarning - Warning callback
229
+ * @param {Function} options.onProgress - Progress callback
230
+ */
231
+ export async function copyTemplateDirectory(
232
+ sourcePath,
233
+ targetPath,
234
+ data,
235
+ options = {}
236
+ ) {
237
+ const { onWarning, onProgress, skip } = options
238
+
239
+ await fs.mkdir(targetPath, { recursive: true })
240
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true })
241
+
242
+ for (const entry of entries) {
243
+ const sourceName = entry.name
244
+
245
+ if (entry.isDirectory()) {
246
+ const sourceFullPath = path.join(sourcePath, sourceName)
247
+ // Rename _prefix directories to .prefix (e.g., _vscode → .vscode).
248
+ const targetFullPath = path.join(targetPath, dotfileRename(sourceName))
249
+
250
+ await copyTemplateDirectory(sourceFullPath, targetFullPath, data, {
251
+ onWarning,
252
+ onProgress,
253
+ skip
254
+ })
255
+ } else {
256
+ // Skip template.json as it's metadata for the template, not for the output
257
+ if (sourceName === 'template.json') {
258
+ continue
259
+ }
260
+
261
+ // Output name: strip `.hbs`, then rename a leading `_` to `.`
262
+ // (e.g. `_gitignore` → `.gitignore`). Used for both the skip check and
263
+ // the written filename so they can't drift.
264
+ const targetName = templateOutputName(sourceName)
265
+ if (skip?.includes(targetName)) {
266
+ continue
267
+ }
268
+
269
+ const sourceFullPath = path.join(sourcePath, sourceName)
270
+ const targetFullPath = path.join(targetPath, targetName)
271
+
272
+ if (onProgress) {
273
+ onProgress(`Creating ${targetName}`)
274
+ }
275
+
276
+ await processFile(sourceFullPath, targetFullPath, data, { onWarning })
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Enumerate the output paths a template directory would write, without
283
+ * touching disk. Mirrors `copyTemplateDirectory`'s naming rules:
284
+ * - `.hbs` extension is stripped
285
+ * - a leading `_` is renamed to `.` for both files and directories
286
+ * (e.g. `_gitignore` → `.gitignore`, `_vscode` → `.vscode`); `__name`
287
+ * is preserved
288
+ * - `template.json` is excluded
289
+ * - `skip` filenames are excluded by their post-rename name
290
+ *
291
+ * Returns relative paths (POSIX-style separators on POSIX, native on Windows
292
+ * — `path.join` semantics). Used by the in-place create flow to detect
293
+ * conflicts before any I/O begins.
294
+ *
295
+ * @param {string} sourcePath - Source template directory
296
+ * @param {Object} [options]
297
+ * @param {string[]} [options.skip] - Output filenames to exclude
298
+ * @returns {Promise<string[]>}
299
+ */
300
+ export async function enumerateTemplateOutputs(sourcePath, options = {}) {
301
+ const { skip = [] } = options
302
+ const outputs = []
303
+ await enumerateInto(sourcePath, '', outputs, skip)
304
+ return outputs
305
+ }
306
+
307
+ async function enumerateInto(sourcePath, relPath, outputs, skip) {
308
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true })
309
+ for (const entry of entries) {
310
+ const sourceName = entry.name
311
+ if (entry.isDirectory()) {
312
+ const targetName = dotfileRename(sourceName)
313
+ await enumerateInto(
314
+ path.join(sourcePath, sourceName),
315
+ relPath ? path.join(relPath, targetName) : targetName,
316
+ outputs,
317
+ skip
318
+ )
319
+ } else {
320
+ if (sourceName === 'template.json') continue
321
+ const outputName = templateOutputName(sourceName)
322
+ if (skip.includes(outputName)) continue
323
+ outputs.push(relPath ? path.join(relPath, outputName) : outputName)
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Clear the template cache
330
+ */
331
+ export function clearCache() {
332
+ templateCache.clear()
333
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Template resolver - parses template identifiers and determines source type
3
+ */
4
+
5
+ import { loadFrameworkIndex } from '../versions.js'
6
+
7
+ // Built-in templates (programmatic, not file-based)
8
+ export const BUILTIN_TEMPLATES = ['blank', 'starter', 'none']
9
+
10
+ /**
11
+ * THE OFFICIAL TEMPLATES THIS CLI SCAFFOLDS — one snapshot.
12
+ *
13
+ * `../framework-index.json` is written when the CLI is published: the
14
+ * templates' manifest (`{ id: { name, description, tags } }`) and, in
15
+ * `packages`, the `@uniweb/templates` version whose release holds their
16
+ * content. The interactive picker, `uniweb template list` and the download in
17
+ * `create` all read it, so the CLI offers exactly the templates it can fetch,
18
+ * and fetches the content released with the package versions it pins. A CLI
19
+ * goes on scaffolding the templates it was published with until it is
20
+ * updated — consistent, not newest.
21
+ *
22
+ * ⛔ Until 2026-10-08 a CLI run from a source checkout listed the templates
23
+ * repo's working manifest instead, and every CLI downloaded from the `latest`
24
+ * release. So a CLI could offer a template its release did not hold, and
25
+ * scaffold content newer than the packages it pinned: a template using a kit
26
+ * export the CLI's kit did not have yet failed to build.
27
+ *
28
+ * (An earlier version still hardcoded the picker's list, which silently
29
+ * drifted whenever a template was added — the reason no list is written here.)
30
+ */
31
+ const INDEX = loadFrameworkIndex()
32
+
33
+ // Official template metadata keyed by id. Empty when the snapshot did not load:
34
+ // an unknown name then falls through to the npm `@uniweb/template-<name>`
35
+ // lookup, the path for third-party templates, rather than to a stale list.
36
+ export const OFFICIAL_TEMPLATE_MAP =
37
+ INDEX?.templates && typeof INDEX.templates === 'object' ? INDEX.templates : {}
38
+
39
+ // Official template ids, e.g. ['marketing', 'docs', 'academic', …].
40
+ export const OFFICIAL_TEMPLATES = Object.keys(OFFICIAL_TEMPLATE_MAP)
41
+
42
+ // The release the official templates download from — `v<@uniweb/templates
43
+ // version>`, the repo's tag — or null when the snapshot names none.
44
+ const TEMPLATES_VERSION = INDEX?.packages?.['@uniweb/templates']?.version
45
+ export const OFFICIAL_TEMPLATES_RELEASE = TEMPLATES_VERSION ? `v${TEMPLATES_VERSION}` : null
46
+
47
+ // Built-in (programmatic) picker entries — not in the manifest; the CLI
48
+ // generates these itself. "Blank" trails the official templates.
49
+ const BUILTIN_LEAD_CHOICES = [
50
+ {
51
+ title: 'None',
52
+ value: 'none',
53
+ description: 'Foundation + site with no content'
54
+ },
55
+ {
56
+ title: 'Starter',
57
+ value: 'starter',
58
+ description: 'Foundation + site + sample content'
59
+ }
60
+ ]
61
+ const BLANK_CHOICE = {
62
+ title: 'Blank workspace',
63
+ value: 'blank',
64
+ description: 'Empty workspace — grow with uniweb add'
65
+ }
66
+
67
+ /**
68
+ * Build the choices for the interactive `create` template prompt: the
69
+ * built-in leads (None, Starter), then every official template from the
70
+ * shared manifest in manifest order, then Blank last. Deriving the official
71
+ * entries from OFFICIAL_TEMPLATE_MAP keeps the picker in lockstep with
72
+ * framework/templates/manifest.json — adding a template there (and
73
+ * republishing the CLI) is all it takes for it to appear here.
74
+ *
75
+ * @returns {Array<{title: string, value: string, description: string}>}
76
+ */
77
+ export function buildTemplateChoices() {
78
+ const official = Object.entries(OFFICIAL_TEMPLATE_MAP).map(([id, info]) => ({
79
+ title: info?.name || id,
80
+ value: id,
81
+ description: info?.description || ''
82
+ }))
83
+ return [...BUILTIN_LEAD_CHOICES, ...official, BLANK_CHOICE]
84
+ }
85
+
86
+ /**
87
+ * How many options the `create` picker shows at once: all of them, when the
88
+ * terminal has the rows. prompts shows 10 by default and scrolls the rest, and
89
+ * past ten choices that put most of the official templates below the fold —
90
+ * a template you have to scroll to find reads as one that is not there.
91
+ *
92
+ * Seven rows are kept back: the question, and the highlighted choice's
93
+ * description, which wraps under it when it is long.
94
+ *
95
+ * @param {number} count - the picker's choices
96
+ * @param {number} [rows] - the terminal's height, when it reports one
97
+ * @returns {number}
98
+ */
99
+ export function templatePickerPageSize(count, rows = process.stdout.rows) {
100
+ const room = Number.isFinite(rows) && rows > 0 ? rows - 7 : count
101
+ return Math.max(10, Math.min(count, room))
102
+ }
103
+
104
+ /**
105
+ * Parse a template identifier and determine its source type
106
+ *
107
+ * @param {string} identifier - Template identifier (e.g., 'blank', 'marketing', 'github:user/repo')
108
+ * @returns {Object} Parsed template info
109
+ */
110
+ export function parseTemplateId(identifier) {
111
+ if (!identifier || typeof identifier !== 'string') {
112
+ throw new Error('Template identifier is required')
113
+ }
114
+
115
+ identifier = identifier.trim()
116
+
117
+ // Built-in templates
118
+ if (BUILTIN_TEMPLATES.includes(identifier)) {
119
+ return {
120
+ type: 'builtin',
121
+ name: identifier
122
+ }
123
+ }
124
+
125
+ // Official templates from @uniweb/templates
126
+ if (OFFICIAL_TEMPLATES.includes(identifier)) {
127
+ return {
128
+ type: 'official',
129
+ name: identifier
130
+ }
131
+ }
132
+
133
+ // GitHub shorthand: github:user/repo or github:user/repo#ref
134
+ if (identifier.startsWith('github:')) {
135
+ const rest = identifier.slice(7) // Remove 'github:'
136
+ return parseGitHubIdentifier(rest)
137
+ }
138
+
139
+ // GitHub URL: https://github.com/user/repo
140
+ if (
141
+ identifier.startsWith('https://github.com/') ||
142
+ identifier.startsWith('http://github.com/')
143
+ ) {
144
+ const url = new URL(identifier)
145
+ const pathParts = url.pathname.split('/').filter(Boolean)
146
+ if (pathParts.length >= 2) {
147
+ const [owner, repo] = pathParts
148
+ // Check for tree/branch in URL
149
+ const treeIndex = pathParts.indexOf('tree')
150
+ const ref =
151
+ treeIndex >= 0 && pathParts[treeIndex + 1]
152
+ ? pathParts[treeIndex + 1]
153
+ : undefined
154
+ return {
155
+ type: 'github',
156
+ owner,
157
+ repo: repo.replace(/\.git$/, ''),
158
+ ref
159
+ }
160
+ }
161
+ throw new Error(`Invalid GitHub URL: ${identifier}`)
162
+ }
163
+
164
+ // Scoped npm package: @scope/package-name
165
+ if (identifier.startsWith('@')) {
166
+ return {
167
+ type: 'npm',
168
+ package: identifier
169
+ }
170
+ }
171
+
172
+ // Local path (relative, absolute, or home directory)
173
+ if (
174
+ identifier.startsWith('./') ||
175
+ identifier.startsWith('../') ||
176
+ identifier.startsWith('/') ||
177
+ identifier.startsWith('~')
178
+ ) {
179
+ return {
180
+ type: 'local',
181
+ path: identifier
182
+ }
183
+ }
184
+
185
+ // Unscoped name - assume it's an npm package with @uniweb/template- prefix
186
+ // This allows users to type `uniweb create foo --template blog` for @uniweb/template-blog
187
+ return {
188
+ type: 'npm',
189
+ package: `@uniweb/template-${identifier}`
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Parse GitHub identifier: user/repo or user/repo#ref
195
+ */
196
+ function parseGitHubIdentifier(identifier) {
197
+ const [repoPath, ref] = identifier.split('#')
198
+ const [owner, repo] = repoPath.split('/')
199
+
200
+ if (!owner || !repo) {
201
+ throw new Error(
202
+ `Invalid GitHub identifier: ${identifier}. Expected format: user/repo or user/repo#ref`
203
+ )
204
+ }
205
+
206
+ return {
207
+ type: 'github',
208
+ owner,
209
+ repo: repo.replace(/\.git$/, ''),
210
+ ref: ref || undefined
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Get a display name for a template identifier
216
+ */
217
+ export function getTemplateDisplayName(parsed) {
218
+ switch (parsed.type) {
219
+ case 'builtin':
220
+ return `Built-in: ${parsed.name}`
221
+ case 'official':
222
+ return `Official: ${parsed.name}`
223
+ case 'npm':
224
+ return parsed.package
225
+ case 'github':
226
+ return `${parsed.owner}/${parsed.repo}${parsed.ref ? `#${parsed.ref}` : ''}`
227
+ case 'local':
228
+ return `Local: ${parsed.path}`
229
+ default:
230
+ return 'Unknown'
231
+ }
232
+ }