@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.25.0",
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/semantic-parser": "^1.2.3",
66
- "@uniweb/content-writer": "^0.3.3"
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/runtime": "^0.12.6",
72
- "@uniweb/semantic-parser": "^1.2.3"
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.0"
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
- const module = await import(pathToFileURL(filePath).href)
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
- console.warn(`Warning: Failed to load foundation config ${filePath}:`, error.message)
202
- return {}
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
- console.warn('[collection-processor] YAML parse error:', err.message)
158
- return { frontmatter: {}, body: raw }
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 — operational config and last-deploy memory for this site.',
31
- ' Safe to commit. The `lastDeploy:` block is auto-managed by `uniweb deploy`;',
32
- ' edit `targets:` freely.',
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
  /**