@uniweb/build 0.15.12 → 0.15.14

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.15.12",
3
+ "version": "0.15.14",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,13 +59,13 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "0.1.14",
63
- "@uniweb/projections": "0.1.5",
62
+ "@uniweb/projections": "0.1.6",
63
+ "@uniweb/theming": "0.1.15",
64
64
  "@uniweb/content-writer": "0.2.9"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/content-reader": "1.1.17",
68
- "@uniweb/runtime": "0.8.41",
67
+ "@uniweb/runtime": "0.8.42",
68
+ "@uniweb/content-reader": "1.1.19",
69
69
  "@uniweb/schemas": "0.2.4"
70
70
  },
71
71
  "peerDependencies": {
@@ -75,7 +75,7 @@
75
75
  "@tailwindcss/vite": "^4.0.0",
76
76
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
77
77
  "vite-plugin-svgr": "^4.0.0",
78
- "@uniweb/core": "0.7.33"
78
+ "@uniweb/core": "0.7.34"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/prerender.js CHANGED
@@ -381,35 +381,19 @@ export function scopeFetchedData(fetchedData, scopeRoutes) {
381
381
  export function injectBuildData(html, siteContent, { splitContent = false, currentRoute = null, scopeRoutes = null } = {}) {
382
382
  let result = html
383
383
 
384
- // Inject the theme's font <link> tags if not already present (the vite
385
- // plugin normally puts them there when it builds index.html)
384
+ // Neither the theme <style> NOR the font <link>s are injected here anymore.
385
+ // Both are derived from the website graph (`website.themeData`), so they
386
+ // belong to the shared seam — @uniweb/runtime/ssr's injectPageContent(),
387
+ // which runs just before this — and every lane gets them from one
388
+ // implementation. The theme CSS sat here until 2026-07-28 and cloud-rendered
389
+ // pages were unstyled the whole time; the font links followed once
390
+ // FONT_LINKS_MARKER moved to @uniweb/theming, the one package both this and
391
+ // the runtime can read it from.
386
392
  //
387
- // STILL HERE, and it is the remaining half of the 2026-07-28 seam fix.
388
- // `theme.links` is graph-derived like the theme CSS was, so by the rule
389
- // below it belongs in injectPageContent() too a lane that doesn't run
390
- // this build gets no webfont <link>s and falls back to system fonts.
391
- // What blocks the move is the marker, not the principle: FONT_LINKS_MARKER
392
- // has a second consumer in site/plugin.js (the vite plugin), which must not
393
- // import the SSR bundle, and @uniweb/build declares no dependency on
394
- // @uniweb/runtime — so there is no one place both sides can read it from
395
- // today. Fixing that means giving the runtime a zero-dep leaf export for
396
- // the constant and re-exporting it here. Degraded-not-broken (wrong
397
- // typeface, not unstyled), which is why it was scoped out rather than
398
- // rushed.
399
- if (siteContent?.theme?.links && !result.includes(FONT_LINKS_MARKER)) {
400
- result = result.replace(
401
- '</head>',
402
- ` ${FONT_LINKS_MARKER}\n${siteContent.theme.links}\n </head>`
403
- )
404
- }
405
-
406
- // Theme CSS is NOT injected here. Like the appearance boot script below, it
407
- // is derived from the website graph (`website.themeData`), so it belongs to
408
- // the shared seam — @uniweb/runtime/ssr's injectPageContent(), which runs
409
- // just before this — and every lane gets it from one implementation. It sat
410
- // here until 2026-07-28 and cloud-rendered pages were unstyled the whole
411
- // time. See the "which side of the seam?" note there before adding a new
412
- // head injection, and note that this file has now got it wrong twice.
393
+ // See the "which side of the seam?" note there before adding a new head
394
+ // injection, and note that this file has now got it wrong twice. The guard
395
+ // against a third is mechanical: tests/head-seam-parity.test.js fails if this
396
+ // function's <head> output gains anything outside its BUILD_ONLY allowlist.
413
397
 
414
398
  // The pre-paint appearance script is NOT injected here. It belongs to the
415
399
  // shared seam — @uniweb/runtime/ssr's injectPageContent(), which runs just
@@ -1,16 +1,26 @@
1
1
  /**
2
2
  * Stable markers for head content the build injects.
3
3
  *
4
- * Two stages can write the same block: the vite plugin's `transformIndexHtml`
5
- * (which produces `dist/index.html`) and the prerenderer (which post-processes
6
- * that HTML per page). The marker lets the second stage tell "already injected"
7
- * from "never injected" instead of guessing, so a page rendered through both
8
- * paths gets exactly one copy.
4
+ * Several stages can write the same block: this package's vite plugin
5
+ * (`transformIndexHtml`, which produces `dist/index.html`), this package's
6
+ * prerenderer (which post-processes that HTML per page), and since the
7
+ * 2026-07-28 seam fix `@uniweb/runtime`'s `injectPageContent()`, the prerender
8
+ * seam every lane shares. The marker lets a later stage tell "already injected"
9
+ * from "never injected" instead of guessing, so a page passing through several
10
+ * gets exactly one copy.
9
11
  *
10
12
  * The theme CSS uses `id="uniweb-theme"` on its <style> for the same purpose;
11
13
  * <link> tags have no natural id to hang that on, hence the comment marker.
12
14
  *
15
+ * **The marker itself now lives in `@uniweb/theming`**, beside the code that
16
+ * generates the block it delimits. This package and `@uniweb/runtime` cannot
17
+ * import one another, but both depend on `@uniweb/theming` — so that is the one
18
+ * home neither has to reach across a dependency boundary to read, and the
19
+ * literal is never duplicated (two halves of a dedupe check that drift apart
20
+ * stop deduping, silently). Re-exported here so every existing import in this
21
+ * package keeps working unchanged.
22
+ *
13
23
  * @module @uniweb/build/site
14
24
  */
15
25
 
16
- export const FONT_LINKS_MARKER = '<!--uniweb-fonts-->'
26
+ export { FONT_LINKS_MARKER } from '@uniweb/theming'
@@ -108,29 +108,50 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
108
108
  * map lives only on disk (`locales/{locale}.json`) and is recovered on pull (see
109
109
  * unwrapLocalizedContent).
110
110
  *
111
- * Returns the bare doc unchanged when there are no target locales / no translations
112
- * for any of them (single-locale and pre-localization sites are untouched). A
113
- * target locale with no translation for THIS doc is omitted (it falls back to the
114
- * source locale), so the payload stays lean.
111
+ * ALWAYS returns the per-locale map `{ [sourceLocale]: doc }` at minimum. A target
112
+ * locale with no translation for THIS doc is omitted (it falls back to the source
113
+ * locale through the delivery locale chain), so the payload stays lean.
114
+ *
115
+ * It used to return the bare doc when nothing had been translated, which made the
116
+ * wire shape depend on *whether a given section happened to have a translation* —
117
+ * so one push could carry a map for a translated section and a bare doc for its
118
+ * untranslated neighbour, on the same page. The field declares `localized: true`,
119
+ * and a bare doc makes that declaration false: `{type, content}` is an object, so
120
+ * it satisfies "must be a map" with `type` and `content` sitting where locale codes
121
+ * belong, and a store cannot validate the one property the field declares. Every
122
+ * reader then has to disambiguate by asking "does this object happen to look like a
123
+ * ProseMirror doc" — a heuristic that has to be implemented identically in three
124
+ * codebases forever, and has already fail-opened in one of them.
125
+ *
126
+ * `localizeScalar` below has always wrapped unconditionally, so the producer was
127
+ * also inconsistent with itself: a section's `title` shipped as `{en: …}` while its
128
+ * `content` shipped bare, in the same payload.
129
+ *
130
+ * Readers stay tolerant of the bare form permanently — it exists in stores, in
131
+ * `.uwx` files on disk, and in every backup taken before this change. This changes
132
+ * what we WRITE, never what we accept (see `isLocalizedContent`, and
133
+ * `unwrapLocalizedContent`, which returns `content[sourceLocale]` so a source-only
134
+ * map round-trips back to a bare doc on the file lane).
115
135
  *
116
136
  * @param {object} doc - the source-locale ProseMirror content doc
117
137
  * @param {string} sourceLocale
118
- * @param {string[]} targetLocales
119
- * @param {object} translations - `{ locale: { hash: tgt } }` from loadLocaleTranslations
138
+ * @param {string[]} [targetLocales]
139
+ * @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
120
140
  */
121
141
  export function localizeContentDoc(doc, sourceLocale, targetLocales, translations) {
122
- if (!isProseMirrorDoc(doc) || !targetLocales || targetLocales.length === 0 || !translations) {
123
- return doc
124
- }
142
+ // Non-docs (null, an already-localized map) pass through untouched.
143
+ if (!isProseMirrorDoc(doc)) return doc
144
+
125
145
  const result = { [sourceLocale]: doc }
126
- for (const locale of targetLocales) {
127
- const table = translations[locale]
128
- if (!table) continue
129
- const resolved = resolveDocForLocale(doc, table)
130
- if (resolved) result[locale] = resolved
146
+ if (targetLocales && translations) {
147
+ for (const locale of targetLocales) {
148
+ const table = translations[locale]
149
+ if (!table) continue
150
+ const resolved = resolveDocForLocale(doc, table)
151
+ if (resolved) result[locale] = resolved
152
+ }
131
153
  }
132
- // Only wrap when at least one target carried a translation — else stay a bare doc.
133
- return Object.keys(result).length > 1 ? result : doc
154
+ return result
134
155
  }
135
156
 
136
157
  /**
package/src/uwx/site.js CHANGED
@@ -663,11 +663,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
663
663
 
664
664
  // Wrap each section's content into its per-locale form (source doc + target
665
665
  // structural maps from locales/{locale}.json, or a free-form body override from
666
- // locales/freeform/**) when the site is multi-locale. A non-invasive post-pass
667
- // over the built tree — single-locale sites are untouched.
668
- if (targetLocales.length > 0) {
669
- await localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot)
670
- }
666
+ // locales/freeform/**). A non-invasive post-pass over the built tree.
667
+ //
668
+ // Runs for EVERY site, including single-locale ones: `content` declares
669
+ // `localized: true`, so it ships as `{ [sourceLocale]: doc }` whatever the
670
+ // language count. Guarding this on `targetLocales.length` is what used to send
671
+ // a bare doc from single-locale sites — see localizeContentDoc for why one
672
+ // field with two shapes is a store that cannot validate its own declaration.
673
+ await localizeContentTree(pages, layoutSections, sourceLocale, targetLocales, translations, siteRoot)
671
674
 
672
675
  // Collection DECLARATIONS — the merged collections.yml + site.yml::collections
673
676
  // config (the records themselves are separate entities; this is just the config).