uniweb 0.11.0 → 0.12.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 +20 -18
- package/package.json +6 -6
- package/partials/agents.md +163 -39
- package/src/commands/add.js +154 -237
- package/src/commands/build.js +14 -42
- package/src/commands/deploy.js +262 -3
- package/src/commands/docs.js +5 -6
- package/src/commands/doctor.js +21 -22
- package/src/commands/publish.js +255 -34
- package/src/framework-index.json +7 -7
- package/src/index.js +32 -15
- package/src/templates/validator.js +52 -4
- package/src/utils/auth.js +82 -6
- package/src/utils/names.js +9 -2
- package/src/utils/registry.js +88 -16
- package/src/utils/scaffold.js +67 -10
- package/src/utils/workspace.js +8 -46
- package/starter/site/pages/home/1-welcome.md.hbs +1 -1
- package/templates/foundation/{src/foundation.js.hbs → main.js.hbs} +1 -1
- package/templates/foundation/package.json.hbs +6 -6
- package/templates/foundation/{src/styles.css → styles.css} +1 -1
- package/templates/site/index.html.hbs +1 -1
- package/templates/site/theme.yml +1 -1
- package/templates/workspace/README.md.hbs +9 -7
- /package/starter/foundation/{src/foundation.js → main.js} +0 -0
- /package/starter/foundation/{src/sections → sections}/Section/index.jsx +0 -0
- /package/starter/foundation/{src/sections → sections}/Section/meta.js +0 -0
- /package/templates/foundation/{src/components → components}/.gitkeep +0 -0
- /package/templates/foundation/{src/sections → sections}/.gitkeep +0 -0
- /package/templates/site/{main.js → entry.js} +0 -0
package/src/utils/auth.js
CHANGED
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* User-global (not workspace-local) — you publish as yourself, not as a project.
|
|
6
6
|
*
|
|
7
7
|
* Used by `login`, `publish`, and `deploy` commands.
|
|
8
|
+
*
|
|
9
|
+
* Stored shape (auth.json):
|
|
10
|
+
* {
|
|
11
|
+
* token: string, // bearer JWT, sent in Authorization: Bearer <token>
|
|
12
|
+
* email: string, // signup_email; permanent, deliverable
|
|
13
|
+
* loginName?: string, // PHP session login_name; immutable per session model
|
|
14
|
+
* sub?: string, // memberId from JWT; permanent, numeric
|
|
15
|
+
* namespaces?: string[], // org handles the user can publish under
|
|
16
|
+
* expiresAt?: string // ISO timestamp; JWT exp claim
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The extra identity fields (loginName, sub, namespaces) are decoded from
|
|
20
|
+
* the JWT at write time and persisted alongside the token. They're cheap
|
|
21
|
+
* to derive (HS256 payload is base64url-encoded JSON), but persisting them
|
|
22
|
+
* means callers don't need to decode the JWT themselves to ask
|
|
23
|
+
* "who is the user?" — they just `readAuth()`.
|
|
8
24
|
*/
|
|
9
25
|
|
|
10
26
|
import { existsSync } from 'node:fs'
|
|
@@ -29,28 +45,88 @@ export function getAuthPath() {
|
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
48
|
+
* Decode the payload of a JWT. Returns `null` for malformed tokens.
|
|
49
|
+
* No signature verification — that's the server's job; we just want to
|
|
50
|
+
* read the claims locally.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} token
|
|
53
|
+
* @returns {Object|null}
|
|
54
|
+
*/
|
|
55
|
+
export function decodeJwtPayload(token) {
|
|
56
|
+
if (typeof token !== 'string') return null
|
|
57
|
+
const parts = token.split('.')
|
|
58
|
+
if (parts.length < 2) return null
|
|
59
|
+
try {
|
|
60
|
+
// base64url → base64
|
|
61
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
|
62
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'))
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read stored credentials. If the persisted record predates the
|
|
70
|
+
* identity-fields plumbing (no loginName/sub/namespaces) but has a
|
|
71
|
+
* token, derive the missing fields from the JWT in memory so callers
|
|
72
|
+
* see a consistent shape regardless of write generation.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Promise<{ token: string, email: string, loginName?: string, sub?: string, namespaces?: string[], expiresAt?: string } | null>}
|
|
34
75
|
*/
|
|
35
76
|
export async function readAuth() {
|
|
36
77
|
const authPath = getAuthPath()
|
|
37
78
|
if (!existsSync(authPath)) return null
|
|
38
79
|
|
|
80
|
+
let auth
|
|
39
81
|
try {
|
|
40
|
-
|
|
82
|
+
auth = JSON.parse(await readFile(authPath, 'utf8'))
|
|
41
83
|
} catch {
|
|
42
84
|
return null
|
|
43
85
|
}
|
|
86
|
+
|
|
87
|
+
// Backfill identity fields from the JWT for older auth.json files
|
|
88
|
+
// that were written before this plumbing existed. Read-only — the
|
|
89
|
+
// file isn't rewritten until the next login.
|
|
90
|
+
if (auth?.token && (auth.loginName === undefined || auth.sub === undefined || auth.namespaces === undefined)) {
|
|
91
|
+
const payload = decodeJwtPayload(auth.token)
|
|
92
|
+
if (payload) {
|
|
93
|
+
if (auth.loginName === undefined && typeof payload.loginName === 'string') {
|
|
94
|
+
auth.loginName = payload.loginName
|
|
95
|
+
}
|
|
96
|
+
if (auth.sub === undefined && typeof payload.sub === 'string') {
|
|
97
|
+
auth.sub = payload.sub
|
|
98
|
+
}
|
|
99
|
+
if (auth.namespaces === undefined && Array.isArray(payload.namespaces)) {
|
|
100
|
+
auth.namespaces = payload.namespaces
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return auth
|
|
44
106
|
}
|
|
45
107
|
|
|
46
108
|
/**
|
|
47
|
-
* Write credentials to storage.
|
|
48
|
-
*
|
|
109
|
+
* Write credentials to storage. Decodes the JWT and persists the
|
|
110
|
+
* identity claims (loginName, sub, namespaces) alongside the token,
|
|
111
|
+
* so future `readAuth()` calls don't have to decode it themselves.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ token: string, email: string, expiresAt?: string }} auth - Caller passes the basics; identity fields are derived.
|
|
49
114
|
*/
|
|
50
115
|
export async function writeAuth(auth) {
|
|
116
|
+
const record = { ...auth }
|
|
117
|
+
|
|
118
|
+
if (record.token) {
|
|
119
|
+
const payload = decodeJwtPayload(record.token)
|
|
120
|
+
if (payload) {
|
|
121
|
+
if (typeof payload.loginName === 'string') record.loginName = payload.loginName
|
|
122
|
+
if (typeof payload.sub === 'string') record.sub = payload.sub
|
|
123
|
+
if (Array.isArray(payload.namespaces)) record.namespaces = payload.namespaces
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
51
127
|
const dir = getAuthDir()
|
|
52
128
|
await mkdir(dir, { recursive: true })
|
|
53
|
-
await writeFile(join(dir, 'auth.json'), JSON.stringify(
|
|
129
|
+
await writeFile(join(dir, 'auth.json'), JSON.stringify(record, null, 2))
|
|
54
130
|
}
|
|
55
131
|
|
|
56
132
|
/**
|
package/src/utils/names.js
CHANGED
|
@@ -14,12 +14,19 @@ import { join } from 'node:path'
|
|
|
14
14
|
* Names that must not be used as package names.
|
|
15
15
|
* - JS module keywords: default, undefined, null, true, false
|
|
16
16
|
* - Node/filesystem: node_modules, package
|
|
17
|
-
* - Common
|
|
17
|
+
* - Common build-output directories: dist, build (would shadow `dist/` /
|
|
18
|
+
* `build/` references)
|
|
19
|
+
*
|
|
20
|
+
* Note: `src` is NOT reserved. A foundation in `src/` whose package name is
|
|
21
|
+
* also `src` is the default scaffold pattern — folder name and package name
|
|
22
|
+
* match. Multi-foundation co-located workspaces still use suffixes
|
|
23
|
+
* (`<project>-src`) because pnpm requires workspace-unique package names;
|
|
24
|
+
* that's a real constraint, not aesthetic.
|
|
18
25
|
*/
|
|
19
26
|
const RESERVED_NAMES = new Set([
|
|
20
27
|
'default', 'undefined', 'null', 'true', 'false',
|
|
21
28
|
'node_modules', 'package',
|
|
22
|
-
'
|
|
29
|
+
'dist', 'build',
|
|
23
30
|
])
|
|
24
31
|
|
|
25
32
|
/**
|
package/src/utils/registry.js
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local Foundation Registry
|
|
3
3
|
*
|
|
4
|
-
* Manages published foundations in .unicloud/registry/.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Manages published foundations in .unicloud/registry/. The on-disk
|
|
5
|
+
* shape mirrors uniweb-edge's registry index (versions as an array of
|
|
6
|
+
* { version, ... } objects, plus top-level namespace and latest) so
|
|
7
|
+
* `--local` exercises the same data shape that ships in production.
|
|
7
8
|
*
|
|
8
9
|
* Layout:
|
|
9
10
|
* .unicloud/
|
|
10
11
|
* registry/
|
|
11
|
-
* index.json #
|
|
12
|
+
* index.json # see "Index format" below
|
|
12
13
|
* packages/
|
|
13
14
|
* name/
|
|
14
15
|
* 1.0.0/
|
|
15
16
|
* foundation.js
|
|
16
17
|
* schema.json
|
|
17
18
|
* assets/...
|
|
19
|
+
*
|
|
20
|
+
* Index format:
|
|
21
|
+
* {
|
|
22
|
+
* "@ns/name": {
|
|
23
|
+
* namespace: "ns",
|
|
24
|
+
* versions: [
|
|
25
|
+
* { version: "1.0.0", publishedAt, publishedBy, ... },
|
|
26
|
+
* ...
|
|
27
|
+
* ],
|
|
28
|
+
* latest: "1.0.0"
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* Legacy entries (versions as an object keyed by version) are migrated
|
|
33
|
+
* to this shape on read. The next write persists the new shape.
|
|
18
34
|
*/
|
|
19
35
|
|
|
20
36
|
import { existsSync } from 'node:fs'
|
|
@@ -37,7 +53,7 @@ export function getRegistryDir(startDir = process.cwd()) {
|
|
|
37
53
|
|
|
38
54
|
/**
|
|
39
55
|
* Sanitize a package name for filesystem use.
|
|
40
|
-
* '@org/pkg' → '
|
|
56
|
+
* '@org/pkg' → 'org/pkg'
|
|
41
57
|
* @param {string} name
|
|
42
58
|
* @returns {string}
|
|
43
59
|
*/
|
|
@@ -46,6 +62,38 @@ function sanitizeName(name) {
|
|
|
46
62
|
return name.startsWith('@') ? name.slice(1) : name
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Parse the namespace out of a scoped package name. '@org/pkg' → 'org';
|
|
67
|
+
* unscoped → ''.
|
|
68
|
+
* @param {string} name
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function parseNamespace(name) {
|
|
72
|
+
const m = /^@([a-z0-9_-]+)\//.exec(name)
|
|
73
|
+
return m ? m[1] : ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Migrate a legacy index entry (versions as object, no namespace/latest)
|
|
78
|
+
* to the current shape (versions as array, namespace + latest at top).
|
|
79
|
+
* Mutates and returns the entry.
|
|
80
|
+
*/
|
|
81
|
+
function normalizeEntry(name, entry) {
|
|
82
|
+
if (!entry) return entry
|
|
83
|
+
if (entry.versions && !Array.isArray(entry.versions) && typeof entry.versions === 'object') {
|
|
84
|
+
entry.versions = Object.entries(entry.versions).map(([version, data]) => ({
|
|
85
|
+
version,
|
|
86
|
+
...data,
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(entry.versions)) entry.versions = []
|
|
90
|
+
if (!entry.namespace) entry.namespace = parseNamespace(name)
|
|
91
|
+
if (!entry.latest && entry.versions.length > 0) {
|
|
92
|
+
entry.latest = entry.versions[entry.versions.length - 1].version
|
|
93
|
+
}
|
|
94
|
+
return entry
|
|
95
|
+
}
|
|
96
|
+
|
|
49
97
|
/**
|
|
50
98
|
* Local registry — stores published foundations in .unicloud/registry/
|
|
51
99
|
*/
|
|
@@ -58,7 +106,11 @@ export class LocalRegistry {
|
|
|
58
106
|
|
|
59
107
|
async _readIndex() {
|
|
60
108
|
if (!existsSync(this.indexPath)) return {}
|
|
61
|
-
|
|
109
|
+
const raw = JSON.parse(await readFile(this.indexPath, 'utf8'))
|
|
110
|
+
for (const name of Object.keys(raw)) {
|
|
111
|
+
normalizeEntry(name, raw[name])
|
|
112
|
+
}
|
|
113
|
+
return raw
|
|
62
114
|
}
|
|
63
115
|
|
|
64
116
|
async _writeIndex(index) {
|
|
@@ -74,17 +126,20 @@ export class LocalRegistry {
|
|
|
74
126
|
*/
|
|
75
127
|
async exists(name, version) {
|
|
76
128
|
const index = await this._readIndex()
|
|
77
|
-
|
|
129
|
+
const versions = index[name]?.versions
|
|
130
|
+
if (!Array.isArray(versions)) return false
|
|
131
|
+
return versions.some(v => v.version === version)
|
|
78
132
|
}
|
|
79
133
|
|
|
80
134
|
/**
|
|
81
|
-
* Get all published versions for a package
|
|
135
|
+
* Get all published versions for a package as an array of
|
|
136
|
+
* `{ version, publishedAt, ... }` entries (matches uniweb-edge).
|
|
82
137
|
* @param {string} name
|
|
83
|
-
* @returns {Promise<
|
|
138
|
+
* @returns {Promise<Array>}
|
|
84
139
|
*/
|
|
85
140
|
async getVersions(name) {
|
|
86
141
|
const index = await this._readIndex()
|
|
87
|
-
return index[name]?.versions ||
|
|
142
|
+
return index[name]?.versions || []
|
|
88
143
|
}
|
|
89
144
|
|
|
90
145
|
/**
|
|
@@ -104,12 +159,26 @@ export class LocalRegistry {
|
|
|
104
159
|
|
|
105
160
|
const index = await this._readIndex()
|
|
106
161
|
if (!index[name]) {
|
|
107
|
-
index[name] = {
|
|
162
|
+
index[name] = {
|
|
163
|
+
namespace: parseNamespace(name),
|
|
164
|
+
versions: [],
|
|
165
|
+
latest: null,
|
|
166
|
+
}
|
|
108
167
|
}
|
|
109
|
-
|
|
168
|
+
|
|
169
|
+
const versionEntry = {
|
|
170
|
+
version,
|
|
110
171
|
publishedAt: new Date().toISOString(),
|
|
111
172
|
...metadata,
|
|
112
173
|
}
|
|
174
|
+
const existingIdx = index[name].versions.findIndex(v => v.version === version)
|
|
175
|
+
if (existingIdx >= 0) {
|
|
176
|
+
index[name].versions[existingIdx] = versionEntry
|
|
177
|
+
} else {
|
|
178
|
+
index[name].versions.push(versionEntry)
|
|
179
|
+
}
|
|
180
|
+
index[name].latest = version
|
|
181
|
+
|
|
113
182
|
await this._writeIndex(index)
|
|
114
183
|
}
|
|
115
184
|
|
|
@@ -165,20 +234,23 @@ export class RemoteRegistry {
|
|
|
165
234
|
async exists(name, version) {
|
|
166
235
|
try {
|
|
167
236
|
const index = await this._fetchIndex()
|
|
168
|
-
|
|
237
|
+
const versions = index[name]?.versions
|
|
238
|
+
if (!Array.isArray(versions)) return false
|
|
239
|
+
return versions.some(v => v.version === version)
|
|
169
240
|
} catch {
|
|
170
241
|
return false
|
|
171
242
|
}
|
|
172
243
|
}
|
|
173
244
|
|
|
174
245
|
/**
|
|
175
|
-
* Get all published versions for a package
|
|
246
|
+
* Get all published versions for a package as an array of
|
|
247
|
+
* `{ version, publishedAt, ... }` entries.
|
|
176
248
|
* @param {string} name
|
|
177
|
-
* @returns {Promise<
|
|
249
|
+
* @returns {Promise<Array>}
|
|
178
250
|
*/
|
|
179
251
|
async getVersions(name) {
|
|
180
252
|
const index = await this._fetchIndex()
|
|
181
|
-
return index[name]?.versions ||
|
|
253
|
+
return index[name]?.versions || []
|
|
182
254
|
}
|
|
183
255
|
|
|
184
256
|
/**
|
package/src/utils/scaffold.js
CHANGED
|
@@ -94,12 +94,23 @@ export async function scaffoldSite(targetDir, context, options = {}) {
|
|
|
94
94
|
* Apply content overlay from a content directory onto a target
|
|
95
95
|
*
|
|
96
96
|
* Content files overwrite scaffolded defaults. Structural files
|
|
97
|
-
* (package.json, vite.config.js,
|
|
97
|
+
* (package.json, vite.config.js, entry.js, index.html) are NOT overwritten.
|
|
98
|
+
*
|
|
99
|
+
* Note: a foundation's `main.js` (the user-authored declarations file) is
|
|
100
|
+
* NOT structural — templates legitimately provide their own `main.js` to
|
|
101
|
+
* override the empty scaffold default. The site's `entry.js` (formerly
|
|
102
|
+
* `main.js`) IS structural — the boilerplate `start({...})` is identical
|
|
103
|
+
* across all sites and shouldn't be overwritten.
|
|
98
104
|
*
|
|
99
105
|
* @param {string} contentDir - Source content directory (e.g., starter/foundation/)
|
|
100
106
|
* @param {string} targetDir - Target directory to overlay onto
|
|
101
107
|
* @param {Object} context - Handlebars context for .hbs files
|
|
102
108
|
* @param {Object} [options] - Processing options
|
|
109
|
+
* @param {Object} [options.renames] - Top-level filename remapping
|
|
110
|
+
* (e.g. `{ 'foundation.js': 'main.js' }`). Applied only at depth 0
|
|
111
|
+
* so renames don't accidentally rewrite same-named files in nested
|
|
112
|
+
* directories. Used to migrate legacy `foundation/foundation.js`
|
|
113
|
+
* templates onto the new flat `src/main.js` layout.
|
|
103
114
|
*/
|
|
104
115
|
export async function applyContent(contentDir, targetDir, context, options = {}) {
|
|
105
116
|
if (!existsSync(contentDir)) return
|
|
@@ -110,7 +121,7 @@ export async function applyContent(contentDir, targetDir, context, options = {})
|
|
|
110
121
|
const STRUCTURAL_FILES = new Set([
|
|
111
122
|
'package.json',
|
|
112
123
|
'vite.config.js',
|
|
113
|
-
'
|
|
124
|
+
'entry.js',
|
|
114
125
|
'index.html',
|
|
115
126
|
'.gitignore',
|
|
116
127
|
])
|
|
@@ -121,29 +132,41 @@ export async function applyContent(contentDir, targetDir, context, options = {})
|
|
|
121
132
|
'site.yml': ['name', 'foundation'],
|
|
122
133
|
}
|
|
123
134
|
|
|
124
|
-
await copyContentRecursive(contentDir, targetDir, context, STRUCTURAL_FILES, MERGE_FILES, options)
|
|
135
|
+
await copyContentRecursive(contentDir, targetDir, context, STRUCTURAL_FILES, MERGE_FILES, options, 0)
|
|
125
136
|
}
|
|
126
137
|
|
|
127
138
|
/**
|
|
128
|
-
* Recursively copy content files, skipping structural files
|
|
139
|
+
* Recursively copy content files, skipping structural files.
|
|
140
|
+
*
|
|
141
|
+
* `depth` is tracked so the `renames` map (passed via options) only
|
|
142
|
+
* applies at depth 0 — the top of the content directory. Without this
|
|
143
|
+
* guard, a rename like `foundation.js → main.js` would also rewrite a
|
|
144
|
+
* nested `sections/foo/foundation.js` if one existed, which is not the
|
|
145
|
+
* intent.
|
|
129
146
|
*/
|
|
130
|
-
async function copyContentRecursive(sourceDir, targetDir, context, structuralFiles, mergeFiles, options) {
|
|
147
|
+
async function copyContentRecursive(sourceDir, targetDir, context, structuralFiles, mergeFiles, options, depth = 0) {
|
|
131
148
|
await fs.mkdir(targetDir, { recursive: true })
|
|
132
149
|
|
|
133
150
|
const entries = readdirSync(sourceDir, { withFileTypes: true })
|
|
151
|
+
const renames = (depth === 0 && options.renames) || null
|
|
134
152
|
|
|
135
153
|
for (const entry of entries) {
|
|
136
154
|
const sourcePath = join(sourceDir, entry.name)
|
|
137
155
|
|
|
138
156
|
if (entry.isDirectory()) {
|
|
139
157
|
const targetSubDir = join(targetDir, entry.name)
|
|
140
|
-
await copyContentRecursive(sourcePath, targetSubDir, context, structuralFiles, mergeFiles, options)
|
|
158
|
+
await copyContentRecursive(sourcePath, targetSubDir, context, structuralFiles, mergeFiles, options, depth + 1)
|
|
141
159
|
} else {
|
|
142
160
|
// Determine the output filename (strip .hbs extension)
|
|
143
|
-
|
|
161
|
+
let outputName = entry.name.endsWith('.hbs')
|
|
144
162
|
? entry.name.slice(0, -4)
|
|
145
163
|
: entry.name
|
|
146
164
|
|
|
165
|
+
// Apply top-level rename (e.g. legacy `foundation.js` → `main.js`)
|
|
166
|
+
if (renames && Object.prototype.hasOwnProperty.call(renames, outputName)) {
|
|
167
|
+
outputName = renames[outputName]
|
|
168
|
+
}
|
|
169
|
+
|
|
147
170
|
// Skip structural files
|
|
148
171
|
if (structuralFiles.has(outputName)) continue
|
|
149
172
|
|
|
@@ -184,8 +207,17 @@ async function copyContentRecursive(sourceDir, targetDir, context, structuralFil
|
|
|
184
207
|
for (const key of preserveKeys) {
|
|
185
208
|
if (existing[key] === undefined) continue
|
|
186
209
|
const baseLine = matchTopLevelLine(existingContent, key)
|
|
187
|
-
if (baseLine)
|
|
210
|
+
if (!baseLine) continue
|
|
211
|
+
// If the new content carries the key, replace its line with
|
|
212
|
+
// the scaffolded value (preserving the user's project/foundation
|
|
213
|
+
// choice). Otherwise insert the line — older content templates
|
|
214
|
+
// (notably `docs/site/site.yml.hbs`) omit `foundation:` entirely,
|
|
215
|
+
// and dropping it leaves the site without a foundation ref so
|
|
216
|
+
// the entry's `import '#foundation/styles'` fails at build time.
|
|
217
|
+
if (matchTopLevelLine(merged, key)) {
|
|
188
218
|
merged = replaceTopLevelLine(merged, key, baseLine)
|
|
219
|
+
} else {
|
|
220
|
+
merged = insertTopLevelLine(merged, baseLine)
|
|
189
221
|
}
|
|
190
222
|
}
|
|
191
223
|
|
|
@@ -207,14 +239,20 @@ async function copyContentRecursive(sourceDir, targetDir, context, structuralFil
|
|
|
207
239
|
/**
|
|
208
240
|
* Apply the built-in starter content
|
|
209
241
|
*
|
|
242
|
+
* The starter ships its foundation content under `cli/starter/foundation/`
|
|
243
|
+
* (a description of the *kind* of content), and is applied into whatever
|
|
244
|
+
* folder the workspace uses for the foundation package — which is `src/`
|
|
245
|
+
* in the current single-foundation layout, not `foundation/`. Callers
|
|
246
|
+
* can override via options when scaffolding multi-foundation workspaces.
|
|
247
|
+
*
|
|
210
248
|
* @param {string} projectDir - Root project directory
|
|
211
249
|
* @param {Object} context - Template context
|
|
212
250
|
* @param {Object} [options] - Processing options
|
|
213
|
-
* @param {string} [options.foundationDir] - Foundation directory name (default: '
|
|
251
|
+
* @param {string} [options.foundationDir] - Foundation directory name (default: 'src')
|
|
214
252
|
* @param {string} [options.siteDir] - Site directory name (default: 'site')
|
|
215
253
|
*/
|
|
216
254
|
export async function applyStarter(projectDir, context, options = {}) {
|
|
217
|
-
const foundationDir = options.foundationDir || '
|
|
255
|
+
const foundationDir = options.foundationDir || 'src'
|
|
218
256
|
const siteDir = options.siteDir || 'site'
|
|
219
257
|
|
|
220
258
|
// Apply foundation starter content
|
|
@@ -260,6 +298,25 @@ function replaceTopLevelLine(content, key, replacement) {
|
|
|
260
298
|
)
|
|
261
299
|
}
|
|
262
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Insert a top-level YAML line into a content string.
|
|
303
|
+
*
|
|
304
|
+
* Used by the merge path when the content template omits a key that
|
|
305
|
+
* the scaffolded base file declared (e.g. an older `site.yml.hbs` with
|
|
306
|
+
* no `foundation:` line). Inserts immediately after the `name:` line
|
|
307
|
+
* if one exists — that's the conventional position for site
|
|
308
|
+
* configuration — and otherwise prepends to the file. The original
|
|
309
|
+
* trailing newline (or lack thereof) of the file is preserved.
|
|
310
|
+
*/
|
|
311
|
+
function insertTopLevelLine(content, line) {
|
|
312
|
+
const nameMatch = content.match(/^name:.*$/m)
|
|
313
|
+
if (nameMatch) {
|
|
314
|
+
const idx = nameMatch.index + nameMatch[0].length
|
|
315
|
+
return content.slice(0, idx) + '\n' + line + content.slice(idx)
|
|
316
|
+
}
|
|
317
|
+
return line + '\n' + content
|
|
318
|
+
}
|
|
319
|
+
|
|
263
320
|
/**
|
|
264
321
|
* Resolve a dependency version string from a template.json entry.
|
|
265
322
|
*
|
package/src/utils/workspace.js
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
|
10
10
|
import { readFile } from 'node:fs/promises'
|
|
11
11
|
import { resolve, dirname, join } from 'node:path'
|
|
12
12
|
import yaml from 'js-yaml'
|
|
13
|
+
import { classifyPackage as classifyPackageSync } from '@uniweb/build'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Check if a directory is a workspace root.
|
|
@@ -124,38 +125,17 @@ export async function getWorkspacePackages(workspaceRoot) {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
/**
|
|
127
|
-
* Classify a package as foundation, site, or unknown
|
|
128
|
+
* Classify a package as foundation, site, or unknown.
|
|
128
129
|
*
|
|
129
|
-
*
|
|
130
|
-
* -
|
|
131
|
-
* -
|
|
132
|
-
*
|
|
133
|
-
* Note: Sites also have @uniweb/build for the Vite plugin, so we check
|
|
134
|
-
* for @uniweb/runtime first to distinguish them.
|
|
130
|
+
* Re-exports the canonical (sync) classifier from @uniweb/build, kept
|
|
131
|
+
* async-shaped here so existing call sites continue to work without an
|
|
132
|
+
* await-removal sweep. New code should import directly from @uniweb/build.
|
|
135
133
|
*
|
|
136
134
|
* @param {string} packagePath - Full path to package directory
|
|
137
135
|
* @returns {Promise<'foundation'|'site'|null>}
|
|
138
136
|
*/
|
|
139
137
|
export async function classifyPackage(packagePath) {
|
|
140
|
-
|
|
141
|
-
if (!existsSync(pkgJsonPath)) return null
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
const pkg = JSON.parse(await readFile(pkgJsonPath, 'utf-8'))
|
|
145
|
-
|
|
146
|
-
// Site: has @uniweb/runtime in dependencies (check first - more specific)
|
|
147
|
-
if (pkg.dependencies?.['@uniweb/runtime']) {
|
|
148
|
-
return 'site'
|
|
149
|
-
}
|
|
150
|
-
// Foundation: has @uniweb/build in devDependencies (and not a site)
|
|
151
|
-
if (pkg.devDependencies?.['@uniweb/build']) {
|
|
152
|
-
return 'foundation'
|
|
153
|
-
}
|
|
154
|
-
} catch {
|
|
155
|
-
// Ignore parse errors
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return null
|
|
138
|
+
return classifyPackageSync(packagePath)
|
|
159
139
|
}
|
|
160
140
|
|
|
161
141
|
/**
|
|
@@ -165,16 +145,7 @@ export async function classifyPackage(packagePath) {
|
|
|
165
145
|
*/
|
|
166
146
|
export async function findFoundations(workspaceRoot) {
|
|
167
147
|
const packages = await getWorkspacePackages(workspaceRoot)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
for (const pkg of packages) {
|
|
171
|
-
const fullPath = join(workspaceRoot, pkg)
|
|
172
|
-
if ((await classifyPackage(fullPath)) === 'foundation') {
|
|
173
|
-
foundations.push(pkg)
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return foundations
|
|
148
|
+
return packages.filter(pkg => classifyPackageSync(join(workspaceRoot, pkg)) === 'foundation')
|
|
178
149
|
}
|
|
179
150
|
|
|
180
151
|
/**
|
|
@@ -184,16 +155,7 @@ export async function findFoundations(workspaceRoot) {
|
|
|
184
155
|
*/
|
|
185
156
|
export async function findSites(workspaceRoot) {
|
|
186
157
|
const packages = await getWorkspacePackages(workspaceRoot)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
for (const pkg of packages) {
|
|
190
|
-
const fullPath = join(workspaceRoot, pkg)
|
|
191
|
-
if ((await classifyPackage(fullPath)) === 'site') {
|
|
192
|
-
sites.push(pkg)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return sites
|
|
158
|
+
return packages.filter(pkg => classifyPackageSync(join(workspaceRoot, pkg)) === 'site')
|
|
197
159
|
}
|
|
198
160
|
|
|
199
161
|
/**
|
|
@@ -8,7 +8,7 @@ align: center
|
|
|
8
8
|
|
|
9
9
|
## Your Uniweb project is ready
|
|
10
10
|
|
|
11
|
-
This is a minimal starting point for your Uniweb project. Edit the content in `site/pages/` and build section types in `
|
|
11
|
+
This is a minimal starting point for your Uniweb project. Edit the content in `site/pages/` and build section types in `src/sections/`.
|
|
12
12
|
|
|
13
13
|
[About](/about)
|
|
14
14
|
[Documentation](https://github.com/uniweb)
|
|
@@ -19,7 +19,7 @@ export default {
|
|
|
19
19
|
{{else}}
|
|
20
20
|
export default {
|
|
21
21
|
// ─── Layout ─────────────────────────────────────────────────────────────────
|
|
22
|
-
// Create layouts in
|
|
22
|
+
// Create layouts in layouts/MyLayout/index.jsx (auto-discovered).
|
|
23
23
|
// defaultLayout: 'MyLayout',
|
|
24
24
|
|
|
25
25
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"description": "{{projectName}} foundation",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
6
|
+
"main": "./_entry.generated.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./
|
|
9
|
-
"./styles": "./
|
|
8
|
+
".": "./_entry.generated.js",
|
|
9
|
+
"./styles": "./styles.css",
|
|
10
10
|
"./dist": "./dist/foundation.js",
|
|
11
11
|
"./dist/styles": "./dist/assets/style.css"
|
|
12
12
|
},
|
|
13
13
|
"imports": {
|
|
14
|
-
"#components/*": "./
|
|
15
|
-
"#utils/*": "./
|
|
14
|
+
"#components/*": "./components/*",
|
|
15
|
+
"#utils/*": "./utils/*"
|
|
16
16
|
},
|
|
17
|
-
"files": ["dist", "
|
|
17
|
+
"files": ["dist", "sections", "components", "layouts", "utils", "main.js", "styles.css", "_entry.generated.js"],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"dev": "vite",
|
|
20
20
|
"build": "uniweb build",
|
package/templates/site/theme.yml
CHANGED
|
@@ -93,7 +93,7 @@ colors:
|
|
|
93
93
|
|
|
94
94
|
# ─── Foundation Variables ──────────────────────────────────────────────────────
|
|
95
95
|
# Override variables declared by the foundation (e.g., header-height, max-width).
|
|
96
|
-
# Available variables depend on the foundation — check its
|
|
96
|
+
# Available variables depend on the foundation — check its main.js.
|
|
97
97
|
|
|
98
98
|
# vars:
|
|
99
99
|
# header-height: 5rem
|
|
@@ -8,10 +8,10 @@ A website built with [Uniweb](https://github.com/uniweb/cli) — a component web
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
{{projectName}}/
|
|
11
|
-
├──
|
|
12
|
-
│ ├──
|
|
13
|
-
│
|
|
14
|
-
│
|
|
11
|
+
├── src/ # Foundation package (the site's source code; pkg name: src)
|
|
12
|
+
│ ├── sections/ # Section types (selectable by content authors)
|
|
13
|
+
│ ├── styles.css # Tailwind CSS v4 theme
|
|
14
|
+
│ ├── main.js # Foundation declarations (vars, defaultLayout, props)
|
|
15
15
|
│ └── vite.config.js # defineFoundationConfig()
|
|
16
16
|
│
|
|
17
17
|
├── site/ # Content (markdown)
|
|
@@ -19,12 +19,14 @@ A website built with [Uniweb](https://github.com/uniweb/cli) — a component web
|
|
|
19
19
|
│ │ └── home/
|
|
20
20
|
│ │ ├── page.yml
|
|
21
21
|
│ │ └── 1-welcome.md
|
|
22
|
-
│ ├── site.yml # Site configuration
|
|
22
|
+
│ ├── site.yml # Site configuration (foundation: src)
|
|
23
23
|
│ └── vite.config.js # defineSiteConfig()
|
|
24
24
|
│
|
|
25
25
|
└── AGENTS.md # Developer guide (human + AI)
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
A site is pure content. A foundation is the site's source code — that's why it lives in `src/`.
|
|
29
|
+
|
|
28
30
|
## Content Authoring
|
|
29
31
|
|
|
30
32
|
Content lives in markdown files under `site/pages/`:
|
|
@@ -46,10 +48,10 @@ Your content here.
|
|
|
46
48
|
|
|
47
49
|
## Component Development
|
|
48
50
|
|
|
49
|
-
Section types live in `
|
|
51
|
+
Section types live in `src/sections/`. Each is a folder with an `index.jsx` and a `meta.js`:
|
|
50
52
|
|
|
51
53
|
```jsx
|
|
52
|
-
//
|
|
54
|
+
// src/sections/Hero/index.jsx
|
|
53
55
|
import { H1, P } from '@uniweb/kit'
|
|
54
56
|
|
|
55
57
|
export default function Hero({ content }) {
|