uniweb 0.14.34 → 0.14.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.14.34",
3
+ "version": "0.14.35",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/kit": "0.10.23",
45
44
  "@uniweb/core": "0.8.2",
45
+ "@uniweb/kit": "0.10.23",
46
46
  "@uniweb/runtime": "0.9.10"
47
47
  },
48
48
  "peerDependencies": {
49
+ "@uniweb/content-reader": "1.2.2",
49
50
  "@uniweb/build": "0.16.23",
50
- "@uniweb/semantic-parser": "1.2.1",
51
- "@uniweb/content-reader": "1.2.2"
51
+ "@uniweb/semantic-parser": "1.2.1"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -58,6 +58,7 @@ import { promptForDestination } from '../utils/destination-prompt.js'
58
58
  import { readFlagValue } from '../utils/args.js'
59
59
  import { parseBoolEnv } from '../utils/env.js'
60
60
  import { headProvenance } from '../utils/git.js'
61
+ import { warnIfContentDoesNotConform } from '../utils/conformance.js'
61
62
 
62
63
  import {
63
64
  findWorkspaceRoot,
@@ -90,6 +91,10 @@ export async function deploy(args = []) {
90
91
  const dryRun = args.includes('--dry-run')
91
92
  const siteDir = await resolveSiteDir(args)
92
93
 
94
+ // Advisory only — warns and ships. See utils/conformance.js for why this
95
+ // is not a gate.
96
+ await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
97
+
93
98
  // Host dispatch. Resolution order:
94
99
  // 1. --target <name> picks a target from deploy.yml
95
100
  // 2. deploy.yml's `default:` target when no flag is given
@@ -185,7 +190,9 @@ export async function deploy(args = []) {
185
190
  // publish ignores deploy's --host/--target; --dry-run/--no-save/--backend
186
191
  // /--token pass straight through.
187
192
  const { publish } = await import('./publish.js')
188
- const result = await publish(args)
193
+ // Conformance was already reported above, and publish runs the same check
194
+ // — without this the user reads one warning twice and learns to skim it.
195
+ const result = await publish([...args, '--no-validate'])
189
196
  process.exit(result?.exitCode ?? 0)
190
197
  }
191
198
 
@@ -54,6 +54,7 @@ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
54
54
 
55
55
  import { BackendClient } from '../backend/client.js'
56
56
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
57
+ import { warnIfContentDoesNotConform } from '../utils/conformance.js'
57
58
  import { readFlagValue } from '../utils/args.js'
58
59
  import { isNonInteractive } from '../utils/interactive.js'
59
60
  import { headProvenance } from '../utils/git.js'
@@ -170,6 +171,10 @@ export async function publish(args = []) {
170
171
  const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
171
172
 
172
173
  const siteDir = await resolveSiteDir(args, 'publish')
174
+
175
+ // Advisory only — warns and ships. See utils/conformance.js for why this
176
+ // is not a gate.
177
+ await warnIfContentDoesNotConform(siteDir, { args })
173
178
  const siteYml = readSiteYml(join(siteDir, 'site.yml'))
174
179
  // The site's deploy.yml-bound backend (where it was published) feeds the
175
180
  // resolution ladder below an explicit --backend / UNIWEB_REGISTER_URL.
@@ -58,6 +58,7 @@ import { emitSyncPackages } from '@uniweb/build/uwx'
58
58
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
59
59
  import { BackendClient } from '../backend/client.js'
60
60
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
61
+ import { warnIfContentDoesNotConform } from '../utils/conformance.js'
61
62
  import {
62
63
  makeModelResolver,
63
64
  readSyncCache,
@@ -116,6 +117,10 @@ export async function push(args = []) {
116
117
  const force = args.includes('--force')
117
118
 
118
119
  const siteDir = await resolveSiteDir(args, 'push')
120
+
121
+ // Advisory only — warns and pushes. A malformed data block otherwise rides
122
+ // the sync wire unchecked; see utils/conformance.js.
123
+ await warnIfContentDoesNotConform(siteDir, { args })
119
124
  const siteBackend = await resolveSiteBackend(siteDir)
120
125
  // One front door. The bearer is resolved lazily on first need (a non-local Model
121
126
  // read during the build, or the submit). Offline emit (--dry-run / -o) is fully
package/src/index.js CHANGED
@@ -1285,6 +1285,7 @@ ${colors.bright}Options:${colors.reset}
1285
1285
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1286
1286
  --dry-run Resolve the target + adapter; print summary; upload nothing
1287
1287
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1288
+ --no-validate Skip the content-conformance check (it only warns)
1288
1289
  --non-interactive Fail with usage info instead of prompting
1289
1290
 
1290
1291
  ${colors.bright}Examples:${colors.reset}
@@ -1316,6 +1317,7 @@ ${colors.bright}Options:${colors.reset}
1316
1317
  --dry-run Resolve everything; release/sync/POST nothing
1317
1318
  --yes Skip confirmations (CI); never block on a prompt
1318
1319
  --no-save Skip the deploy.yml lastDeploy auto-save
1320
+ --no-validate Skip the content-conformance check (it only warns)
1319
1321
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1320
1322
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1321
1323
  `,
@@ -1692,6 +1694,7 @@ ${colors.bright}Publish Options:${colors.reset}
1692
1694
  --dry-run Resolve everything; release/sync/POST nothing
1693
1695
  --yes Skip confirmations (CI); never block on a prompt
1694
1696
  --no-save Skip the deploy.yml lastDeploy auto-save
1697
+ --no-validate Skip the content-conformance check (it only warns)
1695
1698
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1696
1699
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1697
1700
 
@@ -1707,6 +1710,7 @@ ${colors.bright}Deploy Options:${colors.reset}
1707
1710
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1708
1711
  --dry-run Resolve the target + adapter; print summary; upload nothing
1709
1712
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1713
+ --no-validate Skip the content-conformance check (it only warns)
1710
1714
  To deploy on every push instead, see \`uniweb add ci --help\`.
1711
1715
 
1712
1716
  ${colors.bright}Dev Options:${colors.reset}
@@ -0,0 +1,196 @@
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
+ * ## It WARNS. It never blocks, and that is a decision, not a TODO.
12
+ *
13
+ * A schema can be newer than the content that was valid when it was authored —
14
+ * a foundation upgrade, a `@std/*` revision — so a finding does not mean the
15
+ * content is wrong, it means the two disagree. A framework that refused to
16
+ * publish over that would make an author's site hostage to a schema release,
17
+ * for a defect whose real-world cost is one section rendering wrong. `--strict`
18
+ * on `uniweb validate` is where the gate lives, and CI is where it belongs.
19
+ *
20
+ * So: silent when everything conforms, silent when it cannot check, one compact
21
+ * block when it finds something, and **the ship proceeds either way**.
22
+ *
23
+ * ## Why it is quiet about being unable to check
24
+ *
25
+ * A site whose foundation is a registry ref or a URL has no schemas on disk, so
26
+ * there is nothing to check against — the common case for exactly the sites
27
+ * `publish` is used on. `uniweb validate` reports that as "skipped" because the
28
+ * user asked it a direct question and deserves an answer. Here the user asked
29
+ * to ship, and a line saying what was not done on every deploy is noise that
30
+ * teaches people to skim past this block — including the times it fires.
31
+ */
32
+
33
+ import { existsSync, readFileSync } from 'node:fs'
34
+ import { basename, join, relative } from 'node:path'
35
+ import yaml from 'js-yaml'
36
+ import { validateDataInputs } from '@uniweb/build'
37
+ import { discoverFoundations } from './discover.js'
38
+ import { findWorkspaceRoot } from './workspace.js'
39
+
40
+ const c = {
41
+ reset: '\x1b[0m',
42
+ bold: '\x1b[1m',
43
+ dim: '\x1b[2m',
44
+ yellow: '\x1b[33m'
45
+ }
46
+
47
+ /** How many findings to print before summarizing the rest. */
48
+ const MAX_SHOWN = 3
49
+
50
+ function readSiteYml(dir) {
51
+ for (const f of ['site.yml', 'site.yaml']) {
52
+ const p = join(dir, f)
53
+ if (existsSync(p)) {
54
+ try {
55
+ return yaml.load(readFileSync(p, 'utf8'))
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Check one site's file-based data against its local foundation's schemas.
66
+ *
67
+ * Resolves the foundation the same way `uniweb validate` does, so the two
68
+ * commands agree about what is checkable — they must, or a deploy would warn
69
+ * about something `validate` then reports as clean.
70
+ *
71
+ * @param {string} siteDir — the site's directory
72
+ * @returns {Promise<{status: 'checked'|'skipped', reason?: string, report?: object, foundation?: string}>}
73
+ */
74
+ export async function checkSiteConformance(siteDir) {
75
+ const workspaceDir = findWorkspaceRoot(siteDir)
76
+ if (!workspaceDir) return { status: 'skipped', reason: 'not in a workspace' }
77
+
78
+ const siteYml = readSiteYml(siteDir)
79
+ const declared = siteYml?.foundation
80
+ if (!declared) {
81
+ return { status: 'skipped', reason: 'no foundation declared in site.yml' }
82
+ }
83
+
84
+ // Resolution is the name/basename match `uniweb validate` uses, and nothing
85
+ // more. A registry ref (`@ns/name@ver`), a URL or the object form matches no
86
+ // local foundation and falls through to "skipped" on its own — pre-filtering
87
+ // those shapes here would be a second, subtly different rule, and the two
88
+ // commands disagreeing about what is checkable is precisely the bug that
89
+ // would make a deploy warn about content `validate` then calls clean.
90
+ const foundations = await discoverFoundations(workspaceDir)
91
+ const match = foundations.find(
92
+ (f) => f.name === declared || basename(f.path) === declared
93
+ )
94
+ if (!match) {
95
+ return { status: 'skipped', reason: `foundation "${declared}" is not on disk` }
96
+ }
97
+
98
+ const report = await validateDataInputs({
99
+ siteRoot: siteDir,
100
+ foundationPath: join(workspaceDir, match.path)
101
+ })
102
+ return { status: 'checked', foundation: match.name, report }
103
+ }
104
+
105
+ /**
106
+ * Run the check and print a compact warning if anything does not conform.
107
+ *
108
+ * Swallows its own failures deliberately. This runs inside `publish` / `push` /
109
+ * `deploy`, and an advisory check that can abort a ship is worse than no check:
110
+ * the thing the user asked for is the ship, and a crash in a warning is a
111
+ * regression with no upside. A checker that cannot run says nothing.
112
+ *
113
+ * @param {string} siteDir
114
+ * @param {object} [options]
115
+ * @param {string[]} [options.args] — the command's argv, to honour `--no-validate`
116
+ * @param {(m: string) => void} [options.warn] — the caller's warning printer
117
+ * @param {(m: string) => void} [options.dim] — the caller's dim printer
118
+ * @returns {Promise<number>} how many violations were reported (0 when clean,
119
+ * skipped, suppressed, or the check itself failed)
120
+ */
121
+ export async function warnIfContentDoesNotConform(siteDir, options = {}) {
122
+ const {
123
+ args = [],
124
+ warn = (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
125
+ dim = (m) => console.log(` ${c.dim}${m}${c.reset}`)
126
+ } = options
127
+
128
+ if (args.includes('--no-validate')) return 0
129
+
130
+ let result
131
+ try {
132
+ result = await checkSiteConformance(siteDir)
133
+ } catch {
134
+ return 0 // see the docstring: a warning must not be able to stop a ship
135
+ }
136
+
137
+ if (result.status !== 'checked') return 0
138
+
139
+ const formatted = formatConformanceWarning(result, siteDir)
140
+ if (!formatted) return 0
141
+
142
+ warn(formatted.headline)
143
+ for (const line of formatted.details) dim(line)
144
+ return formatted.total
145
+ }
146
+
147
+ /**
148
+ * Turn a report into the lines to print, or `null` when there is nothing to say.
149
+ *
150
+ * Split out from the reporting so the *warning* itself is testable without
151
+ * scaffolding a whole workspace on disk. That is not tidiness: every test of
152
+ * the silent paths would pass equally well against a function that could never
153
+ * warn at all, so without this the suite would assert its own inertness.
154
+ *
155
+ * @param {{report?: object, foundation?: string}} result
156
+ * @param {string} siteDir
157
+ * @returns {{headline: string, details: string[], total: number}|null}
158
+ */
159
+ export function formatConformanceWarning(result, siteDir = '') {
160
+ const violations = result?.report?.violations || []
161
+ const setupErrors = result?.report?.setupErrors || []
162
+ const total = violations.length + setupErrors.length
163
+ if (total === 0) return null
164
+
165
+ const headline =
166
+ `${total} content record${total === 1 ? '' : 's'} ` +
167
+ `${total === 1 ? 'does' : 'do'} not match the schemas ` +
168
+ `${c.bold}${result.foundation}${c.reset} declares.`
169
+
170
+ const lines = [
171
+ // A finding on the record itself carries no `field` — "expected a list of
172
+ // records, got object" is about the whole value — so naming one would print
173
+ // an empty path segment.
174
+ ...violations.map((v) => {
175
+ const where = v.field ? `item "${v.item}" › ${v.field}` : `item "${v.item}"`
176
+ return `${rel(siteDir, v.file)} — ${where}: ${v.message}`
177
+ }),
178
+ ...setupErrors.map((e) => `${rel(siteDir, e.file)} — ${e.message}`)
179
+ ]
180
+
181
+ const details = lines.slice(0, MAX_SHOWN).map((l) => `• ${l}`)
182
+ if (lines.length > MAX_SHOWN) details.push(`…and ${lines.length - MAX_SHOWN} more`)
183
+
184
+ // Name the command that explains it, and say plainly that this is not a
185
+ // refusal — a warning during a ship reads as a failure unless it says so.
186
+ details.push('Shipping anyway. Run `uniweb validate` for the full report.')
187
+
188
+ return { headline, details, total }
189
+ }
190
+
191
+ /** Site-relative when it helps, the original when it does not. */
192
+ function rel(siteDir, file) {
193
+ if (!file) return '(unknown file)'
194
+ const r = relative(siteDir, file)
195
+ return !r || r.startsWith('..') ? file : r
196
+ }