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,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Config Management
|
|
3
|
+
*
|
|
4
|
+
* Read/write pnpm-workspace.yaml and root package.json, plus URL config
|
|
5
|
+
* for the platform backend / registry. Used by both `create` and `add`
|
|
6
|
+
* commands.
|
|
7
|
+
*
|
|
8
|
+
* This module is on the CLI's startup path (statically imported by
|
|
9
|
+
* `commands/login.js`, which is loaded by `src/index.js`). It MUST NOT
|
|
10
|
+
* import any optional peer dependency — most importantly `@uniweb/build`,
|
|
11
|
+
* which is absent in `npx uniweb create` scratch dirs and global
|
|
12
|
+
* installs. Workspace package discovery (which does need `@uniweb/build`)
|
|
13
|
+
* lives in `./discover.js` and is loaded only by commands that already
|
|
14
|
+
* require a project context.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import yaml from 'js-yaml'
|
|
22
|
+
import { filterCmd } from './pm.js'
|
|
23
|
+
import { writeJsonPreservingStyleAsync } from './json-file.js'
|
|
24
|
+
import { loggedInOriginOf, readSessionFileSync } from './session-file.js'
|
|
25
|
+
|
|
26
|
+
// ── Backend origin ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read ~/.uniweb/config.json for a persistent backend-origin override (e.g.
|
|
30
|
+
* `{ "registryApiUrl": "http://localhost:8081" }`) — consumed by
|
|
31
|
+
* getRegistryApiBaseUrl() below.
|
|
32
|
+
*
|
|
33
|
+
* @returns {{ registryApiUrl?: string }}
|
|
34
|
+
*/
|
|
35
|
+
let _cliConfig = undefined
|
|
36
|
+
function readCliConfig() {
|
|
37
|
+
if (_cliConfig !== undefined) return _cliConfig
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const configPath = join(homedir(), '.uniweb', 'config.json')
|
|
41
|
+
if (existsSync(configPath)) {
|
|
42
|
+
_cliConfig = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
43
|
+
return _cliConfig
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_cliConfig = {}
|
|
50
|
+
return _cliConfig
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The origin the LAST `uniweb login` authenticated against — persisted on the
|
|
55
|
+
* session record so subsequent verbs default to the backend you logged into
|
|
56
|
+
* (no backend flag per command). Sync read; null when there's no session or it
|
|
57
|
+
* carries no origin (older sessions). Read directly (not via registry-auth.js)
|
|
58
|
+
* to keep this module off the optional-peer / import-cycle path.
|
|
59
|
+
* @returns {string|null}
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* **The backend the user is logged in to** — the one session's backend — or null.
|
|
63
|
+
*
|
|
64
|
+
* ⛔ This read `~/.uniweb/registry-auth.json`'s top-level `origin` itself until
|
|
65
|
+
* 2026-09-21: the v1 shape. The file had become one session per backend, which has no
|
|
66
|
+
* such field, so this answered null for every new login and the session tier below
|
|
67
|
+
* silently fell through to the default backend. It reads through the file's one reader
|
|
68
|
+
* now (utils/session-file.js).
|
|
69
|
+
*
|
|
70
|
+
* @returns {string|null}
|
|
71
|
+
*/
|
|
72
|
+
export function loggedInOrigin() {
|
|
73
|
+
return loggedInOriginOf(readSessionFileSync(DEFAULT_BACKEND_ORIGIN))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A value's bare origin, or null when it is not a URL. */
|
|
77
|
+
function originOrNull(value) {
|
|
78
|
+
if (!value) return null
|
|
79
|
+
try {
|
|
80
|
+
// ⛔ http(s) only: `new URL('localhost:8080')` PARSES — as the scheme `localhost:` —
|
|
81
|
+
// and its origin is the string "null", which is truthy. A bare host:port is the
|
|
82
|
+
// easy slip, and it must not become a backend.
|
|
83
|
+
const u = new URL(value)
|
|
84
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* **The default backend** — where a bare `uniweb login` goes, and where a backend command
|
|
92
|
+
* goes when nobody is logged in (the login it asks for is then this one).
|
|
93
|
+
*
|
|
94
|
+
* `UNIWEB_SERVER`, else `~/.uniweb/config.json` `registryApiUrl`, else
|
|
95
|
+
* https://uniweb.app. ⛔ **Never the current session** *[Diego, 2026-09-21: "the default
|
|
96
|
+
* backend for login, if not specified, is uniweb.app"]* — a bare `uniweb login` means the
|
|
97
|
+
* default backend, not "the one I am already on".
|
|
98
|
+
*
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function getDefaultBackendOrigin() {
|
|
102
|
+
return (
|
|
103
|
+
originOrNull(process.env.UNIWEB_SERVER) ||
|
|
104
|
+
originOrNull(readCliConfig().registryApiUrl) ||
|
|
105
|
+
DEFAULT_BACKEND_ORIGIN
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The `uniweb login` that SWITCHES the workspace on `origin` — the caller appends `--org @x`
|
|
111
|
+
* or `--personal` — with `--server` unless a switch without it reaches `origin`
|
|
112
|
+
* (`resolveLoginOrigin`): the backend you are logged in to, or, logged in nowhere, the
|
|
113
|
+
* default backend.
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ *Measured 2026-10-06 against a local backend, before switches acted on the session:*
|
|
116
|
+
* `uniweb login --org @acme` went to https://uniweb.app and began a new login there — the
|
|
117
|
+
* reason a hint about any other backend names it.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} origin - the backend the hint is about
|
|
120
|
+
* @param {string} [prefix='uniweb'] - how the user runs the CLI (`getCliPrefix`)
|
|
121
|
+
* @returns {string} e.g. `uniweb login`, or `uniweb login --server http://localhost:8080`
|
|
122
|
+
*/
|
|
123
|
+
export function loginCommand(origin, prefix = 'uniweb') {
|
|
124
|
+
const o = originOrNull(origin)
|
|
125
|
+
const reached = originOrNull(loggedInOrigin()) || getDefaultBackendOrigin()
|
|
126
|
+
return o && o !== reached ? `${prefix} login --server ${o}` : `${prefix} login`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The flags with which `uniweb login` SIGNS IN — a method, or a credential. */
|
|
130
|
+
const SIGN_IN_FLAGS = ['--token', '--browser', '--password', '--token-paste']
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a `uniweb login` is a WORKSPACE SWITCH: `--org` or `--personal`, and nothing that
|
|
134
|
+
* signs in (`SIGN_IN_FLAGS`).
|
|
135
|
+
*
|
|
136
|
+
* @param {string[]} [args]
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function isWorkspaceSwitch(args = []) {
|
|
140
|
+
const names = args.map((a) => String(a).split('=')[0])
|
|
141
|
+
return (
|
|
142
|
+
(names.includes('--org') || names.includes('--personal')) &&
|
|
143
|
+
!SIGN_IN_FLAGS.some((f) => names.includes(f))
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The backend `uniweb login` logs in to: `--server`; else, for a workspace switch, the
|
|
149
|
+
* backend you are logged in to; else the default backend.
|
|
150
|
+
*
|
|
151
|
+
* ⭐ A SWITCH ACTS ON YOUR SESSION *[Diego, 2026-10-06: "`uniweb login --org @x` or
|
|
152
|
+
* `--personal`, with no `--backend`, should switch to the workspace on the backend you're
|
|
153
|
+
* already logged in to so we can fullfil the promise 'switches without logging in again'"]*.
|
|
154
|
+
* Logged in nowhere, it is a first login, and goes where any first login goes. A login that
|
|
155
|
+
* signs in — a method or a credential named (`isWorkspaceSwitch`) — still goes to the
|
|
156
|
+
* default backend *[Diego, 2026-09-21: "the default backend for login, if not specified, is
|
|
157
|
+
* uniweb.app"]*. ⛔ Until 2026-10-06 a switch went to the default backend too, so on any
|
|
158
|
+
* other backend it began a new login there, logging you out of the one you were on.
|
|
159
|
+
*
|
|
160
|
+
* ⛔ A mistyped `--server` is an error, never a fallback — it would log you in, and so
|
|
161
|
+
* point every command, somewhere you did not name.
|
|
162
|
+
*
|
|
163
|
+
* @param {string|null|undefined} flag - `readFlagValue`'s answer: undefined when the
|
|
164
|
+
* flag is absent, null when it was given with no value
|
|
165
|
+
* @param {string[]} [args] - the login's argv, to tell a switch from a sign-in
|
|
166
|
+
* @returns {string}
|
|
167
|
+
* @throws {Error} when --server was given and is not a URL
|
|
168
|
+
*/
|
|
169
|
+
export function resolveLoginOrigin(flag, args = []) {
|
|
170
|
+
if (flag === undefined) {
|
|
171
|
+
const session = isWorkspaceSwitch(args) ? originOrNull(loggedInOrigin()) : null
|
|
172
|
+
return session || getDefaultBackendOrigin()
|
|
173
|
+
}
|
|
174
|
+
const origin = originOrNull(flag)
|
|
175
|
+
if (!origin) throw new Error(flag ? `Not a URL: ${flag}` : '--server needs a URL')
|
|
176
|
+
return origin
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* **The backend a command talks to** — the base of every `/dev/*` route (`register`
|
|
181
|
+
* POSTs to {origin}/dev/registry/register, and so on).
|
|
182
|
+
*
|
|
183
|
+
* `UNIWEB_SERVER` > **the backend the user is logged in to** > the default
|
|
184
|
+
* (`getDefaultBackendOrigin`). ⭐ Nothing talks to a backend the user is not logged in
|
|
185
|
+
* to *[Diego, 2026-09-21]*: when this falls through to the default, the command's first
|
|
186
|
+
* request asks for that login — so the default is where the login goes, never a
|
|
187
|
+
* backend reached without one. `resolveBackendOrigin` (backend/client.js) returns this
|
|
188
|
+
* unchanged: no command takes a backend flag. ⛔ *Until 2026-10-05 this said "when no
|
|
189
|
+
* `--backend` is given" and that `resolveBackendOrigin` put `--backend` on top — read as
|
|
190
|
+
* a per-command flag outranking the login. The verbs lost that flag on 2026-09-21.*
|
|
191
|
+
*
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
export function getRegistryApiBaseUrl() {
|
|
195
|
+
return (
|
|
196
|
+
originOrNull(process.env.UNIWEB_SERVER) ||
|
|
197
|
+
originOrNull(loggedInOrigin()) ||
|
|
198
|
+
getDefaultBackendOrigin()
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The built-in backend origin — the last tier of the ladder, and the 98% case.
|
|
204
|
+
*
|
|
205
|
+
* Exported because several readers need the *built-in* default, not the resolved one:
|
|
206
|
+
* a v1 session file with no origin stamp, `login`'s list of known backends, and a
|
|
207
|
+
* deploy.yml `host: uniweb` target with no `backend:` (its documented meaning).
|
|
208
|
+
* `getRegistryApiBaseUrl()` cannot give it — that function returns whatever tier won,
|
|
209
|
+
* so on a machine with a session or a saved config it returns something else
|
|
210
|
+
* entirely. Two copies of the literal would silently disagree exactly there.
|
|
211
|
+
*/
|
|
212
|
+
export const DEFAULT_BACKEND_ORIGIN = 'https://uniweb.app'
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Read workspace package globs.
|
|
216
|
+
* Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
|
|
217
|
+
* @param {string} rootDir - Workspace root directory
|
|
218
|
+
* @returns {Promise<{packages: string[]}>}
|
|
219
|
+
*/
|
|
220
|
+
export async function readWorkspaceConfig(rootDir) {
|
|
221
|
+
// Try pnpm-workspace.yaml first
|
|
222
|
+
const configPath = join(rootDir, 'pnpm-workspace.yaml')
|
|
223
|
+
if (existsSync(configPath)) {
|
|
224
|
+
const content = await readFile(configPath, 'utf-8')
|
|
225
|
+
const config = yaml.load(content)
|
|
226
|
+
return { packages: config?.packages || [] }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fall back to package.json workspaces
|
|
230
|
+
const pkg = await readRootPackageJson(rootDir)
|
|
231
|
+
if (Array.isArray(pkg.workspaces)) {
|
|
232
|
+
return { packages: pkg.workspaces }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { packages: [] }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Write pnpm-workspace.yaml
|
|
240
|
+
* @param {string} rootDir - Workspace root directory
|
|
241
|
+
* @param {{packages: string[]}} config
|
|
242
|
+
*/
|
|
243
|
+
export async function writeWorkspaceConfig(rootDir, config) {
|
|
244
|
+
const configPath = join(rootDir, 'pnpm-workspace.yaml')
|
|
245
|
+
const content = yaml.dump(config, { flowLevel: -1, quotingType: '"' })
|
|
246
|
+
await writeFile(configPath, content)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Add a glob pattern to pnpm-workspace.yaml if not already present
|
|
251
|
+
* @param {string} rootDir - Workspace root directory
|
|
252
|
+
* @param {string} glob - Glob pattern to add
|
|
253
|
+
*/
|
|
254
|
+
export async function addWorkspaceGlob(rootDir, glob) {
|
|
255
|
+
const config = await readWorkspaceConfig(rootDir)
|
|
256
|
+
if (!config.packages.includes(glob)) {
|
|
257
|
+
config.packages.push(glob)
|
|
258
|
+
await writeWorkspaceConfig(rootDir, config)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Sync workspaces array in package.json (for npm compatibility)
|
|
262
|
+
const pkg = await readRootPackageJson(rootDir)
|
|
263
|
+
if (!pkg.workspaces) {
|
|
264
|
+
pkg.workspaces = []
|
|
265
|
+
}
|
|
266
|
+
if (!pkg.workspaces.includes(glob)) {
|
|
267
|
+
pkg.workspaces.push(glob)
|
|
268
|
+
await writeRootPackageJson(rootDir, pkg)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Read root package.json
|
|
274
|
+
* @param {string} rootDir - Workspace root directory
|
|
275
|
+
* @returns {Promise<Object>}
|
|
276
|
+
*/
|
|
277
|
+
export async function readRootPackageJson(rootDir) {
|
|
278
|
+
const pkgPath = join(rootDir, 'package.json')
|
|
279
|
+
if (!existsSync(pkgPath)) {
|
|
280
|
+
return {}
|
|
281
|
+
}
|
|
282
|
+
return JSON.parse(await readFile(pkgPath, 'utf-8'))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Write root package.json, preserving the file's existing indentation
|
|
287
|
+
* (newly scaffolded workspaces use 2-space; we don't reflow whatever's
|
|
288
|
+
* already there).
|
|
289
|
+
* @param {string} rootDir - Workspace root directory
|
|
290
|
+
* @param {Object} pkg - Package.json object
|
|
291
|
+
*/
|
|
292
|
+
export async function writeRootPackageJson(rootDir, pkg) {
|
|
293
|
+
const pkgPath = join(rootDir, 'package.json')
|
|
294
|
+
if (existsSync(pkgPath)) {
|
|
295
|
+
await writeJsonPreservingStyleAsync(pkgPath, pkg)
|
|
296
|
+
} else {
|
|
297
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Compute root scripts based on discovered sites.
|
|
303
|
+
*
|
|
304
|
+
* `dev` and `build` route through the uniweb CLI verb (PM-agnostic — they
|
|
305
|
+
* resolve `uniweb` from the project's local node_modules/.bin, so `pnpm
|
|
306
|
+
* dev` and `npm run dev` both work without locking the scripts to one PM
|
|
307
|
+
* at create-time). `preview` stays on a PM-specific filter because there
|
|
308
|
+
* isn't a `uniweb preview` verb yet (the site's own `vite preview` is what
|
|
309
|
+
* runs); switch it over when one ships.
|
|
310
|
+
*
|
|
311
|
+
* @param {Array<{name: string, path: string}>} sites - Discovered sites
|
|
312
|
+
* @param {'pnpm' | 'npm'} [pm='pnpm'] - Package manager (used only for preview)
|
|
313
|
+
* @returns {Object} Scripts object for package.json
|
|
314
|
+
*/
|
|
315
|
+
export function computeRootScripts(sites, pm = 'pnpm') {
|
|
316
|
+
const scripts = {
|
|
317
|
+
build: 'uniweb build'
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (sites.length === 0) {
|
|
321
|
+
return scripts
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (sites.length === 1) {
|
|
325
|
+
scripts.dev = 'uniweb dev'
|
|
326
|
+
scripts.preview = filterCmd(pm, sites[0].name, 'preview')
|
|
327
|
+
} else {
|
|
328
|
+
// First site gets unqualified dev/preview (matches `uniweb dev`'s
|
|
329
|
+
// default-to-first-site behavior).
|
|
330
|
+
scripts.dev = 'uniweb dev'
|
|
331
|
+
scripts.preview = filterCmd(pm, sites[0].name, 'preview')
|
|
332
|
+
|
|
333
|
+
// Subsequent sites get qualified dev:{name}/preview:{name}
|
|
334
|
+
for (let i = 1; i < sites.length; i++) {
|
|
335
|
+
scripts[`dev:${sites[i].name}`] = `uniweb dev ${sites[i].name}`
|
|
336
|
+
scripts[`preview:${sites[i].name}`] = filterCmd(
|
|
337
|
+
pm,
|
|
338
|
+
sites[i].name,
|
|
339
|
+
'preview'
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return scripts
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Update root scripts after adding a new site
|
|
349
|
+
* @param {string} rootDir - Workspace root directory
|
|
350
|
+
* @param {Array<{name: string, path: string}>} sites - All sites (including new one)
|
|
351
|
+
* @param {'pnpm' | 'npm'} [pm='pnpm'] - Package manager
|
|
352
|
+
*/
|
|
353
|
+
export async function updateRootScripts(rootDir, sites, pm = 'pnpm') {
|
|
354
|
+
const pkg = await readRootPackageJson(rootDir)
|
|
355
|
+
const newScripts = computeRootScripts(sites, pm)
|
|
356
|
+
|
|
357
|
+
// If we're adding a second site, rename existing dev/preview to dev:{firstName}
|
|
358
|
+
if (sites.length === 2 && pkg.scripts?.dev) {
|
|
359
|
+
const firstName = sites[0].name
|
|
360
|
+
// Only rename if the existing dev script references the first site's name
|
|
361
|
+
if (pkg.scripts.dev.includes(firstName)) {
|
|
362
|
+
pkg.scripts[`dev:${firstName}`] = pkg.scripts.dev
|
|
363
|
+
pkg.scripts[`preview:${firstName}`] = pkg.scripts.preview
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
pkg.scripts = { ...pkg.scripts, ...newScripts }
|
|
368
|
+
await writeRootPackageJson(rootDir, pkg)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Resolve a workspace glob pattern to actual directories
|
|
372
|
+
export async function resolveGlob(rootDir, pattern) {
|
|
373
|
+
const clean = pattern.replace(/^["']|["']$/g, '')
|
|
374
|
+
|
|
375
|
+
if (clean.endsWith('/*')) {
|
|
376
|
+
// Pattern like "foundations/*" - list subdirectories
|
|
377
|
+
const baseDir = clean.slice(0, -2)
|
|
378
|
+
const fullPath = join(rootDir, baseDir)
|
|
379
|
+
if (!existsSync(fullPath)) return []
|
|
380
|
+
try {
|
|
381
|
+
const { readdirSync } = await import('node:fs')
|
|
382
|
+
const entries = readdirSync(fullPath, { withFileTypes: true })
|
|
383
|
+
return entries
|
|
384
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
385
|
+
.map((e) => join(baseDir, e.name))
|
|
386
|
+
} catch {
|
|
387
|
+
return []
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (clean.startsWith('*/')) {
|
|
392
|
+
// Pattern like "*/foundation" - find subdirs with this child
|
|
393
|
+
const suffix = clean.slice(2)
|
|
394
|
+
const { readdirSync } = await import('node:fs')
|
|
395
|
+
try {
|
|
396
|
+
const entries = readdirSync(rootDir, { withFileTypes: true })
|
|
397
|
+
return entries
|
|
398
|
+
.filter(
|
|
399
|
+
(e) =>
|
|
400
|
+
e.isDirectory() &&
|
|
401
|
+
!e.name.startsWith('.') &&
|
|
402
|
+
e.name !== 'node_modules'
|
|
403
|
+
)
|
|
404
|
+
.filter((e) => existsSync(join(rootDir, e.name, suffix)))
|
|
405
|
+
.map((e) => join(e.name, suffix))
|
|
406
|
+
} catch {
|
|
407
|
+
return []
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Direct path like "foundation" or "site"
|
|
412
|
+
if (existsSync(join(rootDir, clean))) {
|
|
413
|
+
return [clean]
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return []
|
|
417
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content conformance on the paths that SHIP content.
|
|
3
|
+
*
|
|
4
|
+
* `uniweb validate` answers "does my data match the schemas my foundation
|
|
5
|
+
* declares?" — and until this existed, it was the *only* caller. `build`,
|
|
6
|
+
* `publish`, `push` and `deploy` never ran it, so a malformed data block built
|
|
7
|
+
* clean, deployed clean, and synced to a backend with nothing having checked
|
|
8
|
+
* it. The first symptom was a section rendering nothing on a live site, with no
|
|
9
|
+
* error anywhere in the chain.
|
|
10
|
+
*
|
|
11
|
+
* ## On a STATIC host it warns and never blocks — and on a backend it is a gate.
|
|
12
|
+
*
|
|
13
|
+
* ⭐ Two answers, one per destination [Diego, 2026-09-24: "The old rationale was in
|
|
14
|
+
* the context of static sites. It was never meant for pushed sites since that didn't
|
|
15
|
+
* even exist at the time."]. `push` and `publish` register the foundation first, so
|
|
16
|
+
* the backend checks the records against the very schemas checked here: a finding
|
|
17
|
+
* there is a refusal on arrival, or a value lost on the way — never a disagreement to
|
|
18
|
+
* ship through. `refuseIfContentDoesNotConform` is that gate; `deploy` to a static
|
|
19
|
+
* host keeps the warning below, whose reasoning is unchanged for it:
|
|
20
|
+
*
|
|
21
|
+
* A schema can be newer than the content that was valid when it was authored —
|
|
22
|
+
* a foundation upgrade, a `@std/*` revision — so a finding does not mean the
|
|
23
|
+
* content is wrong, it means the two disagree. A framework that refused to
|
|
24
|
+
* publish over that would make an author's site hostage to a schema release,
|
|
25
|
+
* for a defect whose real-world cost is one section rendering wrong. `uniweb
|
|
26
|
+
* validate` is where the gate lives for a static site, and CI is where it belongs.
|
|
27
|
+
*
|
|
28
|
+
* So, on a static host: silent when everything conforms, silent when it cannot
|
|
29
|
+
* check, one compact block when it finds something, and **the ship proceeds
|
|
30
|
+
* either way**.
|
|
31
|
+
*
|
|
32
|
+
* ## Why it is quiet about being unable to check
|
|
33
|
+
*
|
|
34
|
+
* A site whose foundation is a registry ref or a URL has no schemas on disk, so
|
|
35
|
+
* there is nothing to check against — the common case for exactly the sites
|
|
36
|
+
* `publish` is used on. `uniweb validate` reports that as "skipped" because the
|
|
37
|
+
* user asked it a direct question and deserves an answer. Here the user asked
|
|
38
|
+
* to ship, and a line saying what was not done on every deploy is noise that
|
|
39
|
+
* teaches people to skim past this block — including the times it fires.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
43
|
+
import { basename, join, relative } from 'node:path'
|
|
44
|
+
import yaml from 'js-yaml'
|
|
45
|
+
import { validateDataInputs } from '@uniweb/build'
|
|
46
|
+
import { discoverFoundations } from './discover.js'
|
|
47
|
+
import { findWorkspaceRoot } from './workspace.js'
|
|
48
|
+
|
|
49
|
+
const c = {
|
|
50
|
+
reset: '\x1b[0m',
|
|
51
|
+
bold: '\x1b[1m',
|
|
52
|
+
dim: '\x1b[2m',
|
|
53
|
+
yellow: '\x1b[33m',
|
|
54
|
+
red: '\x1b[31m'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How many findings to print before summarizing the rest. */
|
|
58
|
+
const MAX_SHOWN = 3
|
|
59
|
+
|
|
60
|
+
function readSiteYml(dir) {
|
|
61
|
+
for (const f of ['site.yml', 'site.yaml']) {
|
|
62
|
+
const p = join(dir, f)
|
|
63
|
+
if (existsSync(p)) {
|
|
64
|
+
try {
|
|
65
|
+
return yaml.load(readFileSync(p, 'utf8'))
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check one site's file-based data against its local foundation's schemas.
|
|
76
|
+
*
|
|
77
|
+
* Resolves the foundation the same way `uniweb validate` does, so the two
|
|
78
|
+
* commands agree about what is checkable — they must, or a deploy would warn
|
|
79
|
+
* about something `validate` then reports as clean.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} siteDir — the site's directory
|
|
82
|
+
* @returns {Promise<{status: 'checked'|'skipped', reason?: string, report?: object, foundation?: string}>}
|
|
83
|
+
*/
|
|
84
|
+
export async function checkSiteConformance(siteDir) {
|
|
85
|
+
const workspaceDir = findWorkspaceRoot(siteDir)
|
|
86
|
+
if (!workspaceDir) return { status: 'skipped', reason: 'not in a workspace' }
|
|
87
|
+
|
|
88
|
+
const siteYml = readSiteYml(siteDir)
|
|
89
|
+
const declared = siteYml?.foundation
|
|
90
|
+
if (!declared) {
|
|
91
|
+
return { status: 'skipped', reason: 'no foundation declared in site.yml' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolution is the name/basename match `uniweb validate` uses, and nothing
|
|
95
|
+
// more. A registry ref (`@ns/name@ver`), a URL or the object form matches no
|
|
96
|
+
// local foundation and falls through to "skipped" on its own — pre-filtering
|
|
97
|
+
// those shapes here would be a second, subtly different rule, and the two
|
|
98
|
+
// commands disagreeing about what is checkable is precisely the bug that
|
|
99
|
+
// would make a deploy warn about content `validate` then calls clean.
|
|
100
|
+
const foundations = await discoverFoundations(workspaceDir)
|
|
101
|
+
const match = foundations.find(
|
|
102
|
+
(f) => f.name === declared || basename(f.path) === declared
|
|
103
|
+
)
|
|
104
|
+
if (!match) {
|
|
105
|
+
return { status: 'skipped', reason: `foundation "${declared}" is not on disk` }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const report = await validateDataInputs({
|
|
109
|
+
siteRoot: siteDir,
|
|
110
|
+
foundationPath: join(workspaceDir, match.path)
|
|
111
|
+
})
|
|
112
|
+
return { status: 'checked', foundation: match.name, report }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run the check and print a compact warning if anything does not conform.
|
|
117
|
+
*
|
|
118
|
+
* Swallows its own failures deliberately. This runs inside `publish` / `push` /
|
|
119
|
+
* `deploy`, and an advisory check that can abort a ship is worse than no check:
|
|
120
|
+
* the thing the user asked for is the ship, and a crash in a warning is a
|
|
121
|
+
* regression with no upside. A checker that cannot run says nothing.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} siteDir
|
|
124
|
+
* @param {object} [options]
|
|
125
|
+
* @param {string[]} [options.args] — the command's argv, to honour `--no-validate`
|
|
126
|
+
* @param {(m: string) => void} [options.warn] — the caller's warning printer
|
|
127
|
+
* @param {(m: string) => void} [options.dim] — the caller's dim printer
|
|
128
|
+
* @returns {Promise<number>} how many violations were reported (0 when clean,
|
|
129
|
+
* skipped, suppressed, or the check itself failed)
|
|
130
|
+
*/
|
|
131
|
+
export async function warnIfContentDoesNotConform(siteDir, options = {}) {
|
|
132
|
+
const {
|
|
133
|
+
args = [],
|
|
134
|
+
warn = (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
135
|
+
dim = (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
136
|
+
} = options
|
|
137
|
+
|
|
138
|
+
if (args.includes('--no-validate')) return 0
|
|
139
|
+
|
|
140
|
+
let result
|
|
141
|
+
try {
|
|
142
|
+
result = await checkSiteConformance(siteDir)
|
|
143
|
+
} catch {
|
|
144
|
+
return 0 // see the docstring: a warning must not be able to stop a ship
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (result.status !== 'checked') return 0
|
|
148
|
+
|
|
149
|
+
const formatted = formatConformanceWarning(result, siteDir)
|
|
150
|
+
if (!formatted) return 0
|
|
151
|
+
|
|
152
|
+
warn(formatted.headline)
|
|
153
|
+
for (const line of formatted.details) dim(line)
|
|
154
|
+
return formatted.total
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The same check as a GATE, for `push` and `publish` — see the header for why a
|
|
159
|
+
* backend gets a gate where a static host gets a warning.
|
|
160
|
+
*
|
|
161
|
+
* Only VIOLATIONS stop it: a record the backend would refuse, or whose value it would
|
|
162
|
+
* lose. A setup error is about how data reaches a section — a rendering question, not
|
|
163
|
+
* a record — so it is still printed as a warning and the ship goes on. A checker that
|
|
164
|
+
* cannot run says nothing and stops nothing, as above. `--no-validate` skips the check
|
|
165
|
+
* and leaves the records to the backend.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} siteDir
|
|
168
|
+
* @param {object} [options] - `args`, and the caller's `error` / `warn` / `dim` printers
|
|
169
|
+
* @returns {Promise<number>} the violations that stop the ship (0 to go on)
|
|
170
|
+
*/
|
|
171
|
+
export async function refuseIfContentDoesNotConform(siteDir, options = {}) {
|
|
172
|
+
const {
|
|
173
|
+
args = [],
|
|
174
|
+
error = (m) => console.log(`${c.red}✗${c.reset} ${m}`),
|
|
175
|
+
warn = (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
176
|
+
dim = (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
177
|
+
} = options
|
|
178
|
+
|
|
179
|
+
if (args.includes('--no-validate')) return 0
|
|
180
|
+
|
|
181
|
+
let result
|
|
182
|
+
try {
|
|
183
|
+
result = await checkSiteConformance(siteDir)
|
|
184
|
+
} catch {
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
187
|
+
if (result.status !== 'checked') return 0
|
|
188
|
+
|
|
189
|
+
const violations = result.report?.violations?.length || 0
|
|
190
|
+
const formatted = formatConformanceWarning(result, siteDir, { stops: violations > 0 })
|
|
191
|
+
if (!formatted) return 0
|
|
192
|
+
|
|
193
|
+
;(violations > 0 ? error : warn)(formatted.headline)
|
|
194
|
+
for (const line of formatted.details) dim(line)
|
|
195
|
+
return violations
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Turn a report into the lines to print, or `null` when there is nothing to say.
|
|
200
|
+
*
|
|
201
|
+
* Split out from the reporting so the *warning* itself is testable without
|
|
202
|
+
* scaffolding a whole workspace on disk. That is not tidiness: every test of
|
|
203
|
+
* the silent paths would pass equally well against a function that could never
|
|
204
|
+
* warn at all, so without this the suite would assert its own inertness.
|
|
205
|
+
*
|
|
206
|
+
* @param {{report?: object, foundation?: string}} result
|
|
207
|
+
* @param {string} siteDir
|
|
208
|
+
* @param {{ stops?: boolean }} [options] - `stops`: the ship stops on this report
|
|
209
|
+
* (`refuseIfContentDoesNotConform`), so the last line says nothing was sent
|
|
210
|
+
* @returns {{headline: string, details: string[], total: number}|null}
|
|
211
|
+
*/
|
|
212
|
+
export function formatConformanceWarning(result, siteDir = '', { stops = false } = {}) {
|
|
213
|
+
const violations = result?.report?.violations || []
|
|
214
|
+
const setupErrors = result?.report?.setupErrors || []
|
|
215
|
+
const total = violations.length + setupErrors.length
|
|
216
|
+
if (total === 0) return null
|
|
217
|
+
|
|
218
|
+
// ⛔ **Two different kinds of finding share this block, and one sentence cannot
|
|
219
|
+
// describe both.** A `violation` is a record whose VALUE disagrees with a
|
|
220
|
+
// schema. A `setupError` is data that could not be read, or that never reached
|
|
221
|
+
// the section meant to read it — *no record is involved*. Counting the second
|
|
222
|
+
// as "content records that do not match" sends the reader hunting through their
|
|
223
|
+
// data for a fault that is in their configuration, which is the worse of the
|
|
224
|
+
// two searches and the one that ends in "the framework is wrong".
|
|
225
|
+
//
|
|
226
|
+
// The total still leads the block; only the naming is split.
|
|
227
|
+
const parts = []
|
|
228
|
+
if (violations.length > 0) {
|
|
229
|
+
parts.push(
|
|
230
|
+
`${violations.length} content record${violations.length === 1 ? '' : 's'} ` +
|
|
231
|
+
`that ${violations.length === 1 ? 'does' : 'do'} not match the schemas ` +
|
|
232
|
+
`${c.bold}${result.foundation}${c.reset} declares`
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
if (setupErrors.length > 0) {
|
|
236
|
+
parts.push(
|
|
237
|
+
`${setupErrors.length} problem${setupErrors.length === 1 ? '' : 's'} ` +
|
|
238
|
+
`with how data reaches your sections`
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
const headline = `Found ${parts.join(' and ')}.`
|
|
242
|
+
|
|
243
|
+
const lines = [
|
|
244
|
+
// A finding on the record itself carries no `field` — "expected a list of
|
|
245
|
+
// records, got object" is about the whole value — so naming one would print
|
|
246
|
+
// an empty path segment.
|
|
247
|
+
...violations.map((v) => {
|
|
248
|
+
const where = v.field ? `item "${v.item}" › ${v.field}` : `item "${v.item}"`
|
|
249
|
+
return `${rel(siteDir, v.file)} — ${where}: ${v.message}`
|
|
250
|
+
}),
|
|
251
|
+
...setupErrors.map((e) => `${rel(siteDir, e.file)} — ${e.message}`)
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
const details = lines.slice(0, MAX_SHOWN).map((l) => `• ${l}`)
|
|
255
|
+
if (lines.length > MAX_SHOWN) details.push(`…and ${lines.length - MAX_SHOWN} more`)
|
|
256
|
+
|
|
257
|
+
// Name the command that explains it, and say plainly which this is — a warning
|
|
258
|
+
// during a ship reads as a failure unless it says so, and a refusal must say what
|
|
259
|
+
// did not happen.
|
|
260
|
+
details.push(
|
|
261
|
+
stops
|
|
262
|
+
? 'Nothing was sent. Run `uniweb validate` for the full report, or pass `--no-validate` to send it anyway and let the backend decide.'
|
|
263
|
+
: 'Shipping anyway. Run `uniweb validate` for the full report.'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return { headline, details, total }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Site-relative when it helps, the original when it does not. */
|
|
270
|
+
function rel(siteDir, file) {
|
|
271
|
+
if (!file) return '(unknown file)'
|
|
272
|
+
const r = relative(siteDir, file)
|
|
273
|
+
return !r || r.startsWith('..') ? file : r
|
|
274
|
+
}
|