uniweb 0.87.4 → 0.88.1
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 +1 -1
- package/package.json +6 -6
- package/partials/agents.md +3 -1
- package/src/commands/add.js +2 -5
- package/src/commands/template.js +35 -46
- package/src/framework-index.json +23 -98
- package/src/index.js +50 -50
- package/src/templates/fetchers/release.js +16 -62
- package/src/templates/index.js +15 -32
- package/src/templates/resolver.js +46 -73
- package/src/templates/validator.js +38 -186
- package/src/utils/scaffold.js +3 -20
- package/src/utils/update-check.js +73 -38
- package/src/versions.js +4 -1
|
@@ -14,43 +14,32 @@ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.
|
|
|
14
14
|
const TEMPLATES_REPO = 'uniweb/templates'
|
|
15
15
|
const GITHUB_API = 'https://api.github.com'
|
|
16
16
|
|
|
17
|
-
// Cache for manifest (avoid re-fetching in same session)
|
|
18
|
-
let manifestCache = null
|
|
19
|
-
|
|
20
17
|
/**
|
|
21
|
-
* Fetch
|
|
18
|
+
* Fetch manifest.json from one release of the official templates.
|
|
19
|
+
*
|
|
20
|
+
* ⛔ Always a named release — the one the CLI was published with — and never
|
|
21
|
+
* `latest`: the latest templates can be newer than the packages this CLI pins
|
|
22
|
+
* (templates/resolver.js, OFFICIAL_TEMPLATES_RELEASE).
|
|
22
23
|
*
|
|
23
24
|
* @param {Object} options - Fetch options
|
|
24
|
-
* @param {string} options.version -
|
|
25
|
+
* @param {string} options.version - The release tag, e.g. `v0.18.4`
|
|
25
26
|
* @param {Function} options.onProgress - Progress callback
|
|
26
|
-
* @returns {Promise<Object>} { version, templates,
|
|
27
|
+
* @returns {Promise<Object>} { version, templates, downloadUrlBase }
|
|
27
28
|
*/
|
|
28
29
|
export async function fetchManifest(options = {}) {
|
|
29
30
|
const { version, onProgress } = options
|
|
30
|
-
|
|
31
|
-
// Return cached manifest if available and no specific version requested
|
|
32
|
-
if (manifestCache && !version) {
|
|
33
|
-
return manifestCache
|
|
34
|
-
}
|
|
31
|
+
if (!version) throw new Error('fetchManifest needs the release tag to read')
|
|
35
32
|
|
|
36
33
|
onProgress?.('Fetching template manifest...')
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const releaseResponse = await fetchWithRetry(releaseUrl, {
|
|
44
|
-
headers: getGitHubHeaders()
|
|
45
|
-
})
|
|
35
|
+
const releaseResponse = await fetchWithRetry(
|
|
36
|
+
`${GITHUB_API}/repos/${TEMPLATES_REPO}/releases/tags/${version}`,
|
|
37
|
+
{ headers: getGitHubHeaders() }
|
|
38
|
+
)
|
|
46
39
|
|
|
47
40
|
if (!releaseResponse.ok) {
|
|
48
41
|
if (releaseResponse.status === 404) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
version
|
|
51
|
-
? `Release ${version} not found for ${TEMPLATES_REPO}`
|
|
52
|
-
: `No releases found for ${TEMPLATES_REPO}`
|
|
53
|
-
)
|
|
42
|
+
throw new Error(`Release ${version} not found for ${TEMPLATES_REPO}`)
|
|
54
43
|
}
|
|
55
44
|
await handleGitHubError(releaseResponse)
|
|
56
45
|
}
|
|
@@ -80,28 +69,20 @@ export async function fetchManifest(options = {}) {
|
|
|
80
69
|
|
|
81
70
|
const manifest = await manifestResponse.json()
|
|
82
71
|
|
|
83
|
-
|
|
84
|
-
const result = {
|
|
72
|
+
return {
|
|
85
73
|
version: release.tag_name,
|
|
86
74
|
templates: manifest.templates || {},
|
|
87
75
|
// Base URL for downloading template tarballs
|
|
88
76
|
downloadUrlBase: `https://github.com/${TEMPLATES_REPO}/releases/download/${release.tag_name}`
|
|
89
77
|
}
|
|
90
|
-
|
|
91
|
-
// Cache if this was a "latest" fetch
|
|
92
|
-
if (!version) {
|
|
93
|
-
manifestCache = result
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return result
|
|
97
78
|
}
|
|
98
79
|
|
|
99
80
|
/**
|
|
100
|
-
* Fetch a
|
|
81
|
+
* Fetch a template from one release of the official templates
|
|
101
82
|
*
|
|
102
83
|
* @param {string} name - Template name (e.g., 'marketing')
|
|
103
84
|
* @param {Object} options - Fetch options
|
|
104
|
-
* @param {string} options.version -
|
|
85
|
+
* @param {string} options.version - The release tag, e.g. `v0.18.4`
|
|
105
86
|
* @param {Function} options.onProgress - Progress callback
|
|
106
87
|
* @returns {Promise<Object>} { tempDir, version, metadata }
|
|
107
88
|
*/
|
|
@@ -166,33 +147,6 @@ export async function fetchOfficialTemplate(name, options = {}) {
|
|
|
166
147
|
}
|
|
167
148
|
}
|
|
168
149
|
|
|
169
|
-
/**
|
|
170
|
-
* List available templates from the latest release
|
|
171
|
-
*
|
|
172
|
-
* @param {Object} options - Fetch options
|
|
173
|
-
* @param {Function} options.onProgress - Progress callback
|
|
174
|
-
* @returns {Promise<Array>} List of template metadata
|
|
175
|
-
*/
|
|
176
|
-
export async function listOfficialTemplates(options = {}) {
|
|
177
|
-
try {
|
|
178
|
-
const manifest = await fetchManifest(options)
|
|
179
|
-
return Object.entries(manifest.templates).map(([id, info]) => ({
|
|
180
|
-
id,
|
|
181
|
-
...info
|
|
182
|
-
}))
|
|
183
|
-
} catch {
|
|
184
|
-
// Return empty list if can't fetch
|
|
185
|
-
return []
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Clear the manifest cache
|
|
191
|
-
*/
|
|
192
|
-
export function clearManifestCache() {
|
|
193
|
-
manifestCache = null
|
|
194
|
-
}
|
|
195
|
-
|
|
196
150
|
/**
|
|
197
151
|
* Get GitHub API headers
|
|
198
152
|
*/
|
package/src/templates/index.js
CHANGED
|
@@ -12,15 +12,13 @@ import {
|
|
|
12
12
|
getTemplateDisplayName,
|
|
13
13
|
BUILTIN_TEMPLATES,
|
|
14
14
|
OFFICIAL_TEMPLATES,
|
|
15
|
-
|
|
15
|
+
OFFICIAL_TEMPLATES_RELEASE,
|
|
16
|
+
buildTemplateChoices,
|
|
17
|
+
templatePickerPageSize
|
|
16
18
|
} from './resolver.js'
|
|
17
19
|
import { fetchNpmTemplate } from './fetchers/npm.js'
|
|
18
20
|
import { fetchGitHubTemplate } from './fetchers/github.js'
|
|
19
|
-
import {
|
|
20
|
-
fetchOfficialTemplate,
|
|
21
|
-
listOfficialTemplates
|
|
22
|
-
} from './fetchers/release.js'
|
|
23
|
-
import { validateTemplate } from './validator.js'
|
|
21
|
+
import { fetchOfficialTemplate } from './fetchers/release.js'
|
|
24
22
|
|
|
25
23
|
/**
|
|
26
24
|
* Resolve a template identifier and return the template path
|
|
@@ -61,12 +59,20 @@ export async function resolveTemplate(identifier, options = {}) {
|
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
/**
|
|
64
|
-
* Resolve an official template from
|
|
62
|
+
* Resolve an official template from the release this CLI was published with —
|
|
63
|
+
* never `latest`, so its content matches the package versions the scaffold pins
|
|
64
|
+
* (resolver.js, OFFICIAL_TEMPLATES_RELEASE).
|
|
65
65
|
*/
|
|
66
66
|
async function resolveOfficialTemplate(name, options = {}) {
|
|
67
67
|
const { onProgress } = options
|
|
68
68
|
|
|
69
|
+
if (!OFFICIAL_TEMPLATES_RELEASE) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"This CLI's framework index names no @uniweb/templates release to download from."
|
|
72
|
+
)
|
|
73
|
+
}
|
|
69
74
|
const { tempDir, baseTempDir, version } = await fetchOfficialTemplate(name, {
|
|
75
|
+
version: OFFICIAL_TEMPLATES_RELEASE,
|
|
70
76
|
onProgress
|
|
71
77
|
})
|
|
72
78
|
|
|
@@ -173,35 +179,12 @@ async function resolveLocalTemplate(templatePath, options = {}) {
|
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
|
|
176
|
-
/**
|
|
177
|
-
* List all available templates
|
|
178
|
-
*/
|
|
179
|
-
export async function listAvailableTemplates() {
|
|
180
|
-
const templates = []
|
|
181
|
-
|
|
182
|
-
// Official templates from GitHub releases
|
|
183
|
-
try {
|
|
184
|
-
const official = await listOfficialTemplates()
|
|
185
|
-
for (const t of official) {
|
|
186
|
-
templates.push({
|
|
187
|
-
type: 'official',
|
|
188
|
-
id: t.id,
|
|
189
|
-
name: t.name || t.id,
|
|
190
|
-
description: t.description || ''
|
|
191
|
-
})
|
|
192
|
-
}
|
|
193
|
-
} catch {
|
|
194
|
-
// Ignore errors - templates just won't be listed
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return templates
|
|
198
|
-
}
|
|
199
|
-
|
|
200
182
|
// Re-export for convenience
|
|
201
183
|
export {
|
|
202
184
|
parseTemplateId,
|
|
203
185
|
getTemplateDisplayName,
|
|
204
186
|
BUILTIN_TEMPLATES,
|
|
205
187
|
OFFICIAL_TEMPLATES,
|
|
206
|
-
buildTemplateChoices
|
|
188
|
+
buildTemplateChoices,
|
|
189
|
+
templatePickerPageSize
|
|
207
190
|
}
|
|
@@ -2,93 +2,48 @@
|
|
|
2
2
|
* Template resolver - parses template identifiers and determines source type
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { dirname, join } from 'node:path'
|
|
7
|
-
import { fileURLToPath } from 'node:url'
|
|
8
|
-
|
|
9
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
5
|
+
import { loadFrameworkIndex } from '../versions.js'
|
|
10
6
|
|
|
11
7
|
// Built-in templates (programmatic, not file-based)
|
|
12
8
|
export const BUILTIN_TEMPLATES = ['blank', 'starter', 'none']
|
|
13
9
|
|
|
14
10
|
/**
|
|
15
|
-
*
|
|
16
|
-
* tags } }`, keyed by template id.
|
|
17
|
-
*
|
|
18
|
-
* There are two sources of truth depending on where the CLI is running:
|
|
11
|
+
* THE OFFICIAL TEMPLATES THIS CLI SCAFFOLDS — one snapshot.
|
|
19
12
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
25
21
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* (consumed by versions.js), so both the template list and the version
|
|
32
|
-
* resolver share one source of truth.
|
|
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.
|
|
33
27
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* without waiting for a CLI republish.
|
|
37
|
-
*
|
|
38
|
-
* Both files store `templates` as the same `{ id: { name, description,
|
|
39
|
-
* tags } }` shape, so one map feeds both name-resolution
|
|
40
|
-
* (OFFICIAL_TEMPLATES) and the interactive picker (buildTemplateChoices) —
|
|
41
|
-
* neither hardcodes a list. (An earlier version returned only the keys and
|
|
42
|
-
* left the `create` picker with its own hardcoded array, which silently
|
|
43
|
-
* drifted out of sync whenever a template was added.)
|
|
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.)
|
|
44
30
|
*/
|
|
45
|
-
|
|
46
|
-
// Local dev: framework/templates/manifest.json relative to this file
|
|
47
|
-
// at framework/cli/src/templates/resolver.js
|
|
48
|
-
const workspaceManifest = join(
|
|
49
|
-
__dirname,
|
|
50
|
-
'..',
|
|
51
|
-
'..',
|
|
52
|
-
'..',
|
|
53
|
-
'templates',
|
|
54
|
-
'manifest.json'
|
|
55
|
-
)
|
|
56
|
-
const picked = tryReadTemplateMap(workspaceManifest)
|
|
57
|
-
if (picked) return picked
|
|
58
|
-
|
|
59
|
-
// Published CLI fallback: the framework index snapshot, one directory
|
|
60
|
-
// up at framework/cli/src/framework-index.json.
|
|
61
|
-
const indexPath = join(__dirname, '..', 'framework-index.json')
|
|
62
|
-
const fromIndex = tryReadTemplateMap(indexPath)
|
|
63
|
-
if (fromIndex) return fromIndex
|
|
64
|
-
|
|
65
|
-
// If both sources fail, return an empty map rather than a stale
|
|
66
|
-
// hardcoded list. An unknown template name then falls through to
|
|
67
|
-
// the npm `@uniweb/template-<name>` lookup path, which is the
|
|
68
|
-
// intended behavior for third-party templates.
|
|
69
|
-
return {}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function tryReadTemplateMap(path) {
|
|
73
|
-
try {
|
|
74
|
-
if (!statSync(path).isFile()) return null
|
|
75
|
-
const data = JSON.parse(readFileSync(path, 'utf8'))
|
|
76
|
-
if (data && data.templates && typeof data.templates === 'object') {
|
|
77
|
-
return data.templates
|
|
78
|
-
}
|
|
79
|
-
} catch {}
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
31
|
+
const INDEX = loadFrameworkIndex()
|
|
82
32
|
|
|
83
|
-
// Official template metadata keyed by id.
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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 : {}
|
|
88
38
|
|
|
89
39
|
// Official template ids, e.g. ['marketing', 'docs', 'academic', …].
|
|
90
40
|
export const OFFICIAL_TEMPLATES = Object.keys(OFFICIAL_TEMPLATE_MAP)
|
|
91
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
|
+
|
|
92
47
|
// Built-in (programmatic) picker entries — not in the manifest; the CLI
|
|
93
48
|
// generates these itself. "Blank" trails the official templates.
|
|
94
49
|
const BUILTIN_LEAD_CHOICES = [
|
|
@@ -128,6 +83,24 @@ export function buildTemplateChoices() {
|
|
|
128
83
|
return [...BUILTIN_LEAD_CHOICES, ...official, BLANK_CHOICE]
|
|
129
84
|
}
|
|
130
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
|
+
|
|
131
104
|
/**
|
|
132
105
|
* Parse a template identifier and determine its source type
|
|
133
106
|
*
|
|
@@ -1,84 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Template validation
|
|
2
|
+
* Template validation — reads template.json and finds the content directories
|
|
3
|
+
*
|
|
4
|
+
* ⛔ Two things this used to do are retired [2026-10-08]:
|
|
5
|
+
* - `compatible` (alias `uniweb`), a CLI version range a template declared. It
|
|
6
|
+
* was checked only when a caller passed the CLI's version, and none ever
|
|
7
|
+
* did. An official template now comes from the release its CLI was
|
|
8
|
+
* published with (resolver.js), so it cannot be newer than that CLI.
|
|
9
|
+
* - Unwrapping a package folder that keeps its code under `src/` — the layout of
|
|
10
|
+
* the v0.7 releases (`foundation/src/foundation.js`). Such a folder is now
|
|
11
|
+
* REFUSED, naming the fix (validateTemplate). ⛔ The unwrap went in 0.88.0
|
|
12
|
+
* with a note that the official templates were flat, but its condition was any
|
|
13
|
+
* `src/main.js` too, and `extensions/effects/` still relied on it: 0.88.0
|
|
14
|
+
* copied that folder nested, and every project made from `extensions` failed
|
|
15
|
+
* to build, saying only that it found no section types. The template is flat
|
|
16
|
+
* since templates 0.18.6.
|
|
3
17
|
*/
|
|
4
18
|
|
|
5
19
|
import fs from 'node:fs/promises'
|
|
6
20
|
import { existsSync } from 'node:fs'
|
|
7
21
|
import path from 'node:path'
|
|
8
22
|
|
|
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)
|
|
47
|
-
return current.major > min.major && min.major === 0
|
|
48
|
-
if (current.minor > min.minor) return true
|
|
49
|
-
if (current.minor < min.minor) return false
|
|
50
|
-
return current.patch >= min.patch
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (range.startsWith('~')) {
|
|
54
|
-
// ~x.y.z means >=x.y.z and <x.(y+1).0
|
|
55
|
-
const min = parseVersion(range.slice(1))
|
|
56
|
-
if (!min) return true
|
|
57
|
-
if (current.major !== min.major) return false
|
|
58
|
-
if (current.minor !== min.minor) return false
|
|
59
|
-
return current.patch >= min.patch
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Exact version or x.y.x pattern
|
|
63
|
-
if (range.includes('x')) {
|
|
64
|
-
const parts = range.split('.')
|
|
65
|
-
const min = parseVersion(range.replace(/x/g, '0'))
|
|
66
|
-
if (!min) return true
|
|
67
|
-
if (parts[0] !== 'x' && current.major !== min.major) return false
|
|
68
|
-
if (parts[1] !== 'x' && current.minor !== min.minor) return false
|
|
69
|
-
return true
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Exact match
|
|
73
|
-
const exact = parseVersion(range)
|
|
74
|
-
if (!exact) return true
|
|
75
|
-
return (
|
|
76
|
-
current.major === exact.major &&
|
|
77
|
-
current.minor === exact.minor &&
|
|
78
|
-
current.patch === exact.patch
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
23
|
/**
|
|
83
24
|
* Validation error with structured details
|
|
84
25
|
*/
|
|
@@ -96,20 +37,16 @@ export const ErrorCodes = {
|
|
|
96
37
|
INVALID_TEMPLATE_JSON: 'INVALID_TEMPLATE_JSON',
|
|
97
38
|
MISSING_CONTENT_DIR: 'MISSING_CONTENT_DIR',
|
|
98
39
|
MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD',
|
|
99
|
-
|
|
40
|
+
NESTED_PACKAGE_CODE: 'NESTED_PACKAGE_CODE'
|
|
100
41
|
}
|
|
101
42
|
|
|
102
43
|
/**
|
|
103
44
|
* Validate a template directory structure and metadata
|
|
104
45
|
*
|
|
105
46
|
* @param {string} templateRoot - Path to the template root (contains template.json)
|
|
106
|
-
* @param {Object} options - Validation options
|
|
107
|
-
* @param {string} options.uniwebVersion - Current Uniweb version to check compatibility
|
|
108
47
|
* @returns {Object} Parsed and validated template metadata
|
|
109
48
|
*/
|
|
110
|
-
export async function validateTemplate(templateRoot
|
|
111
|
-
const { uniwebVersion } = options
|
|
112
|
-
|
|
49
|
+
export async function validateTemplate(templateRoot) {
|
|
113
50
|
// Check for template.json
|
|
114
51
|
const metadataPath = path.join(templateRoot, 'template.json')
|
|
115
52
|
if (!existsSync(metadataPath)) {
|
|
@@ -142,18 +79,6 @@ export async function validateTemplate(templateRoot, options = {}) {
|
|
|
142
79
|
)
|
|
143
80
|
}
|
|
144
81
|
|
|
145
|
-
// Check version compatibility (support both `compatible` and `uniweb` fields)
|
|
146
|
-
const versionRange = metadata.compatible || metadata.uniweb
|
|
147
|
-
if (uniwebVersion && versionRange) {
|
|
148
|
-
if (!satisfiesVersion(uniwebVersion, versionRange)) {
|
|
149
|
-
throw new ValidationError(
|
|
150
|
-
`Template requires Uniweb ${versionRange}, but current version is ${uniwebVersion}`,
|
|
151
|
-
ErrorCodes.VERSION_MISMATCH,
|
|
152
|
-
{ required: versionRange, current: uniwebVersion }
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
82
|
// Format 2: content template — foundation/ and/or site/ directories alongside template.json
|
|
158
83
|
const contentDirs = resolveContentDirs(templateRoot, metadata)
|
|
159
84
|
|
|
@@ -165,6 +90,23 @@ export async function validateTemplate(templateRoot, options = {}) {
|
|
|
165
90
|
)
|
|
166
91
|
}
|
|
167
92
|
|
|
93
|
+
// A foundation or extension folder is copied over the package the CLI
|
|
94
|
+
// scaffolds, as it is. Code kept under src/ would land beside the scaffold's
|
|
95
|
+
// own empty sections/, and the project would fail to build saying only that it
|
|
96
|
+
// found no section types — so it stops here, saying what to move.
|
|
97
|
+
for (const { type, name, dir } of contentDirs) {
|
|
98
|
+
if (type !== 'foundation' && type !== 'extension') continue
|
|
99
|
+
if (['main.js', 'foundation.js'].some((file) => existsSync(path.join(dir, 'src', file)))) {
|
|
100
|
+
throw new ValidationError(
|
|
101
|
+
`The template's ${name}/ keeps its code in ${name}/src/, a layout this CLI does not scaffold. ` +
|
|
102
|
+
`A foundation or extension folder holds main.js, sections/ and styles.css at its root: ` +
|
|
103
|
+
`move ${name}/src/'s contents up into ${name}/.`,
|
|
104
|
+
ErrorCodes.NESTED_PACKAGE_CODE,
|
|
105
|
+
{ dir }
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
168
110
|
return {
|
|
169
111
|
...metadata,
|
|
170
112
|
format: 2,
|
|
@@ -178,7 +120,7 @@ export async function validateTemplate(templateRoot, options = {}) {
|
|
|
178
120
|
*
|
|
179
121
|
* @param {string} templateRoot - Root of the template (contains template.json)
|
|
180
122
|
* @param {Object} metadata - Parsed template.json
|
|
181
|
-
* @returns {Array<Object>} Content directories: [{ type, name, dir, foundation
|
|
123
|
+
* @returns {Array<Object>} Content directories: [{ type, name, dir, foundation? }]
|
|
182
124
|
*/
|
|
183
125
|
export function resolveContentDirs(templateRoot, metadata) {
|
|
184
126
|
const dirs = []
|
|
@@ -188,29 +130,19 @@ export function resolveContentDirs(templateRoot, metadata) {
|
|
|
188
130
|
for (const pkg of metadata.packages) {
|
|
189
131
|
const dir = path.join(templateRoot, pkg.name)
|
|
190
132
|
if (existsSync(dir)) {
|
|
191
|
-
|
|
133
|
+
dirs.push({
|
|
192
134
|
type: pkg.type,
|
|
193
135
|
name: pkg.name,
|
|
194
136
|
dir,
|
|
195
137
|
...(pkg.foundation ? { foundation: pkg.foundation } : {})
|
|
196
|
-
}
|
|
197
|
-
if (entry.type === 'foundation' || entry.type === 'extension') {
|
|
198
|
-
applyLegacyFoundationLayout(entry)
|
|
199
|
-
}
|
|
200
|
-
dirs.push(entry)
|
|
138
|
+
})
|
|
201
139
|
}
|
|
202
140
|
}
|
|
203
141
|
} else {
|
|
204
142
|
// Standard template: look for foundation/ and site/
|
|
205
143
|
const foundationDir = path.join(templateRoot, 'foundation')
|
|
206
144
|
if (existsSync(foundationDir)) {
|
|
207
|
-
|
|
208
|
-
type: 'foundation',
|
|
209
|
-
name: 'foundation',
|
|
210
|
-
dir: foundationDir
|
|
211
|
-
}
|
|
212
|
-
applyLegacyFoundationLayout(entry)
|
|
213
|
-
dirs.push(entry)
|
|
145
|
+
dirs.push({ type: 'foundation', name: 'foundation', dir: foundationDir })
|
|
214
146
|
}
|
|
215
147
|
|
|
216
148
|
const siteDir = path.join(templateRoot, 'site')
|
|
@@ -221,83 +153,3 @@ export function resolveContentDirs(templateRoot, metadata) {
|
|
|
221
153
|
|
|
222
154
|
return dirs
|
|
223
155
|
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Detect and unwrap the legacy foundation layout.
|
|
227
|
-
*
|
|
228
|
-
* Old templates (published in the `uniweb/templates` releases up to
|
|
229
|
-
* v0.7.x) shipped foundation content nested one level deeper, with the
|
|
230
|
-
* package source under `foundation/src/` and the user-authored
|
|
231
|
-
* declarations file named `foundation.js`:
|
|
232
|
-
*
|
|
233
|
-
* <template>/foundation/src/foundation.js
|
|
234
|
-
* <template>/foundation/src/sections/...
|
|
235
|
-
* <template>/foundation/src/components/...
|
|
236
|
-
*
|
|
237
|
-
* The current layout is flat — the foundation package root contains
|
|
238
|
-
* the source directly, and the declarations file is named `main.js`:
|
|
239
|
-
*
|
|
240
|
-
* <template>/foundation/main.js
|
|
241
|
-
* <template>/foundation/sections/...
|
|
242
|
-
*
|
|
243
|
-
* The CLI scaffolds the new flat shape into the project's `src/`
|
|
244
|
-
* directory, so an unmodified copy of an old-format template would
|
|
245
|
-
* land at `src/src/foundation.js` (extra `src/` layer + old name).
|
|
246
|
-
*
|
|
247
|
-
* This helper detects the legacy marker (`<dir>/src/foundation.js`),
|
|
248
|
-
* mutates the contentDir entry to point at the inner `src/` directory,
|
|
249
|
-
* and records a top-level rename so `foundation.js` is written as
|
|
250
|
-
* `main.js`. Once `uniweb/templates` is republished with the flat
|
|
251
|
-
* layout, this branch becomes a no-op.
|
|
252
|
-
*/
|
|
253
|
-
function applyLegacyFoundationLayout(entry) {
|
|
254
|
-
const innerSrc = path.join(entry.dir, 'src')
|
|
255
|
-
if (!existsSync(innerSrc)) return
|
|
256
|
-
|
|
257
|
-
const legacyMain = path.join(innerSrc, 'foundation.js')
|
|
258
|
-
const newMain = path.join(innerSrc, 'main.js')
|
|
259
|
-
if (!existsSync(legacyMain) && !existsSync(newMain)) return
|
|
260
|
-
|
|
261
|
-
entry.dir = innerSrc
|
|
262
|
-
if (existsSync(legacyMain)) {
|
|
263
|
-
entry.renames = { ...(entry.renames || {}), 'foundation.js': 'main.js' }
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Get list of available templates in a templates directory
|
|
269
|
-
*
|
|
270
|
-
* @param {string} templatesDir - Path to directory containing templates
|
|
271
|
-
* @returns {Array<Object>} List of template metadata
|
|
272
|
-
*/
|
|
273
|
-
export async function listTemplates(templatesDir) {
|
|
274
|
-
if (!existsSync(templatesDir)) {
|
|
275
|
-
return []
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const entries = await fs.readdir(templatesDir, { withFileTypes: true })
|
|
279
|
-
const templates = []
|
|
280
|
-
|
|
281
|
-
for (const entry of entries) {
|
|
282
|
-
if (!entry.isDirectory()) continue
|
|
283
|
-
|
|
284
|
-
const templatePath = path.join(templatesDir, entry.name)
|
|
285
|
-
const metadataPath = path.join(templatePath, 'template.json')
|
|
286
|
-
|
|
287
|
-
if (existsSync(metadataPath)) {
|
|
288
|
-
try {
|
|
289
|
-
const content = await fs.readFile(metadataPath, 'utf8')
|
|
290
|
-
const metadata = JSON.parse(content)
|
|
291
|
-
templates.push({
|
|
292
|
-
id: entry.name,
|
|
293
|
-
...metadata,
|
|
294
|
-
path: templatePath
|
|
295
|
-
})
|
|
296
|
-
} catch {
|
|
297
|
-
// Skip invalid templates
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return templates
|
|
303
|
-
}
|