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 +4 -4
- package/src/commands/deploy.js +8 -1
- package/src/commands/publish.js +5 -0
- package/src/commands/push.js +5 -0
- package/src/index.js +4 -0
- package/src/utils/conformance.js +196 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.14.
|
|
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": {
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/commands/publish.js
CHANGED
|
@@ -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.
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
+
}
|