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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|
+
}
|