@uniweb/build 0.25.0 → 0.25.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/package.json +8 -8
- package/src/schema.js +107 -11
- package/src/site/collection-processor.js +41 -5
- package/src/site/deploy-config-writer.js +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,17 +59,17 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.35.3",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
-
"@uniweb/theming": "^0.1.15",
|
|
63
62
|
"@uniweb/projections": "^0.3.4",
|
|
64
63
|
"@uniweb/schemas": "^0.2.10",
|
|
65
|
-
"@uniweb/
|
|
66
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/theming": "^0.1.15",
|
|
65
|
+
"@uniweb/semantic-parser": "^1.3.0",
|
|
66
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
67
67
|
},
|
|
68
68
|
"optionalDependencies": {
|
|
69
|
-
"@uniweb/content-reader": "^1.2.3",
|
|
70
69
|
"@uniweb/schemas": "^0.2.10",
|
|
71
|
-
"@uniweb/
|
|
72
|
-
"@uniweb/
|
|
70
|
+
"@uniweb/content-reader": "^1.2.4",
|
|
71
|
+
"@uniweb/runtime": "^0.12.9",
|
|
72
|
+
"@uniweb/semantic-parser": "^1.3.0"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@tailwindcss/vite": "^4.0.0",
|
|
79
79
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
80
80
|
"vite-plugin-svgr": "^4.0.0",
|
|
81
|
-
"@uniweb/core": "^0.11.
|
|
81
|
+
"@uniweb/core": "^0.11.2"
|
|
82
82
|
},
|
|
83
83
|
"peerDependenciesMeta": {
|
|
84
84
|
"vite": {
|
package/src/schema.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Additional paths (via config): meta.js required for addressability
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readdir, readFile } from 'node:fs/promises'
|
|
13
|
+
import { readdir, readFile, rm } from 'node:fs/promises'
|
|
14
14
|
import { existsSync } from 'node:fs'
|
|
15
15
|
import { isFontVar } from '@uniweb/theming'
|
|
16
16
|
import { join, dirname, extname, basename } from 'node:path'
|
|
@@ -156,6 +156,24 @@ const DEFAULT_ONLY_CAPABILITIES = [
|
|
|
156
156
|
* every `[#id]` rendered as its own literal text — indistinguishable from a
|
|
157
157
|
* foundation that had never opted in. Found only by reading generate-entry.js.
|
|
158
158
|
*
|
|
159
|
+
* ⚠️ THAT DIAGNOSIS WAS CORRECT AND INCOMPLETE, and the rest took three weeks
|
|
160
|
+
* to find (2026-08-23). The same commit that moved those keys onto the default
|
|
161
|
+
* export also added `import … from '@uniweb/kit/xref'`, whose graph reaches
|
|
162
|
+
* `Ref.jsx`. `loadFoundationConfig` could not parse that, so the config never
|
|
163
|
+
* loaded at all — the identical symptom, from an unrelated cause, arriving on
|
|
164
|
+
* the same day the correct fix was applied. The fix looked inert because it
|
|
165
|
+
* was: where the keys sat could not matter while nothing was read.
|
|
166
|
+
*
|
|
167
|
+
* ⛔ AND IT DISABLED THIS WARNING. `warnMisplacedCapabilities` runs AFTER the
|
|
168
|
+
* import; a config that throws never reaches it. The guard added to catch that
|
|
169
|
+
* incident was, from that day, unable to fire on the project it was written
|
|
170
|
+
* for. A check downstream of the thing most likely to fail is not a check.
|
|
171
|
+
*
|
|
172
|
+
* To tell the two apart: a misplaced named export loses only that capability.
|
|
173
|
+
* A config that fails to load loses everything the file declares — `vars`
|
|
174
|
+
* included, so the site also renders with no theme variables at all. If the
|
|
175
|
+
* theme is gone too, stop looking at export placement.
|
|
176
|
+
*
|
|
159
177
|
* A warning rather than an error: a foundation may legitimately export a name
|
|
160
178
|
* that collides for its own use, and failing someone's build over a naming
|
|
161
179
|
* coincidence is worse than telling them what we ignored.
|
|
@@ -177,6 +195,70 @@ function warnMisplacedCapabilities(module, filePath) {
|
|
|
177
195
|
)
|
|
178
196
|
}
|
|
179
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Import a foundation config, transpiling it when Node alone cannot.
|
|
200
|
+
*
|
|
201
|
+
* ⛔ A foundation config routinely imports JSX, and Node cannot parse it.
|
|
202
|
+
* `defaultInsets` and `xref` take REACT COMPONENTS — that is what they are
|
|
203
|
+
* for — so `main.js` legitimately reads:
|
|
204
|
+
*
|
|
205
|
+
* import { buildXrefRegistry, Ref } from '@uniweb/kit/xref'
|
|
206
|
+
*
|
|
207
|
+
* kit ships source, so that resolves to `Ref.jsx` and a bare `import()`
|
|
208
|
+
* throws `Unknown file extension ".jsx"`. The config is fine; the loader was
|
|
209
|
+
* the problem.
|
|
210
|
+
*
|
|
211
|
+
* Fast path first: a plain `import()`, which is free and covers the common
|
|
212
|
+
* case of a config that is pure data. Only when Node rejects the SYNTAX do we
|
|
213
|
+
* pay for a bundle — the same fallback shape Vite uses for `vite.config.ts`.
|
|
214
|
+
*
|
|
215
|
+
* React stays external because Node imports it happily and it is the bulk of
|
|
216
|
+
* the graph; everything else is inlined so no `.jsx` survives to be resolved
|
|
217
|
+
* at run time. The temp file is written beside the config so bare specifiers
|
|
218
|
+
* still resolve from the project's own `node_modules`.
|
|
219
|
+
*/
|
|
220
|
+
async function importFoundationConfig(filePath) {
|
|
221
|
+
const href = pathToFileURL(filePath).href
|
|
222
|
+
try {
|
|
223
|
+
return await import(href)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!isUnparseableByNode(error)) throw error
|
|
226
|
+
|
|
227
|
+
const esbuild = (await import('esbuild')).default ?? (await import('esbuild'))
|
|
228
|
+
const outfile = join(
|
|
229
|
+
dirname(filePath),
|
|
230
|
+
`.${basename(filePath)}.uniweb-config.${process.pid}.mjs`,
|
|
231
|
+
)
|
|
232
|
+
try {
|
|
233
|
+
await esbuild.build({
|
|
234
|
+
entryPoints: [filePath],
|
|
235
|
+
outfile,
|
|
236
|
+
bundle: true,
|
|
237
|
+
format: 'esm',
|
|
238
|
+
platform: 'node',
|
|
239
|
+
jsx: 'automatic',
|
|
240
|
+
// Node can load these as-is, and they dominate the graph.
|
|
241
|
+
external: ['react', 'react/*', 'react-dom', 'react-dom/*'],
|
|
242
|
+
logLevel: 'silent',
|
|
243
|
+
})
|
|
244
|
+
return await import(pathToFileURL(outfile).href)
|
|
245
|
+
} finally {
|
|
246
|
+
await rm(outfile, { force: true }).catch(() => {})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Does this error mean "Node cannot read this file", as opposed to "the
|
|
253
|
+
* config threw"? Only the former is worth re-trying through a bundler —
|
|
254
|
+
* re-running a config that threw on its own would just throw again.
|
|
255
|
+
*/
|
|
256
|
+
function isUnparseableByNode(error) {
|
|
257
|
+
if (error instanceof SyntaxError) return true
|
|
258
|
+
const code = error?.code
|
|
259
|
+
return code === 'ERR_UNKNOWN_FILE_EXTENSION' || code === 'ERR_UNSUPPORTED_DIR_IMPORT'
|
|
260
|
+
}
|
|
261
|
+
|
|
180
262
|
export async function loadFoundationConfig(srcDir) {
|
|
181
263
|
let filePath = null
|
|
182
264
|
for (const name of FOUNDATION_FILE_NAMES) {
|
|
@@ -188,18 +270,32 @@ export async function loadFoundationConfig(srcDir) {
|
|
|
188
270
|
}
|
|
189
271
|
if (!filePath) return {}
|
|
190
272
|
|
|
273
|
+
let module
|
|
191
274
|
try {
|
|
192
|
-
|
|
193
|
-
warnMisplacedCapabilities(module, filePath)
|
|
194
|
-
// Support both default export and named exports
|
|
195
|
-
return {
|
|
196
|
-
...module.default,
|
|
197
|
-
vars: inferFontVarTypes(module.vars || module.default?.vars),
|
|
198
|
-
defaultLayout: module.default?.defaultLayout,
|
|
199
|
-
}
|
|
275
|
+
module = await importFoundationConfig(filePath)
|
|
200
276
|
} catch (error) {
|
|
201
|
-
|
|
202
|
-
|
|
277
|
+
// ⛔ NEVER degrade to `{}` here. Everything a foundation declares — `vars`,
|
|
278
|
+
// `xref`, `defaultInsets`, `name` — arrives through this one call, so an
|
|
279
|
+
// empty return silently produces a foundation with NO THEME VARIABLES. The
|
|
280
|
+
// symptom is a site whose `px-[var(--section-padding-x)]` resolves to 0 and
|
|
281
|
+
// whose `max-w-[var(--width-content)]` resolves to `none`: content sprawls
|
|
282
|
+
// edge to edge and every layout token is gone, with nothing in the output
|
|
283
|
+
// naming a cause. Measured 2026-08-23 on a real site that had been shipping
|
|
284
|
+
// that way for three weeks behind a single `console.warn`.
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Failed to load foundation config ${filePath}: ${error.message}\n` +
|
|
287
|
+
` Everything the foundation declares (vars, xref, defaultInsets, name) comes from this file,\n` +
|
|
288
|
+
` so the build cannot continue without it — a partial load would emit a site with no theme variables.`,
|
|
289
|
+
{ cause: error },
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
warnMisplacedCapabilities(module, filePath)
|
|
294
|
+
// Support both default export and named exports
|
|
295
|
+
return {
|
|
296
|
+
...module.default,
|
|
297
|
+
vars: inferFontVarTypes(module.vars || module.default?.vars),
|
|
298
|
+
defaultLayout: module.default?.defaultLayout,
|
|
203
299
|
}
|
|
204
300
|
}
|
|
205
301
|
|
|
@@ -134,12 +134,38 @@ function parseCollectionConfig(name, config) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Parse YAML frontmatter from markdown content
|
|
137
|
+
* Parse YAML frontmatter from markdown content.
|
|
138
|
+
*
|
|
139
|
+
* Two cases, and keeping them apart is the whole point:
|
|
140
|
+
*
|
|
141
|
+
* NO frontmatter — the file does not open with `---`, or never closes the
|
|
142
|
+
* block. Legitimate: a record can be pure body. Returns {}.
|
|
143
|
+
*
|
|
144
|
+
* DECLARED frontmatter that does not parse — an error, because every field
|
|
145
|
+
* is gone at once. Not just the one with the typo: title, slug, date, image,
|
|
146
|
+
* category, all of it. The record still builds, still ships, and lands at a
|
|
147
|
+
* filename-derived slug with no title and no cover.
|
|
148
|
+
*
|
|
149
|
+
* ⛔ THIS USED TO WARN AND CONTINUE, and the warning could not be found.
|
|
150
|
+
* Measured 2026-08-24 on a real post: an unquoted colon inside a description
|
|
151
|
+
* ("...on a website framework: everything hard about docs...") voided six
|
|
152
|
+
* fields and moved the page from /blog/docs-sites to /blog/11_docs_sites. The
|
|
153
|
+
* only trace was
|
|
154
|
+
*
|
|
155
|
+
* [collection-processor] YAML parse error: bad indentation of a mapping entry (4:72)
|
|
156
|
+
*
|
|
157
|
+
* on line 16 of 857 lines of build output, naming no file, nine lines above
|
|
158
|
+
* "Processed articles: 6 items" — a success line that reads as everything
|
|
159
|
+
* being fine. The build exited 0 and the broken record shipped.
|
|
160
|
+
*
|
|
161
|
+
* A parse error now names the file and says what it costs, because "which of
|
|
162
|
+
* my 200 records is (4:72) in?" is the question the old message left you with.
|
|
138
163
|
*
|
|
139
164
|
* @param {string} raw - Raw file content
|
|
165
|
+
* @param {string} [filepath] - Path to the file, for the error message
|
|
140
166
|
* @returns {{ frontmatter: Object, body: string }}
|
|
141
167
|
*/
|
|
142
|
-
function parseFrontmatter(raw) {
|
|
168
|
+
function parseFrontmatter(raw, filepath) {
|
|
143
169
|
if (!raw.trim().startsWith('---')) {
|
|
144
170
|
return { frontmatter: {}, body: raw }
|
|
145
171
|
}
|
|
@@ -154,8 +180,18 @@ function parseFrontmatter(raw) {
|
|
|
154
180
|
const body = parts.slice(2).join('---\n')
|
|
155
181
|
return { frontmatter, body }
|
|
156
182
|
} catch (err) {
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
const where = filepath ? `${filepath}: ` : ''
|
|
184
|
+
throw new Error(
|
|
185
|
+
`${where}frontmatter is not valid YAML — ${err.message}\n` +
|
|
186
|
+
` The file opens with \`---\`, so it is declaring frontmatter. Since the block does not\n` +
|
|
187
|
+
` parse, EVERY field in it is lost — title, slug, date, image, category — and the record\n` +
|
|
188
|
+
` would build as an untitled entry at a slug derived from its filename.\n` +
|
|
189
|
+
` A common cause is an unquoted value containing a colon followed by a space:\n` +
|
|
190
|
+
` description: Building on a framework: everything hard is a website problem\n` +
|
|
191
|
+
` Quote the value and it parses:\n` +
|
|
192
|
+
` description: "Building on a framework: everything hard is a website problem"`,
|
|
193
|
+
{ cause: err },
|
|
194
|
+
)
|
|
159
195
|
}
|
|
160
196
|
}
|
|
161
197
|
|
|
@@ -508,7 +544,7 @@ async function processContentItem(dir, filename, config, siteRoot, basePath) {
|
|
|
508
544
|
const slug = basename(filename, extname(filename))
|
|
509
545
|
|
|
510
546
|
// Parse frontmatter and body
|
|
511
|
-
const { frontmatter, body } = parseFrontmatter(raw)
|
|
547
|
+
const { frontmatter, body } = parseFrontmatter(raw, filepath)
|
|
512
548
|
|
|
513
549
|
// Skip unpublished items by default
|
|
514
550
|
if (frontmatter.published === false) {
|
|
@@ -26,10 +26,31 @@ import { existsSync } from 'node:fs'
|
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
import { Document, parseDocument, isMap } from 'yaml'
|
|
28
28
|
|
|
29
|
+
// The header every generated deploy.yml carries. It has to answer the question
|
|
30
|
+
// a reader has while looking AT the file — who wrote this, and may I edit it —
|
|
31
|
+
// because that is where the question gets asked, not in the docs.
|
|
32
|
+
//
|
|
33
|
+
// The previous wording opened "operational config ... edit `targets:` freely",
|
|
34
|
+
// which reads as a file you are expected to author with one auto-managed block
|
|
35
|
+
// inside it. It cost a real reader an afternoon: they went looking for what to
|
|
36
|
+
// write under `targets:` for a site that had never deployed, when the answer is
|
|
37
|
+
// that the first deploy writes it.
|
|
29
38
|
const SCAFFOLD_HEADER = [
|
|
30
|
-
' deploy.yml —
|
|
31
|
-
'
|
|
32
|
-
'
|
|
39
|
+
' deploy.yml — written by `uniweb deploy` / `uniweb publish`.',
|
|
40
|
+
'',
|
|
41
|
+
' You do not create this file. The first successful deploy does, recording the',
|
|
42
|
+
' target you picked and what happened. Later deploys rewrite only `lastDeploy:`',
|
|
43
|
+
' and leave the rest — including your comments — alone.',
|
|
44
|
+
'',
|
|
45
|
+
' Safe to commit; no credentials live here. Host credentials come from the',
|
|
46
|
+
' environment.',
|
|
47
|
+
'',
|
|
48
|
+
' default: which target is used when none is named',
|
|
49
|
+
' targets: where this site ships. Edit to change the destination, or add',
|
|
50
|
+
' a target and pick it with `--target <name>`',
|
|
51
|
+
' autoSave: `lastDeploy` to keep the record below, `off` to stop writing it',
|
|
52
|
+
' lastDeploy: what the last deploy did. A record, not a setting — nothing',
|
|
53
|
+
' reads it back, so a stale one is safe to delete',
|
|
33
54
|
].join('\n')
|
|
34
55
|
|
|
35
56
|
/**
|