@uniweb/build 0.14.32 → 0.14.33

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.14.32",
3
+ "version": "0.14.33",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,11 +59,11 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "0.1.8",
62
+ "@uniweb/theming": "0.1.9",
63
63
  "@uniweb/content-writer": "0.2.6"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/runtime": "0.8.26",
66
+ "@uniweb/runtime": "0.8.27",
67
67
  "@uniweb/content-reader": "1.1.12",
68
68
  "@uniweb/schemas": "0.2.4"
69
69
  },
@@ -74,7 +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/core": "0.7.20"
77
+ "@uniweb/core": "0.7.21"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/prerender.js CHANGED
@@ -13,6 +13,7 @@ import { join, dirname, resolve } from 'node:path'
13
13
  import { pathToFileURL } from 'node:url'
14
14
  import { executeFetch, mergeDataIntoContent } from './site/data-fetcher.js'
15
15
  import { shouldSplitContent } from './site/split-content.js'
16
+ import { FONT_LINKS_MARKER } from './site/head-markers.js'
16
17
  import { getAdapter } from './hosts/index.js'
17
18
  import { detectCiContext } from './hosts/detect-ci-context.js'
18
19
 
@@ -376,9 +377,18 @@ export function scopeFetchedData(fetchedData, scopeRoutes) {
376
377
  * @param {string|null} [options.currentRoute=null] - Route of the page this HTML is for
377
378
  * @returns {string} HTML with build-specific data injected
378
379
  */
379
- function injectBuildData(html, siteContent, { splitContent = false, currentRoute = null, scopeRoutes = null } = {}) {
380
+ export function injectBuildData(html, siteContent, { splitContent = false, currentRoute = null, scopeRoutes = null } = {}) {
380
381
  let result = html
381
382
 
383
+ // Inject the theme's font <link> tags if not already present (the vite
384
+ // plugin normally puts them there when it builds index.html)
385
+ if (siteContent?.theme?.links && !result.includes(FONT_LINKS_MARKER)) {
386
+ result = result.replace(
387
+ '</head>',
388
+ ` ${FONT_LINKS_MARKER}\n${siteContent.theme.links}\n </head>`
389
+ )
390
+ }
391
+
382
392
  // Inject theme CSS if not already present
383
393
  if (siteContent?.theme?.css && !result.includes('id="uniweb-theme"')) {
384
394
  result = result.replace(
@@ -388,11 +398,12 @@ function injectBuildData(html, siteContent, { splitContent = false, currentRoute
388
398
  }
389
399
 
390
400
  // Inject site content as JSON for hydration
391
- // Strip CSS from theme (it's already in a <style> tag)
401
+ // Strip CSS and font links from theme (both are already in <head>)
392
402
  let contentForJson = { ...siteContent }
393
- if (contentForJson.theme?.css) {
403
+ if (contentForJson.theme?.css || contentForJson.theme?.links) {
394
404
  contentForJson.theme = { ...contentForJson.theme }
395
405
  delete contentForJson.theme.css
406
+ delete contentForJson.theme.links
396
407
  }
397
408
 
398
409
  // Split mode: strip sections from all pages except the current one.
@@ -258,6 +258,7 @@ export async function generatePdfThumbnail(pdfPath, outputPath, options = {}) {
258
258
  *
259
259
  * @param {Object} asset - Asset info
260
260
  * @param {Object} options - Processing options
261
+ * @param {string} [options.basePath='/'] - Site base path for subdirectory deployments
261
262
  * @returns {Promise<Object>} Processing result with poster/thumbnail info
262
263
  */
263
264
  export async function processAdvancedAsset(asset, options = {}) {
@@ -266,9 +267,16 @@ export async function processAdvancedAsset(asset, options = {}) {
266
267
  assetsSubdir = 'assets',
267
268
  videoPosters = true,
268
269
  pdfThumbnails = true,
269
- quality = 80
270
+ quality = 80,
271
+ basePath = '/'
270
272
  } = options
271
273
 
274
+ // Emitted URLs are site-root-absolute and must carry the deployment base,
275
+ // same as processAsset() — see the withBase() note in asset-processor.js.
276
+ const prefix = basePath && basePath !== '/'
277
+ ? (basePath.endsWith('/') ? basePath.slice(0, -1) : basePath)
278
+ : ''
279
+
272
280
  const { resolved } = asset
273
281
 
274
282
  if (!existsSync(resolved)) {
@@ -293,7 +301,7 @@ export async function processAdvancedAsset(asset, options = {}) {
293
301
  return {
294
302
  processed: true,
295
303
  type: 'video',
296
- poster: `/${assetsSubdir}/${posterFilename}`
304
+ poster: `${prefix}/${assetsSubdir}/${posterFilename}`
297
305
  }
298
306
  } else if (result.skipped) {
299
307
  // ffmpeg not available - not an error, just skip
@@ -314,7 +322,7 @@ export async function processAdvancedAsset(asset, options = {}) {
314
322
  return {
315
323
  processed: true,
316
324
  type: 'pdf',
317
- thumbnail: `/${assetsSubdir}/${thumbFilename}`,
325
+ thumbnail: `${prefix}/${assetsSubdir}/${thumbFilename}`,
318
326
  pageCount: result.pageCount,
319
327
  placeholder: result.placeholder
320
328
  }
@@ -21,6 +21,24 @@ const CONVERTIBLE_FORMATS = ['.png', '.jpg', '.jpeg', '.gif']
21
21
  // Image formats to pass through without conversion
22
22
  const PASSTHROUGH_FORMATS = ['.svg', '.webp', '.avif', '.ico']
23
23
 
24
+ /**
25
+ * Prefix a root-relative URL with the site's base path.
26
+ *
27
+ * Emitted asset URLs are absolute-from-site-root (`/assets/hero-ab12cd34.webp`).
28
+ * Under a subdirectory deployment (GitHub Pages project sites, `/docs/`, ...)
29
+ * the served root moves, so the URL baked into site-content.json has to carry
30
+ * the base — components render `content.images[]` as a raw `<img src>` and have
31
+ * no chance to resolve it themselves. This mirrors what the collection
32
+ * processor already does for collection asset paths.
33
+ */
34
+ function withBase(url, basePath) {
35
+ if (!url || !basePath || basePath === '/') return url
36
+ if (!url.startsWith('/') || url.startsWith('//')) return url
37
+ const prefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath
38
+ if (url === prefix || url.startsWith(prefix + '/')) return url // already based
39
+ return prefix + url
40
+ }
41
+
24
42
  /**
25
43
  * Generate a content hash for a file
26
44
  */
@@ -56,7 +74,8 @@ export async function processAsset(asset, options = {}) {
56
74
  outputDir,
57
75
  assetsSubdir = 'assets',
58
76
  convertToWebp: shouldConvert = true,
59
- quality = 80
77
+ quality = 80,
78
+ basePath = '/'
60
79
  } = options
61
80
 
62
81
  const { original, resolved, isImage } = asset
@@ -66,7 +85,9 @@ export async function processAsset(asset, options = {}) {
66
85
  console.warn(`[asset-processor] Source not found: ${resolved}`)
67
86
  return {
68
87
  original,
69
- output: original, // Keep original path as fallback
88
+ // Keep the authored path as fallback — still based, since an unprocessed
89
+ // public/ asset is served under the base too.
90
+ output: withBase(original, basePath),
70
91
  processed: false,
71
92
  error: 'Source not found'
72
93
  }
@@ -107,8 +128,8 @@ export async function processAsset(asset, options = {}) {
107
128
  // Write processed file
108
129
  await writeFile(outputPath, outputBuffer)
109
130
 
110
- // Return the URL path (relative to site root)
111
- const outputUrl = `/${assetsSubdir}/${outputFilename}`
131
+ // Return the URL path (site-root-absolute, carrying the deployment base)
132
+ const outputUrl = withBase(`/${assetsSubdir}/${outputFilename}`, basePath)
112
133
 
113
134
  return {
114
135
  original,
@@ -123,7 +144,7 @@ export async function processAsset(asset, options = {}) {
123
144
  console.warn(`[asset-processor] Failed to process ${resolved}:`, error.message)
124
145
  return {
125
146
  original,
126
- output: original,
147
+ output: withBase(original, basePath),
127
148
  processed: false,
128
149
  error: error.message
129
150
  }
@@ -135,6 +156,7 @@ export async function processAsset(asset, options = {}) {
135
156
  *
136
157
  * @param {Object} assetManifest - Asset manifest from content collector
137
158
  * @param {Object} options - Processing options
159
+ * @param {string} [options.basePath='/'] - Site base path for subdirectory deployments
138
160
  * @returns {Promise<Object>} Mapping of original paths to output URLs
139
161
  */
140
162
  export async function processAssets(assetManifest, options = {}) {
@@ -97,7 +97,7 @@ export async function buildSiteData({
97
97
  // No vite needed — collectSiteContent is a plain async function.
98
98
  // dropUnpublished: link mode is always a published deploy — prune hidden
99
99
  // pages + their subtree so drafts never reach the served site.
100
- let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true })
100
+ let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true, base: basePath })
101
101
 
102
102
  // 2. Compile content collections (file-based markdown/yaml/json).
103
103
  // `writeCollectionFiles` lands them under `<siteRoot>/public/data/`;
@@ -138,6 +138,7 @@ export async function buildSiteData({
138
138
  assetsSubdir: assetsOpts.outputDir,
139
139
  convertToWebp: assetsOpts.convertToWebp,
140
140
  quality: assetsOpts.quality,
141
+ basePath,
141
142
  })
142
143
 
143
144
  const advancedEnabled = assetsOpts.videoPosters || assetsOpts.pdfThumbnails
@@ -150,6 +151,7 @@ export async function buildSiteData({
150
151
  videoPosters: assetsOpts.videoPosters,
151
152
  pdfThumbnails: assetsOpts.pdfThumbnails,
152
153
  quality: assetsOpts.quality,
154
+ basePath,
153
155
  hasExplicitPoster: siteContent.hasExplicitPoster || new Set(),
154
156
  hasExplicitPreview: siteContent.hasExplicitPreview || new Set(),
155
157
  }
@@ -2013,11 +2013,26 @@ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
2013
2013
  * @returns {Promise<Object>} Site content object with assets manifest
2014
2014
  */
2015
2015
  export async function collectSiteContent(sitePath, options = {}) {
2016
- const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false } = options
2016
+ const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false, base = '/' } = options
2017
2017
 
2018
2018
  // Read site config and raw theme config
2019
2019
  const siteConfig = await readYamlFile(join(sitePath, configFile))
2020
2020
 
2021
+ // Record the RESOLVED base (--base > UNIWEB_BASE > site.yml::base) on the
2022
+ // config so every consumer reads one value. Prerender sets website.basePath
2023
+ // from `config.base` alone — it has no access to Vite's BASE_URL — so a base
2024
+ // that arrived via UNIWEB_BASE (what the generated GitHub Pages workflow
2025
+ // uses) was invisible to it, and prerendered <a href>s came out with no
2026
+ // base prefix while the hydrated browser routes were fine.
2027
+ //
2028
+ // Only a real base is written. At '/' the field stays absent, because in
2029
+ // shell mode `config.base` is the SERVING layer's channel (it injects the
2030
+ // served subpath, e.g. /gateway/site/<uuid>/) and a build-time '/' would
2031
+ // be a meaningless value sitting in its slot.
2032
+ if (base && base !== '/') {
2033
+ siteConfig.base = base
2034
+ }
2035
+
2021
2036
  // Profile selects workspace-root defaults: site.yml → pages/ + page mode +
2022
2037
  // pages: ordering; document.yml → content/ + folder mode + content: ordering.
2023
2038
  const profile = (profileName && PROFILE_ALIASES[profileName]) || getContentProfile(configFile)
@@ -2047,7 +2062,10 @@ export async function collectSiteContent(sitePath, options = {}) {
2047
2062
 
2048
2063
  // Load foundation info (vars + layout names) and process theme
2049
2064
  const { vars: foundationVars, layoutNames: layoutNames } = await loadFoundationInfo(foundationPath)
2050
- const { config: processedTheme, css: themeCSS, warnings } = buildTheme(rawThemeConfig, { foundationVars })
2065
+ // `base` reaches the theme because self-hosted font faces are authored
2066
+ // root-relative (`/fonts/x.woff2`) and the emitted @font-face lives in an
2067
+ // inline <style> — under a subdirectory deployment it must carry the base.
2068
+ const { config: processedTheme, css: themeCSS, links: themeLinks, warnings } = buildTheme(rawThemeConfig, { foundationVars, base })
2051
2069
 
2052
2070
  // Log theme warnings
2053
2071
  if (warnings?.length > 0) {
@@ -2060,7 +2078,8 @@ export async function collectSiteContent(sitePath, options = {}) {
2060
2078
  config: siteConfig,
2061
2079
  theme: {
2062
2080
  ...processedTheme,
2063
- css: themeCSS
2081
+ css: themeCSS,
2082
+ links: themeLinks
2064
2083
  },
2065
2084
  pages: [],
2066
2085
  assets: {}
@@ -2208,7 +2227,11 @@ export async function collectSiteContent(sitePath, options = {}) {
2208
2227
  },
2209
2228
  theme: {
2210
2229
  ...processedTheme,
2211
- css: themeCSS
2230
+ css: themeCSS,
2231
+ // Font <link> tags (Google Fonts stylesheet + preconnects, preload hints
2232
+ // for self-hosted faces). The theme CSS stopped carrying an @import for
2233
+ // these, so they only reach the page if a consumer injects `links`.
2234
+ links: themeLinks
2212
2235
  },
2213
2236
  // Reachability axis: on the published build paths, drop `hidden` pages and
2214
2237
  // their whole subtree (cascade). Dev keeps them so drafts stay previewable.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Stable markers for head content the build injects.
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.
9
+ *
10
+ * The theme CSS uses `id="uniweb-theme"` on its <style> for the same purpose;
11
+ * <link> tags have no natural id to hang that on, hence the comment marker.
12
+ *
13
+ * @module @uniweb/build/site
14
+ */
15
+
16
+ export const FONT_LINKS_MARKER = '<!--uniweb-fonts-->'
@@ -39,6 +39,7 @@ import { processAdvancedAssets } from './advanced-processors.js'
39
39
  import { processCollections, writeCollectionFiles } from './collection-processor.js'
40
40
  import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
41
41
  import { shouldSplitContent } from './split-content.js'
42
+ import { FONT_LINKS_MARKER } from './head-markers.js'
42
43
 
43
44
  // BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
44
45
  const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
@@ -621,7 +622,7 @@ export function siteContentPlugin(options = {}) {
621
622
  try {
622
623
  // dropUnpublished only on a production build — in dev (serve) hidden
623
624
  // pages stay in the graph so in-progress drafts remain previewable.
624
- siteContent = await collectSiteContent(resolvedSitePath, { foundationPath, dropUnpublished: isProduction })
625
+ siteContent = await collectSiteContent(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath })
625
626
  headHtml = await loadHeadHtml()
626
627
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
627
628
 
@@ -669,7 +670,7 @@ export function siteContentPlugin(options = {}) {
669
670
  rebuildTimeout = setTimeout(async () => {
670
671
  console.log('[site-content] Content changed, rebuilding...')
671
672
  try {
672
- siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
673
+ siteContent = await collectSiteContent(resolvedSitePath, { foundationPath, base: basePath })
673
674
  headHtml = await loadHeadHtml()
674
675
  // Execute fetches for the updated content
675
676
  await executeDevFetches(siteContent, resolvedSitePath)
@@ -989,22 +990,19 @@ export function siteContentPlugin(options = {}) {
989
990
  headInjection += headHtml + '\n'
990
991
  }
991
992
 
992
- // Inject font preconnect links (before theme CSS so browser starts DNS early)
993
- const fontImports = contentToInject.theme?.fonts?.import
994
- if (Array.isArray(fontImports) && fontImports.length > 0) {
995
- const origins = new Set()
996
- for (const font of fontImports) {
997
- if (font.url) {
998
- try { origins.add(new URL(font.url).origin) } catch {}
999
- }
1000
- }
1001
- for (const origin of origins) {
1002
- headInjection += ` <link rel="preconnect" href="${origin}">\n`
1003
- }
1004
- // Google Fonts serves CSS from googleapis.com but font files from gstatic.com
1005
- if (origins.has('https://fonts.googleapis.com')) {
1006
- headInjection += ` <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n`
1007
- }
993
+ // Inject the theme's font <link> tags (before theme CSS so the browser
994
+ // starts DNS/fetch early). This is every font link in one place:
995
+ // preconnects, the merged Google Fonts stylesheet, and preload hints for
996
+ // self-hosted faces.
997
+ //
998
+ // These used to be dropped. The theme CSS once carried `@import
999
+ // url(<google>)` and this spot only had to add a preconnect in front of
1000
+ // it; when @uniweb/theming replaced that @import with a `links` string,
1001
+ // nothing here started consuming it — so the build preconnected to
1002
+ // Google Fonts and then never requested the stylesheet, leaving every
1003
+ // imported family undefined and silently falling back.
1004
+ if (contentToInject.theme?.links) {
1005
+ headInjection += ` ${FONT_LINKS_MARKER}\n${contentToInject.theme.links}\n`
1008
1006
  }
1009
1007
 
1010
1008
  // Inject theme CSS
@@ -1063,7 +1061,8 @@ export function siteContentPlugin(options = {}) {
1063
1061
  outputDir: resolvedOutDir,
1064
1062
  assetsSubdir: assetsOptions.outputDir,
1065
1063
  convertToWebp: assetsOptions.convertToWebp,
1066
- quality: assetsOptions.quality
1064
+ quality: assetsOptions.quality,
1065
+ basePath
1067
1066
  })
1068
1067
 
1069
1068
  // Process advanced assets (videos, PDFs)
@@ -1079,6 +1078,7 @@ export function siteContentPlugin(options = {}) {
1079
1078
  videoPosters: assetsOptions.videoPosters,
1080
1079
  pdfThumbnails: assetsOptions.pdfThumbnails,
1081
1080
  quality: assetsOptions.quality,
1081
+ basePath,
1082
1082
  // Pass explicit poster/preview sets to skip auto-generation
1083
1083
  hasExplicitPoster: siteContent.hasExplicitPoster || new Set(),
1084
1084
  hasExplicitPreview: siteContent.hasExplicitPreview || new Set()