@uniweb/build 0.52.5 → 0.52.7

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.52.5",
3
+ "version": "0.52.7",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,12 +57,12 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/projections": "^0.7.3",
61
- "@uniweb/semantic-parser": "^1.4.1",
62
- "@uniweb/schemas": "^0.3.1",
63
60
  "@uniweb/content-reader": "^1.2.5",
61
+ "@uniweb/semantic-parser": "^1.4.1",
62
+ "@uniweb/schemas": "^0.3.3",
63
+ "@uniweb/theming": "^0.1.15",
64
64
  "@uniweb/content-writer": "^0.3.4",
65
- "@uniweb/theming": "^0.1.15"
65
+ "@uniweb/projections": "^0.7.3"
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "@uniweb/runtime": "^0.26.4"
@@ -74,6 +74,7 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
+ "@uniweb/runtime": "^0.26.4",
77
78
  "@uniweb/core": "^0.29.4"
78
79
  },
79
80
  "peerDependenciesMeta": {
@@ -97,6 +98,9 @@
97
98
  },
98
99
  "vite-plugin-svgr": {
99
100
  "optional": true
101
+ },
102
+ "@uniweb/runtime": {
103
+ "optional": true
100
104
  }
101
105
  },
102
106
  "scripts": {
@@ -97,7 +97,10 @@ export function readSiteConfig(siteRoot) {
97
97
  * @param {Array} [options.plugins] - Additional Vite plugins
98
98
  * @param {Object} [options.build] - Vite build options
99
99
  * @param {Object} [options.resolve] - Vite resolve options
100
- * @param {Object} [options.seo] - SEO configuration for siteContentPlugin
100
+ * @param {Object} [options.seo] - SEO OVERRIDES for siteContentPlugin, merged
101
+ * OVER the site's own `site.yml::seo` block. Omit it and `site.yml` decides —
102
+ * a site's `vite.config.js` is scaffolding, so nothing a site GETS may depend
103
+ * on what it passes here.
101
104
  * @param {Object} [options.assets] - Asset processing configuration
102
105
  * @param {Object} [options.search] - Search index configuration
103
106
  * @param {boolean} [options.tailwind] - Include Tailwind CSS v4 Vite plugin (default: true)
@@ -493,6 +493,51 @@ function escapeHtml(str) {
493
493
  .replace(/"/g, '"')
494
494
  }
495
495
 
496
+ /**
497
+ * The effective `seo` block for a collected site.
498
+ *
499
+ * ⛔ **`site.yml::seo` IS THE SOURCE. The plugin option is an OVERRIDE.**
500
+ * This plugin read `options.seo` alone until 2026-09-17, and nothing ever put
501
+ * `site.yml`'s block there: `defineSiteConfig` passes its own `seo` option
502
+ * straight through and defaults it to `{}`, while the scaffolded
503
+ * `vite.config.js` calls `defineSiteConfig()` with no arguments. So on the
504
+ * bundle lane `seo.baseUrl` and `seo.robots` were authored, documented
505
+ * (`cli/partials/agents.md`), read by `uniweb doctor` — and dead. The symptom,
506
+ * reported from a real site: `seo.baseUrl` set, doctor's
507
+ * `agents-index-relative-links` warning cleared, and `uniweb build` / `uniweb
508
+ * export` emitting neither `sitemap.xml` nor `robots.txt` while `llms.txt`
509
+ * links stayed root-relative.
510
+ *
511
+ * ⭐ **The data-only path was never wrong** — `build-site-data.js::writeProjections`
512
+ * reads `siteContent.config.seo.baseUrl`. Reading the same place here is what
513
+ * makes the two lanes unable to disagree, which is the whole reason
514
+ * `projectionOptions` exists.
515
+ *
516
+ * ⚠️ The generators were unit-tested throughout (`tests/robots-content-signals.test.js`
517
+ * calls `generateRobotsTxt` directly), which is why a green suite proved
518
+ * nothing: the tests supplied the input the wiring never delivered. That is why
519
+ * this is an exported function rather than a closure — the RESOLUTION is the
520
+ * part that was missing, so it is the part that gets asserted.
521
+ *
522
+ * A site's `vite.config.js` is CLI scaffolding — strip it and the site is
523
+ * unchanged — so a site's SEO must not depend on what it passes. An explicit
524
+ * override still wins, key by key, the way `base` does.
525
+ *
526
+ * @param {Object} [content] - Collected site content (reads `content.config.seo`)
527
+ * @param {Object} [overrides] - Caller overrides, merged OVER the site's block
528
+ * @returns {{ baseUrl: string, defaultImage: ?string, twitterHandle: ?string, locales: Array, robots: Object }}
529
+ */
530
+ export function resolveEffectiveSeo(content, overrides = {}) {
531
+ const merged = { ...(content?.config?.seo || {}), ...overrides }
532
+ return {
533
+ baseUrl: merged.baseUrl?.replace(/\/$/, '') || '', // Remove trailing slash
534
+ defaultImage: merged.defaultImage || null,
535
+ twitterHandle: merged.twitterHandle || null,
536
+ locales: merged.locales || [],
537
+ robots: merged.robots || {}
538
+ }
539
+ }
540
+
496
541
  /**
497
542
  * Create the site content plugin
498
543
  *
@@ -503,7 +548,10 @@ function escapeHtml(str) {
503
548
  * @param {boolean} [options.inject=true] - Inject content into HTML
504
549
  * @param {string} [options.filename='site-content.json'] - Output filename
505
550
  * @param {boolean} [options.watch=true] - Watch for changes in dev mode
506
- * @param {Object} [options.seo] - SEO configuration
551
+ * @param {Object} [options.seo] - SEO OVERRIDES, merged OVER `site.yml::seo`
552
+ * key by key. The site's own block is the source; this is for a caller that
553
+ * must override it (a preview build pointing at a staging origin, say).
554
+ * ⛔ Do not reach for this to make `site.yml::seo` work — it already does.
507
555
  * @param {string} [options.seo.baseUrl] - Base URL for sitemap and canonical URLs
508
556
  * @param {string} [options.seo.defaultImage] - Default OG image path
509
557
  * @param {string} [options.seo.twitterHandle] - Twitter handle for cards
@@ -548,15 +596,8 @@ export function siteContentPlugin(options = {}) {
548
596
  pdfThumbnails: assetsConfig.pdfThumbnails !== false // Default true
549
597
  }
550
598
 
551
- // Extract SEO options with defaults
552
- const seoEnabled = !!seo.baseUrl
553
- const seoOptions = {
554
- baseUrl: seo.baseUrl?.replace(/\/$/, '') || '', // Remove trailing slash
555
- defaultImage: seo.defaultImage || null,
556
- twitterHandle: seo.twitterHandle || null,
557
- locales: seo.locales || [],
558
- robots: seo.robots || {}
559
- }
599
+ /** This plugin's SEO resolution — `site.yml::seo`, with `options.seo` over it. */
600
+ const resolveSeo = content => resolveEffectiveSeo(content, seo)
560
601
 
561
602
  // Warn once per build, not once per collection — the dev server re-collects
562
603
  // on every content change and would otherwise repeat this on every keystroke.
@@ -756,7 +797,7 @@ export function siteContentPlugin(options = {}) {
756
797
  function projectionOptions(content) {
757
798
  const defaultLocale = resolveDefaultLocale(content.config)
758
799
  return {
759
- baseUrl: seoOptions.baseUrl,
800
+ baseUrl: resolveSeo(content).baseUrl,
760
801
  basePath,
761
802
  locale: content.config?.activeLocale || defaultLocale,
762
803
  defaultLocale
@@ -1185,17 +1226,22 @@ export function siteContentPlugin(options = {}) {
1185
1226
  // If no translations, fall through to serve default content
1186
1227
  }
1187
1228
 
1229
+ // Dev serves the same two SEO artifacts the build emits, off the same
1230
+ // resolution — so `uniweb dev` and `uniweb build` cannot disagree about
1231
+ // whether a site has them.
1232
+ const devSeo = resolveSeo(siteContent)
1233
+
1188
1234
  // Serve sitemap.xml in dev mode
1189
- if (req.url === '/sitemap.xml' && seoEnabled && siteContent?.pages) {
1235
+ if (req.url === '/sitemap.xml' && devSeo.baseUrl && siteContent?.pages) {
1190
1236
  res.setHeader('Content-Type', 'application/xml')
1191
- res.end(generateSitemap(siteContent.pages, seoOptions.baseUrl, filterSeoLocales(seoOptions.locales, siteContent.config), siteContent.config?.i18n?.routeTranslations))
1237
+ res.end(generateSitemap(siteContent.pages, devSeo.baseUrl, filterSeoLocales(devSeo.locales, siteContent.config), siteContent.config?.i18n?.routeTranslations))
1192
1238
  return
1193
1239
  }
1194
1240
 
1195
1241
  // Serve robots.txt in dev mode
1196
- if (req.url === '/robots.txt' && seoEnabled) {
1242
+ if (req.url === '/robots.txt' && devSeo.baseUrl) {
1197
1243
  res.setHeader('Content-Type', 'text/plain')
1198
- res.end(generateRobotsTxt(seoOptions.baseUrl, seoOptions.robots))
1244
+ res.end(generateRobotsTxt(devSeo.baseUrl, devSeo.robots))
1199
1245
  return
1200
1246
  }
1201
1247
 
@@ -1398,8 +1444,9 @@ export function siteContentPlugin(options = {}) {
1398
1444
  }
1399
1445
 
1400
1446
  // Inject SEO meta tags
1401
- if (seoEnabled) {
1402
- const metaTags = generateMetaTags(contentToInject, seoOptions)
1447
+ const injectSeo = resolveSeo(contentToInject)
1448
+ if (injectSeo.baseUrl) {
1449
+ const metaTags = generateMetaTags(contentToInject, injectSeo)
1403
1450
  if (metaTags) {
1404
1451
  headInjection += ` ${metaTags}\n`
1405
1452
  }
@@ -1555,9 +1602,10 @@ export function siteContentPlugin(options = {}) {
1555
1602
  })
1556
1603
 
1557
1604
  // Generate SEO files if enabled
1558
- if (seoEnabled && finalContent?.pages) {
1605
+ const buildSeo = resolveSeo(finalContent)
1606
+ if (buildSeo.baseUrl && finalContent?.pages) {
1559
1607
  // Generate sitemap.xml
1560
- const sitemap = generateSitemap(finalContent.pages, seoOptions.baseUrl, filterSeoLocales(seoOptions.locales, finalContent.config), finalContent.config?.i18n?.routeTranslations)
1608
+ const sitemap = generateSitemap(finalContent.pages, buildSeo.baseUrl, filterSeoLocales(buildSeo.locales, finalContent.config), finalContent.config?.i18n?.routeTranslations)
1561
1609
  this.emitFile({
1562
1610
  type: 'asset',
1563
1611
  fileName: 'sitemap.xml',
@@ -1566,7 +1614,7 @@ export function siteContentPlugin(options = {}) {
1566
1614
  console.log('[site-content] Generated sitemap.xml')
1567
1615
 
1568
1616
  // Generate robots.txt
1569
- const robotsTxt = generateRobotsTxt(seoOptions.baseUrl, seoOptions.robots)
1617
+ const robotsTxt = generateRobotsTxt(buildSeo.baseUrl, buildSeo.robots)
1570
1618
  this.emitFile({
1571
1619
  type: 'asset',
1572
1620
  fileName: 'robots.txt',
@@ -36,9 +36,6 @@
36
36
  ],
37
37
  "template": [
38
38
  "site.yml::template"
39
- ],
40
- "url": [
41
- "site.yml::$url"
42
39
  ]
43
40
  }
44
41
  },
@@ -150,15 +150,15 @@ const INFO_TO_SITE_YML = {
150
150
  // the path an author wrote for an image in the project, which `restoreAssetRefs`
151
151
  // has already put back by recognizing the serve URL the push recorded in
152
152
  // `assets.json` (asset-map.js). [Diego, 2026-09-10]
153
- preview: 'preview',
154
- // ⭐ `url` — where the site is live → `site.yml::$url`. The backend records it at
155
- // every publish, and pull is its only way onto disk; recorded rather than authored,
156
- // so it lands under the `$` like `$uuid`.
157
153
  //
158
- // ⚠️ Both were listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
154
+ // ⚠️ It was listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
159
155
  // 2026-09-10. Leaving an app-written field out of this map is what lets the next
160
156
  // push destroy it, since `info` is replaced whole.
161
- url: '$url',
157
+ preview: 'preview',
158
+ // ⛔ `url` is RETIRED (2026-09-17) and must not come back: where a site went live
159
+ // is a deploy fact, which `publish` records in `deploy.yml`, not site config. A
160
+ // document that still carries `info.url` writes nothing — an unlisted key is
161
+ // ignored — and a `$url` already on disk is left alone and never read.
162
162
  }
163
163
 
164
164
  // ── `settings` Section → site.yml ─────────────────────────────────────────────
package/src/uwx/site.js CHANGED
@@ -1332,18 +1332,18 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1332
1332
  // fingerprint `assets.json` recorded for it).
1333
1333
  // The app leaves an author's value alone.
1334
1334
  //
1335
- // ⚠️ This read "`url` and `preview_image` are BACKEND-STAMPED and framework emits
1336
- // NEITHER" until 2026-09-10. Neither half held: nothing stamped `url`, and leaving
1337
- // an app-written field off an allowlist destroys it on every push, because `info`
1338
- // is replaced whole.
1335
+ // ⚠️ Until 2026-09-10 framework emitted no preview, on the premise that the host
1336
+ // stamps it. Nothing did, and leaving an app-written field off an allowlist
1337
+ // destroys it on every push, because `info` is replaced whole.
1339
1338
  setIf(info, 'preview', scalarString(siteYml.preview))
1340
- // ⭐ `url` — where the site is live, so a site card can link to it without opening
1341
- // the editor. The backend records it at every publish (stated by backend,
1342
- // 2026-09-10) and pull brings it into `site.yml::$url`; nothing in framework writes
1343
- // it. The `$` marks it as recorded rather than authored, like `$uuid` — and keeps it
1344
- // out of the rendered payload, where a bare `url:` would sit beside `seo.baseUrl`,
1345
- // the authored canonical address.
1346
- setIf(info, 'url', siteYml.$url)
1339
+ // ⛔ `url` IS RETIRED (2026-09-17) — do not reintroduce it, in either direction.
1340
+ // It carried where the site went live, read from `site.yml::$url` (which pull
1341
+ // wrote), so a site card could link to it. That is a fact about a DEPLOY, not
1342
+ // about the site: `publish` prints it and records it in `deploy.yml`, and a
1343
+ // derived value must not round-trip (the round-trip law, uwx-format.md). The host
1344
+ // no longer declares the field and refuses a push that carries it. A `$url` line
1345
+ // left in an old site.yml is inert — nothing reads it, so nothing sends it.
1346
+ // (uwx-format.md → info.url.)
1347
1347
 
1348
1348
  const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
1349
1349
  const pagesPath = siteYml.paths?.pages
@@ -60,7 +60,7 @@ export function upsertYamlScalar(filePath, key, value) {
60
60
  * rather than half-removed.
61
61
  *
62
62
  * @param {string} filePath
63
- * @param {string} key - a top-level scalar key (e.g. `$url`)
63
+ * @param {string} key - a top-level scalar key (e.g. `preview`)
64
64
  * @returns {boolean} true if the file changed
65
65
  */
66
66
  export function removeYamlScalar(filePath, key) {