uniweb 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,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
+ }