uniweb 1.0.0 → 1.0.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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build Command
|
|
3
|
+
*
|
|
4
|
+
* Builds foundations with schema generation, or sites.
|
|
5
|
+
*
|
|
6
|
+
* Two site build pipelines are available, but they're INTERNAL vocabulary
|
|
7
|
+
* after Phase 2 of the CLI ergonomics overhaul. Users see `uniweb publish`
|
|
8
|
+
* (Uniweb hosting — runtime-linked), `uniweb deploy --host` (third-party host)
|
|
9
|
+
* and `uniweb export` (self-contained bundle); the build command itself just
|
|
10
|
+
* dispatches to whichever pipeline the caller requested.
|
|
11
|
+
*
|
|
12
|
+
* --bundle (internal; called by `uniweb deploy --host` / `uniweb export`)
|
|
13
|
+
* Full vite + post-vite pipeline. Produces a static-host JS bundle
|
|
14
|
+
* (`dist/index.html`, `dist/entry.js`, `_importmap/*`, `_pages/*` for
|
|
15
|
+
* split mode, sitemap/robots/search-index, prerendered HTML when
|
|
16
|
+
* configured). Foundation is loaded by URL when site.yml's foundation
|
|
17
|
+
* is a registry ref; statically inlined when it's a workspace-local
|
|
18
|
+
* ref (the self-contained case).
|
|
19
|
+
*
|
|
20
|
+
* --link (internal; called by `uniweb publish`)
|
|
21
|
+
* Data-only pipeline. No vite. Emits ONLY what Uniweb hosting
|
|
22
|
+
* needs: `dist/site-content.json` (with full sections),
|
|
23
|
+
* `dist/<lang>/site-content.json` per non-default locale,
|
|
24
|
+
* `dist/data/*.json` (collections), and `dist/assets/<media>` (images,
|
|
25
|
+
* fonts, video posters). The backend stitches runtime + foundation per
|
|
26
|
+
* request — the site's JS bundle would be dead weight.
|
|
27
|
+
*
|
|
28
|
+
* Bare `uniweb build` for a site defaults to --bundle (the historical
|
|
29
|
+
* behavior). This is mostly useful for inspecting the build output during
|
|
30
|
+
* development; for shipping, use `uniweb publish` (Uniweb hosting) or
|
|
31
|
+
* `uniweb deploy --host` / `uniweb export` (third-party / self-contained).
|
|
32
|
+
*
|
|
33
|
+
* Usage:
|
|
34
|
+
* uniweb build # Build current directory (sites default to --bundle)
|
|
35
|
+
* uniweb build --target foundation # Explicitly build as foundation
|
|
36
|
+
* uniweb build --target site # Explicitly build as site
|
|
37
|
+
* uniweb build --prerender # Force pre-rendering
|
|
38
|
+
* uniweb build --no-prerender # Skip pre-rendering
|
|
39
|
+
* uniweb build --host <name> # Pick the host adapter for this build's
|
|
40
|
+
* postBuild step (e.g. cloudflare-pages,
|
|
41
|
+
* s3-cloudfront, github-pages,
|
|
42
|
+
* generic-static). Default: cloudflare-pages.
|
|
43
|
+
*
|
|
44
|
+
* Internal flags:
|
|
45
|
+
* --link # Data-only pipeline (Uniweb hosting; called by `uniweb publish`)
|
|
46
|
+
* --bundle # Full vite pipeline (third-party / self-contained; deploy --host / export)
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
50
|
+
import { resolve, join, dirname, basename, relative } from 'node:path'
|
|
51
|
+
import { spawn } from 'node:child_process'
|
|
52
|
+
import { writeFile, mkdir } from 'node:fs/promises'
|
|
53
|
+
import { createRequire } from 'node:module'
|
|
54
|
+
|
|
55
|
+
// Import build utilities from @uniweb/build
|
|
56
|
+
import {
|
|
57
|
+
generateEntryPoint,
|
|
58
|
+
discoverComponents,
|
|
59
|
+
resolveFoundationSrcPath,
|
|
60
|
+
classifyPackage,
|
|
61
|
+
isExtensionPackage
|
|
62
|
+
} from '@uniweb/build'
|
|
63
|
+
import {
|
|
64
|
+
resolveDefaultLocale,
|
|
65
|
+
normalizeLanguageList
|
|
66
|
+
} from '@uniweb/core/locale-config'
|
|
67
|
+
import { readSiteConfig } from '@uniweb/build/site'
|
|
68
|
+
import { readWorkspaceConfig, resolveGlob } from '../utils/config.js'
|
|
69
|
+
|
|
70
|
+
// Colors for terminal output
|
|
71
|
+
const colors = {
|
|
72
|
+
reset: '\x1b[0m',
|
|
73
|
+
bright: '\x1b[1m',
|
|
74
|
+
dim: '\x1b[2m',
|
|
75
|
+
cyan: '\x1b[36m',
|
|
76
|
+
green: '\x1b[32m',
|
|
77
|
+
yellow: '\x1b[33m',
|
|
78
|
+
red: '\x1b[31m'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function log(message) {
|
|
82
|
+
console.log(message)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function success(message) {
|
|
86
|
+
console.log(`${colors.green}✓${colors.reset} ${message}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function error(message) {
|
|
90
|
+
console.error(`${colors.red}✗${colors.reset} ${message}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function info(message) {
|
|
94
|
+
console.log(`${colors.cyan}→${colors.reset} ${message}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function warn(message) {
|
|
98
|
+
console.log(`${colors.yellow}⚠${colors.reset} ${message}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Detect project type based on directory contents
|
|
103
|
+
*
|
|
104
|
+
* Detection priority:
|
|
105
|
+
* 1. foundation.js → foundation
|
|
106
|
+
* 2. site.yml → site
|
|
107
|
+
* 3. src/components/ → foundation (fallback)
|
|
108
|
+
* 4. pages/ → site (fallback)
|
|
109
|
+
* 5. pnpm-workspace.yaml or package.json workspaces → workspace
|
|
110
|
+
*/
|
|
111
|
+
function detectProjectType(projectDir) {
|
|
112
|
+
// Workspace check FIRST. A workspace root often co-exists with leaf-
|
|
113
|
+
// package signals (the `src/` foundation lives inside it), so asking
|
|
114
|
+
// the leaf classifier about a workspace root could yield 'foundation'
|
|
115
|
+
// by coincidence. Workspace markers (pnpm-workspace.yaml, or
|
|
116
|
+
// package.json::workspaces) are unambiguous.
|
|
117
|
+
if (existsSync(join(projectDir, 'pnpm-workspace.yaml'))) {
|
|
118
|
+
return 'workspace'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const packageJsonPath = join(projectDir, 'package.json')
|
|
122
|
+
if (existsSync(packageJsonPath)) {
|
|
123
|
+
try {
|
|
124
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
|
|
125
|
+
if (packageJson.workspaces) {
|
|
126
|
+
return 'workspace'
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Ignore parse errors
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Foundation vs site: delegate to the canonical classifier in @uniweb/build.
|
|
134
|
+
const kind = classifyPackage(projectDir)
|
|
135
|
+
if (kind) return kind
|
|
136
|
+
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run a command and return a promise
|
|
142
|
+
*/
|
|
143
|
+
function runCommand(command, args, cwd) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
// No shell: the command is node and a script path, which needs none — and a shell
|
|
146
|
+
// splits a path with a space in it. (Node warns DEP0190 on args passed with one.)
|
|
147
|
+
const proc = spawn(command, args, {
|
|
148
|
+
cwd,
|
|
149
|
+
stdio: 'inherit'
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
proc.on('close', (code) => {
|
|
153
|
+
if (code === 0) {
|
|
154
|
+
resolve()
|
|
155
|
+
} else {
|
|
156
|
+
reject(new Error(`Command failed with exit code ${code}`))
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
proc.on('error', reject)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve the project's local Vite binary.
|
|
166
|
+
*
|
|
167
|
+
* We intentionally do NOT fall back to `npx vite`: npx resolves through npm's
|
|
168
|
+
* global cache, which may hold a stale Vite version (e.g. Vite 5 ignores
|
|
169
|
+
* lib.fileName for CSS and always emits style.css). Using the project's local
|
|
170
|
+
* Vite guarantees the version declared in package.json is the one that runs.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} projectDir
|
|
173
|
+
* @returns {string} Absolute path to the vite CLI entry
|
|
174
|
+
*/
|
|
175
|
+
function resolveLocalVite(projectDir) {
|
|
176
|
+
const require = createRequire(join(projectDir, 'package.json'))
|
|
177
|
+
let pkgJsonPath
|
|
178
|
+
try {
|
|
179
|
+
pkgJsonPath = require.resolve('vite/package.json')
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Vite is not installed in ${projectDir}.\n` +
|
|
183
|
+
`Run \`pnpm install\` (or npm/yarn install) in the project before building.`
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
187
|
+
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite
|
|
188
|
+
if (!binRel) {
|
|
189
|
+
throw new Error(`Could not find vite bin entry in ${pkgJsonPath}`)
|
|
190
|
+
}
|
|
191
|
+
return join(dirname(pkgJsonPath), binRel)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run the project's local Vite with the given args.
|
|
196
|
+
* @param {string} projectDir
|
|
197
|
+
* @param {string[]} args - e.g. ['build']
|
|
198
|
+
*/
|
|
199
|
+
async function runLocalVite(projectDir, args) {
|
|
200
|
+
const viteBin = resolveLocalVite(projectDir)
|
|
201
|
+
await runCommand(process.execPath, [viteBin, ...args], projectDir)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build a foundation
|
|
206
|
+
*/
|
|
207
|
+
async function buildFoundation(projectDir, options = {}) {
|
|
208
|
+
const srcDir = resolveFoundationSrcPath(projectDir)
|
|
209
|
+
|
|
210
|
+
info('Building foundation...')
|
|
211
|
+
|
|
212
|
+
// 1. Discover section types
|
|
213
|
+
log('')
|
|
214
|
+
info('Discovering section types...')
|
|
215
|
+
const components = await discoverComponents(srcDir)
|
|
216
|
+
const componentNames = Object.keys(components)
|
|
217
|
+
|
|
218
|
+
// ⭐ A foundation with no section types yet builds — the state
|
|
219
|
+
// `create --template none` makes on purpose, and one `uniweb dev` and
|
|
220
|
+
// @uniweb/build's entry generation already accept. ⛔ Until 2026-10-08 this
|
|
221
|
+
// stopped the build, saying section types belonged in `components/[Name]/`
|
|
222
|
+
// with a meta.js — where they lived in January — so a fresh `none` project
|
|
223
|
+
// failed its first build, pointed at the wrong folder.
|
|
224
|
+
if (componentNames.length === 0) {
|
|
225
|
+
const sectionsDir = relative(process.cwd(), join(srcDir, 'sections')) || 'sections'
|
|
226
|
+
warn(`No section types yet: ${sectionsDir}/ is empty.`)
|
|
227
|
+
log(
|
|
228
|
+
` ${colors.dim}Add one: ${colors.reset}uniweb add section Hero${colors.dim} — a PascalCase file or folder there is one.${colors.reset}`
|
|
229
|
+
)
|
|
230
|
+
} else {
|
|
231
|
+
const n = componentNames.length
|
|
232
|
+
success(`Found ${n} section type${n === 1 ? '' : 's'}: ${componentNames.join(', ')}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 2. Generate entry point
|
|
236
|
+
log('')
|
|
237
|
+
info('Generating entry point...')
|
|
238
|
+
const entryPath = join(srcDir, '_entry.generated.js')
|
|
239
|
+
await generateEntryPoint(srcDir, entryPath)
|
|
240
|
+
success('Generated _entry.generated.js')
|
|
241
|
+
|
|
242
|
+
// 3. Build with Vite
|
|
243
|
+
log('')
|
|
244
|
+
info('Running Vite build...')
|
|
245
|
+
|
|
246
|
+
// Check if vite.config.js uses the generated entry
|
|
247
|
+
// For now, just run the standard vite build
|
|
248
|
+
await runLocalVite(projectDir, ['build'])
|
|
249
|
+
|
|
250
|
+
// Vite's foundation plugin generates dist/meta/schema.json
|
|
251
|
+
// and processes preview images during the build.
|
|
252
|
+
|
|
253
|
+
success('Vite build complete')
|
|
254
|
+
|
|
255
|
+
// Summary
|
|
256
|
+
log('')
|
|
257
|
+
log(`${colors.green}${colors.bright}Build complete!${colors.reset}`)
|
|
258
|
+
|
|
259
|
+
// The next step is for someone who ran `uniweb build` — not for a push or publish
|
|
260
|
+
// that builds the foundation as one of its own steps (UNIWEB_BUILD_STEP).
|
|
261
|
+
if (process.env.UNIWEB_BUILD_STEP) return
|
|
262
|
+
log('')
|
|
263
|
+
log(`${colors.bright}Share with clients:${colors.reset}`)
|
|
264
|
+
log(
|
|
265
|
+
` ${colors.bright}uniweb register${colors.reset} Release your foundation to the catalog (alias: uniweb release)`
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Ensure a local foundation's `dist/entry.js` is current.
|
|
271
|
+
*
|
|
272
|
+
* Bundle mode reads a local foundation from disk (vite imports it, prerender
|
|
273
|
+
* loads dist/entry.js for SSG), so the foundation must be built and current.
|
|
274
|
+
* Otherwise the verb fails with "Foundation not found at .../dist/entry.js" or
|
|
275
|
+
* silently ships stale artifacts. (Link mode does NOT read the built foundation
|
|
276
|
+
* — it forwards only the `foundation:` ref to the backend and ships no
|
|
277
|
+
* foundation code — so buildSiteLink does not call this.)
|
|
278
|
+
*
|
|
279
|
+
* `buildWorkspace()` already cascades when invoked from a workspace root,
|
|
280
|
+
* but verbs invoked from a site directory (`uniweb build` in `sites/x/`,
|
|
281
|
+
* `uniweb deploy`, `uniweb export`) used to skip the cascade and rely on
|
|
282
|
+
* the user having pre-built the foundation. That broke fresh checkouts
|
|
283
|
+
* where the foundation has never been built locally.
|
|
284
|
+
*
|
|
285
|
+
* This helper is idempotent: when the workspace-root cascade has already
|
|
286
|
+
* run, the freshness check sees a current `dist/entry.js` and returns
|
|
287
|
+
* without rebuilding. So calling it inside `buildSite()` does not
|
|
288
|
+
* double-build under `buildWorkspace()`.
|
|
289
|
+
*
|
|
290
|
+
* Freshness rule: a built artifact (`dist/entry.js` or the legacy
|
|
291
|
+
* `dist/foundation.js`) exists AND its mtime is >= the newest mtime of
|
|
292
|
+
* any tracked source file. Tracked sources: every file under
|
|
293
|
+
* `<foundation>/src/`, plus root-level `package.json`, `foundation.js`,
|
|
294
|
+
* `meta.js` (the structural files that drive entry generation and
|
|
295
|
+
* schema). Build outputs and node_modules are skipped.
|
|
296
|
+
*
|
|
297
|
+
* Both artifact names are accepted because the foundation build emitter
|
|
298
|
+
* was renamed `dist/foundation.js → dist/entry.js` in `@uniweb/build`
|
|
299
|
+
* v0.14.3. A foundation built with an older @uniweb/build still produces
|
|
300
|
+
* the legacy name; we don't want the cascade to keep rebuilding such a
|
|
301
|
+
* foundation forever just because the new name is missing.
|
|
302
|
+
*/
|
|
303
|
+
async function ensureFoundationFresh(foundationDir, label = 'foundation') {
|
|
304
|
+
const distArtifact = findFoundationDistArtifact(foundationDir)
|
|
305
|
+
|
|
306
|
+
if (!distArtifact) {
|
|
307
|
+
info(
|
|
308
|
+
`Local ${label} not built yet — building ${basename(foundationDir)} first`
|
|
309
|
+
)
|
|
310
|
+
log('')
|
|
311
|
+
await buildFoundation(foundationDir)
|
|
312
|
+
log('')
|
|
313
|
+
return { built: true, reason: 'missing' }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const distMtime = statSync(distArtifact).mtimeMs
|
|
317
|
+
const stale = isFoundationSourceNewerThan(foundationDir, distMtime)
|
|
318
|
+
|
|
319
|
+
if (stale) {
|
|
320
|
+
info(
|
|
321
|
+
`Local ${label} sources changed — rebuilding ${basename(foundationDir)}`
|
|
322
|
+
)
|
|
323
|
+
log('')
|
|
324
|
+
await buildFoundation(foundationDir)
|
|
325
|
+
log('')
|
|
326
|
+
return { built: true, reason: 'stale' }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { built: false, reason: 'fresh' }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Locate the foundation's built entry artifact. Returns the path of the
|
|
334
|
+
* first match, or null when neither file exists. Prefers the current
|
|
335
|
+
* name (`dist/entry.js`) over the legacy one (`dist/foundation.js`).
|
|
336
|
+
*/
|
|
337
|
+
function findFoundationDistArtifact(foundationDir) {
|
|
338
|
+
for (const name of ['entry.js', 'foundation.js']) {
|
|
339
|
+
const p = join(foundationDir, 'dist', name)
|
|
340
|
+
if (existsSync(p)) return p
|
|
341
|
+
}
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Returns true if any tracked foundation source file has an mtime newer
|
|
347
|
+
* than `referenceMtime`. Walks `<foundationDir>/src/` recursively (skipping
|
|
348
|
+
* dotfiles, node_modules, and dist). Also stats the root structural files.
|
|
349
|
+
*/
|
|
350
|
+
function isFoundationSourceNewerThan(foundationDir, referenceMtime) {
|
|
351
|
+
const rootFiles = ['package.json', 'foundation.js', 'meta.js']
|
|
352
|
+
for (const f of rootFiles) {
|
|
353
|
+
const p = join(foundationDir, f)
|
|
354
|
+
if (existsSync(p) && statSync(p).mtimeMs > referenceMtime) return true
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const srcDir = join(foundationDir, 'src')
|
|
358
|
+
if (!existsSync(srcDir)) return false
|
|
359
|
+
|
|
360
|
+
const stack = [srcDir]
|
|
361
|
+
while (stack.length) {
|
|
362
|
+
const dir = stack.pop()
|
|
363
|
+
let entries
|
|
364
|
+
try {
|
|
365
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
366
|
+
} catch {
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
for (const e of entries) {
|
|
370
|
+
if (
|
|
371
|
+
e.name === 'node_modules' ||
|
|
372
|
+
e.name === 'dist' ||
|
|
373
|
+
e.name.startsWith('.')
|
|
374
|
+
)
|
|
375
|
+
continue
|
|
376
|
+
const full = join(dir, e.name)
|
|
377
|
+
if (e.isDirectory()) {
|
|
378
|
+
stack.push(full)
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
if (!e.isFile()) continue
|
|
382
|
+
try {
|
|
383
|
+
if (statSync(full).mtimeMs > referenceMtime) return true
|
|
384
|
+
} catch {
|
|
385
|
+
/* ignore */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return false
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Load site i18n configuration
|
|
395
|
+
*
|
|
396
|
+
* Resolves locales from config:
|
|
397
|
+
* - undefined → all available locales (from locales/*.json)
|
|
398
|
+
* - '*' → explicitly all available locales
|
|
399
|
+
* - ['es', 'fr'] → only those specific locales
|
|
400
|
+
*/
|
|
401
|
+
async function loadI18nConfig(projectDir, siteConfig = null) {
|
|
402
|
+
const config = siteConfig || readSiteConfig(projectDir)
|
|
403
|
+
|
|
404
|
+
const localesDir = config.i18n?.localesDir || 'locales'
|
|
405
|
+
const localesPath = join(projectDir, localesDir)
|
|
406
|
+
|
|
407
|
+
// Resolve locales (undefined/'*' → all available, array → specific)
|
|
408
|
+
const { resolveLocales } = await import('@uniweb/build/i18n')
|
|
409
|
+
let locales = await resolveLocales(config.languages, localesPath)
|
|
410
|
+
|
|
411
|
+
// Publish filter: build outputs are visitor artifacts. With an explicit
|
|
412
|
+
// `publishLanguages:`, only listed locales get dist/{locale}/ content,
|
|
413
|
+
// per-locale HTML, hreflang, and translated collections. Draft locales
|
|
414
|
+
// stay previewable in dev, which doesn't run this path. Intersecting the
|
|
415
|
+
// DISK-resolved list here also covers wildcard (`languages: '*'`) sites.
|
|
416
|
+
if (config.publishLanguages != null) {
|
|
417
|
+
const publishSet = new Set(normalizeLanguageList(config.publishLanguages))
|
|
418
|
+
const dropped = locales.filter((l) => !publishSet.has(l))
|
|
419
|
+
locales = locales.filter((l) => publishSet.has(l))
|
|
420
|
+
if (dropped.length > 0) {
|
|
421
|
+
info(
|
|
422
|
+
`Skipping non-publishable locale(s): ${dropped.join(', ')} (not in publishLanguages)`
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ⛔ The default locale is the root tree, not `dist/<default>/`. An explicit
|
|
428
|
+
// `languages: [en, fr]` resolves verbatim — default included — so the DECLARED
|
|
429
|
+
// form (the one the contract tells authors to use) minted a duplicate copy of
|
|
430
|
+
// every page, while the wildcard form did not. Dropping it here keeps content,
|
|
431
|
+
// per-locale HTML and translated records agreeing on one set.
|
|
432
|
+
const defaultLocale = resolveDefaultLocale(config)
|
|
433
|
+
locales = locales.filter((l) => l !== defaultLocale)
|
|
434
|
+
|
|
435
|
+
if (locales.length === 0) return null
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
defaultLocale,
|
|
439
|
+
locales,
|
|
440
|
+
localesDir
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Build localized content for all configured locales
|
|
446
|
+
*/
|
|
447
|
+
async function buildLocalizedContent(projectDir, i18nConfig) {
|
|
448
|
+
const { buildLocalizedContent } = await import('@uniweb/build/i18n')
|
|
449
|
+
|
|
450
|
+
const outputs = await buildLocalizedContent(projectDir, {
|
|
451
|
+
localesDir: i18nConfig.localesDir,
|
|
452
|
+
locales: i18nConfig.locales,
|
|
453
|
+
outputDir: join(projectDir, 'dist'),
|
|
454
|
+
fallbackToSource: true
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
return outputs
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Generate index.html for each locale with hreflang tags
|
|
462
|
+
*/
|
|
463
|
+
async function generateLocalizedHtml(projectDir, i18nConfig) {
|
|
464
|
+
const { readFile, writeFile, mkdir, copyFile } =
|
|
465
|
+
await import('node:fs/promises')
|
|
466
|
+
const distDir = join(projectDir, 'dist')
|
|
467
|
+
const baseHtmlPath = join(distDir, 'index.html')
|
|
468
|
+
|
|
469
|
+
if (!existsSync(baseHtmlPath)) {
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let baseHtml = await readFile(baseHtmlPath, 'utf-8')
|
|
474
|
+
|
|
475
|
+
// Build hreflang tags
|
|
476
|
+
const hreflangTags = i18nConfig.locales
|
|
477
|
+
.map(
|
|
478
|
+
(locale) =>
|
|
479
|
+
`<link rel="alternate" hreflang="${locale}" href="/${locale}/" />`
|
|
480
|
+
)
|
|
481
|
+
.join('\n ')
|
|
482
|
+
|
|
483
|
+
const defaultHreflang = `<link rel="alternate" hreflang="x-default" href="/" />`
|
|
484
|
+
const allHreflangTags = `${hreflangTags}\n ${defaultHreflang}`
|
|
485
|
+
|
|
486
|
+
// Add hreflang to base HTML (for default locale)
|
|
487
|
+
if (!baseHtml.includes('hreflang')) {
|
|
488
|
+
baseHtml = baseHtml.replace('</head>', ` ${allHreflangTags}\n </head>`)
|
|
489
|
+
await writeFile(baseHtmlPath, baseHtml)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Generate index.html for each locale
|
|
493
|
+
for (const locale of i18nConfig.locales) {
|
|
494
|
+
const localeDir = join(distDir, locale)
|
|
495
|
+
await mkdir(localeDir, { recursive: true })
|
|
496
|
+
|
|
497
|
+
// Read locale-specific site-content.json
|
|
498
|
+
const localeContentPath = join(localeDir, 'site-content.json')
|
|
499
|
+
let localeHtml = baseHtml
|
|
500
|
+
|
|
501
|
+
// Update html lang attribute
|
|
502
|
+
localeHtml = localeHtml.replace(
|
|
503
|
+
/<html[^>]*lang="[^"]*"/,
|
|
504
|
+
`<html lang="${locale}"`
|
|
505
|
+
)
|
|
506
|
+
if (!localeHtml.includes('lang=')) {
|
|
507
|
+
localeHtml = localeHtml.replace('<html', `<html lang="${locale}"`)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Update script src for locale-specific content if inlined
|
|
511
|
+
if (existsSync(localeContentPath)) {
|
|
512
|
+
const localeContent = await readFile(localeContentPath, 'utf-8')
|
|
513
|
+
// Replace the inlined content if present
|
|
514
|
+
localeHtml = localeHtml.replace(
|
|
515
|
+
/<script[^>]*id="__SITE_CONTENT__"[^>]*>[\s\S]*?<\/script>/,
|
|
516
|
+
`<script type="application/json" id="__SITE_CONTENT__">${localeContent}</script>`
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
await writeFile(join(localeDir, 'index.html'), localeHtml)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Resolve foundation directory based on site config and project structure
|
|
526
|
+
*
|
|
527
|
+
* Priority:
|
|
528
|
+
* 1. Read site's package.json to find foundation dependency path (most reliable)
|
|
529
|
+
* 2. Check foundations/{name} for multi-site projects
|
|
530
|
+
* 3. Check ../foundation for single-site projects
|
|
531
|
+
* 4. Check ../{name} as fallback
|
|
532
|
+
*/
|
|
533
|
+
function resolveFoundationDir(projectDir, siteConfig) {
|
|
534
|
+
const foundationName = siteConfig?.foundation
|
|
535
|
+
|
|
536
|
+
// First, try to resolve from site's package.json dependencies
|
|
537
|
+
// This is the most reliable method as it matches how Vite resolves imports
|
|
538
|
+
if (foundationName) {
|
|
539
|
+
const pkgPath = join(projectDir, 'package.json')
|
|
540
|
+
if (existsSync(pkgPath)) {
|
|
541
|
+
try {
|
|
542
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
543
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
544
|
+
const depValue = deps[foundationName]
|
|
545
|
+
|
|
546
|
+
// Check for file: protocol (local dependency)
|
|
547
|
+
if (depValue && depValue.startsWith('file:')) {
|
|
548
|
+
const relativePath = depValue.slice(5) // Remove 'file:' prefix
|
|
549
|
+
const resolvedPath = join(projectDir, relativePath)
|
|
550
|
+
if (existsSync(resolvedPath)) {
|
|
551
|
+
return resolvedPath
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
} catch {
|
|
555
|
+
// Ignore JSON parse errors, fall through to other methods
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Check if we're in a multi-site structure (site is under sites/)
|
|
561
|
+
const parentDir = join(projectDir, '..')
|
|
562
|
+
const grandParentDir = join(projectDir, '..', '..')
|
|
563
|
+
const isMultiSite =
|
|
564
|
+
parentDir.endsWith('/sites') || parentDir.endsWith('\\sites')
|
|
565
|
+
|
|
566
|
+
if (isMultiSite && foundationName) {
|
|
567
|
+
// Multi-site: look for foundations/{name}
|
|
568
|
+
const multiFoundationDir = join(
|
|
569
|
+
grandParentDir,
|
|
570
|
+
'foundations',
|
|
571
|
+
foundationName
|
|
572
|
+
)
|
|
573
|
+
if (existsSync(multiFoundationDir)) {
|
|
574
|
+
return multiFoundationDir
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Single site: ../foundation
|
|
579
|
+
const singleFoundationDir = join(projectDir, '..', 'foundation')
|
|
580
|
+
if (existsSync(singleFoundationDir)) {
|
|
581
|
+
return singleFoundationDir
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Fallback: try to find by foundation name in parent
|
|
585
|
+
if (foundationName) {
|
|
586
|
+
const namedFoundationDir = join(parentDir, foundationName)
|
|
587
|
+
if (existsSync(namedFoundationDir)) {
|
|
588
|
+
return namedFoundationDir
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Ultimate fallback
|
|
593
|
+
return singleFoundationDir
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Build a site in link mode — data only, no vite.
|
|
598
|
+
*
|
|
599
|
+
* Emits exactly what `uniweb publish` ships to Uniweb hosting:
|
|
600
|
+
* dist/site-content.json (full sections inlined)
|
|
601
|
+
* dist/<lang>/site-content.json per non-default locale
|
|
602
|
+
* dist/data/<query>.json (+ a file per record, dist/data/<query>/<name>.json)
|
|
603
|
+
* dist/assets/<media> (processed images, video posters, PDF thumbnails)
|
|
604
|
+
*
|
|
605
|
+
* Does NOT emit HTML, JS, CSS, source maps, _importmap chunks, or
|
|
606
|
+
* static-host extras (sitemap, robots, search-index, _pages/*) — none
|
|
607
|
+
* of these are consumed on the link-mode deploy path. The worker
|
|
608
|
+
* generates HTML at request time and re-derives split-content per-page
|
|
609
|
+
* files from the full payload it receives.
|
|
610
|
+
*
|
|
611
|
+
* The `buildLocalizedContent` step is the same call bundle mode makes
|
|
612
|
+
* post-vite, so multi-locale sites get identical per-locale outputs in
|
|
613
|
+
* either mode. Collection translation (`buildLocalizedRecords`)
|
|
614
|
+
* also runs here so deploy ships translated collection JSONs.
|
|
615
|
+
*
|
|
616
|
+
* Bug surfaced + fixed by routing deploy through this path: the bundle
|
|
617
|
+
* pipeline's prerender step rewrites `dist/site-content.json` into a
|
|
618
|
+
* lightweight manifest (sections stripped) when split-content is active,
|
|
619
|
+
* and deploy was reading that stripped version, causing the worker to
|
|
620
|
+
* mis-detect split and serve blank pages. Link mode skips prerender
|
|
621
|
+
* entirely; `dist/site-content.json` keeps full sections; the worker
|
|
622
|
+
* splits correctly.
|
|
623
|
+
*/
|
|
624
|
+
async function buildSiteLink(projectDir, options = {}) {
|
|
625
|
+
const { siteConfig = null } = options
|
|
626
|
+
|
|
627
|
+
info('Building site (link mode)...')
|
|
628
|
+
|
|
629
|
+
const { buildSiteData } = await import('@uniweb/build/site')
|
|
630
|
+
const distDir = join(projectDir, 'dist')
|
|
631
|
+
|
|
632
|
+
// Resolve the local foundation path so collectSiteContent can pick up
|
|
633
|
+
// theme variable defaults from `foundation.js::theme.vars`. When the
|
|
634
|
+
// foundation is purely a registry ref (no local sibling), this stays
|
|
635
|
+
// null and theme defaults come from theme.yml only.
|
|
636
|
+
const foundationDir = await resolveFoundationDirForSite(
|
|
637
|
+
projectDir,
|
|
638
|
+
siteConfig
|
|
639
|
+
).catch(() => null)
|
|
640
|
+
|
|
641
|
+
// Link mode does NOT (re)build the foundation. It reads only the
|
|
642
|
+
// foundation's SOURCE config (foundation.js::theme.vars, passed as
|
|
643
|
+
// foundationPath below) for theme defaults, and ships NO foundation code —
|
|
644
|
+
// the backend serves the foundation from the registry by the `foundation:`
|
|
645
|
+
// ref. (Bundle mode is the one that needs a current dist/entry.js — see
|
|
646
|
+
// buildSite — so the ensureFoundationFresh cascade lives there, not here.)
|
|
647
|
+
|
|
648
|
+
await buildSiteData({
|
|
649
|
+
siteRoot: projectDir,
|
|
650
|
+
distDir,
|
|
651
|
+
foundationPath: foundationDir,
|
|
652
|
+
assets: siteConfig?.build?.assets || {}
|
|
653
|
+
})
|
|
654
|
+
success(`Wrote ${join('dist', 'site-content.json')}`)
|
|
655
|
+
|
|
656
|
+
// Per-locale variants — same call bundle mode makes post-vite. Both
|
|
657
|
+
// modes produce identical `dist/<lang>/site-content.json` outputs so
|
|
658
|
+
// the deploy CLI walks the same path shape regardless of mode.
|
|
659
|
+
const i18nConfig = await loadI18nConfig(projectDir, siteConfig)
|
|
660
|
+
if (i18nConfig && i18nConfig.locales.length > 0) {
|
|
661
|
+
log('')
|
|
662
|
+
info(`Building localized content for: ${i18nConfig.locales.join(', ')}`)
|
|
663
|
+
try {
|
|
664
|
+
const outputs = await buildLocalizedContent(projectDir, i18nConfig)
|
|
665
|
+
success(`Generated ${Object.keys(outputs).length} locale(s)`)
|
|
666
|
+
for (const [locale] of Object.entries(outputs)) {
|
|
667
|
+
log(` ${colors.dim}dist/${locale}/site-content.json${colors.reset}`)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Collection translations — optional; don't fail the build if
|
|
671
|
+
// missing. Bundle mode does the same.
|
|
672
|
+
try {
|
|
673
|
+
const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
|
|
674
|
+
const recordOutputs = await buildLocalizedRecords(projectDir, {
|
|
675
|
+
locales: i18nConfig.locales,
|
|
676
|
+
outputDir: distDir,
|
|
677
|
+
recordLocalesDir: join(
|
|
678
|
+
projectDir,
|
|
679
|
+
i18nConfig.localesDir,
|
|
680
|
+
'records'
|
|
681
|
+
),
|
|
682
|
+
// Free-form record translations live under it — the default was `locales/`
|
|
683
|
+
// whatever the site said.
|
|
684
|
+
localesDir: join(projectDir, i18nConfig.localesDir)
|
|
685
|
+
})
|
|
686
|
+
const recordCount = Object.values(recordOutputs).reduce(
|
|
687
|
+
(sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
|
|
688
|
+
0
|
|
689
|
+
)
|
|
690
|
+
if (recordCount > 0) {
|
|
691
|
+
success(
|
|
692
|
+
`Translated records for ${Object.keys(recordOutputs).length} locale(s)`
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
} catch (err) {
|
|
696
|
+
if (process.env.UNIWEB_DEBUG)
|
|
697
|
+
console.error('Record translation:', err.message)
|
|
698
|
+
}
|
|
699
|
+
} catch (err) {
|
|
700
|
+
error(`i18n build failed: ${err.message}`)
|
|
701
|
+
if (process.env.UNIWEB_DEBUG) console.error(err.stack)
|
|
702
|
+
log(`${colors.yellow}Continuing without localized content${colors.reset}`)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
log('')
|
|
707
|
+
log(
|
|
708
|
+
`${colors.green}${colors.bright}Build complete (link mode)${colors.reset}`
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Best-effort resolution of the local foundation directory for a site,
|
|
714
|
+
* used by `buildSiteLink` to pass `foundationPath` to the data pipeline.
|
|
715
|
+
*
|
|
716
|
+
* Mirrors a subset of `@uniweb/build`'s `detectFoundationType` semantics:
|
|
717
|
+
* when the site declares `foundation: <name>` and a sibling/file: dep
|
|
718
|
+
* resolves to a local foundation, return its path. When the foundation
|
|
719
|
+
* is a registry ref or URL, return null (data pipeline still works
|
|
720
|
+
* without a local foundation; theme defaults just come from theme.yml).
|
|
721
|
+
*/
|
|
722
|
+
async function resolveFoundationDirForSite(siteDir, siteConfig) {
|
|
723
|
+
const cfg = siteConfig || readSiteConfig(siteDir)
|
|
724
|
+
const foundation = cfg?.foundation
|
|
725
|
+
if (!foundation || typeof foundation !== 'string') return null
|
|
726
|
+
// Registry ref or URL — no local foundation.
|
|
727
|
+
if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundation)) return null
|
|
728
|
+
if (foundation.startsWith('http://') || foundation.startsWith('https://'))
|
|
729
|
+
return null
|
|
730
|
+
|
|
731
|
+
// Workspace sibling.
|
|
732
|
+
const sibling = resolve(siteDir, '..', foundation)
|
|
733
|
+
if (existsSync(sibling)) return sibling
|
|
734
|
+
|
|
735
|
+
// file: dep declared in package.json.
|
|
736
|
+
try {
|
|
737
|
+
const pkg = JSON.parse(readFileSync(join(siteDir, 'package.json'), 'utf-8'))
|
|
738
|
+
const dep = pkg.dependencies?.[foundation]
|
|
739
|
+
if (typeof dep === 'string' && dep.startsWith('file:')) {
|
|
740
|
+
const filePath = resolve(siteDir, dep.slice(5))
|
|
741
|
+
if (existsSync(filePath)) return filePath
|
|
742
|
+
}
|
|
743
|
+
} catch {
|
|
744
|
+
/* no package.json or malformed — fall through */
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return null
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Build a site
|
|
752
|
+
*/
|
|
753
|
+
async function buildSite(projectDir, options = {}) {
|
|
754
|
+
const {
|
|
755
|
+
prerender = false,
|
|
756
|
+
foundationDir,
|
|
757
|
+
siteConfig = null,
|
|
758
|
+
host = null
|
|
759
|
+
} = options
|
|
760
|
+
|
|
761
|
+
info('Building site...')
|
|
762
|
+
|
|
763
|
+
// Cascade: bundle mode imports the foundation through vite, and (when
|
|
764
|
+
// prerender is on) loads dist/entry.js for SSG. A local foundation must
|
|
765
|
+
// therefore be current. Idempotent under buildWorkspace() — when the
|
|
766
|
+
// workspace cascade has already built it, the freshness check no-ops.
|
|
767
|
+
if (foundationDir) await ensureFoundationFresh(foundationDir)
|
|
768
|
+
|
|
769
|
+
// Run vite build for sites
|
|
770
|
+
await runLocalVite(projectDir, ['build'])
|
|
771
|
+
|
|
772
|
+
success('Site build complete')
|
|
773
|
+
|
|
774
|
+
// Check for i18n configuration
|
|
775
|
+
const i18nConfig = await loadI18nConfig(projectDir, siteConfig)
|
|
776
|
+
|
|
777
|
+
if (i18nConfig && i18nConfig.locales.length > 0) {
|
|
778
|
+
log('')
|
|
779
|
+
info(`Building localized content for: ${i18nConfig.locales.join(', ')}`)
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
// Generate locale-specific site-content.json
|
|
783
|
+
const outputs = await buildLocalizedContent(projectDir, i18nConfig)
|
|
784
|
+
|
|
785
|
+
// Generate locale-specific index.html files
|
|
786
|
+
await generateLocalizedHtml(projectDir, i18nConfig)
|
|
787
|
+
|
|
788
|
+
success(`Generated ${Object.keys(outputs).length} locale(s)`)
|
|
789
|
+
|
|
790
|
+
for (const [locale, path] of Object.entries(outputs)) {
|
|
791
|
+
log(` ${colors.dim}dist/${locale}/site-content.json${colors.reset}`)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Translate collections if they exist
|
|
795
|
+
try {
|
|
796
|
+
const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
|
|
797
|
+
|
|
798
|
+
const recordOutputs = await buildLocalizedRecords(projectDir, {
|
|
799
|
+
locales: i18nConfig.locales,
|
|
800
|
+
outputDir: join(projectDir, 'dist'),
|
|
801
|
+
recordLocalesDir: join(
|
|
802
|
+
projectDir,
|
|
803
|
+
i18nConfig.localesDir,
|
|
804
|
+
'records'
|
|
805
|
+
),
|
|
806
|
+
// Free-form record translations live under it — the default was `locales/`
|
|
807
|
+
// whatever the site said.
|
|
808
|
+
localesDir: join(projectDir, i18nConfig.localesDir)
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
// Count collections translated
|
|
812
|
+
const recordCount = Object.values(recordOutputs).reduce(
|
|
813
|
+
(sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
|
|
814
|
+
0
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
if (recordCount > 0) {
|
|
818
|
+
success(
|
|
819
|
+
`Translated records for ${Object.keys(recordOutputs).length} locale(s)`
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
} catch (err) {
|
|
823
|
+
// Collection translation is optional, don't fail build
|
|
824
|
+
if (process.env.UNIWEB_DEBUG) {
|
|
825
|
+
console.error('Record translation:', err.message)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} catch (err) {
|
|
829
|
+
error(`i18n build failed: ${err.message}`)
|
|
830
|
+
if (process.env.UNIWEB_DEBUG) {
|
|
831
|
+
console.error(err.stack)
|
|
832
|
+
}
|
|
833
|
+
// Don't fail the build, just warn
|
|
834
|
+
log(`${colors.yellow}Continuing without localized content${colors.reset}`)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Pre-render if requested
|
|
839
|
+
if (prerender) {
|
|
840
|
+
log('')
|
|
841
|
+
info('Pre-rendering pages to static HTML (SSG)...')
|
|
842
|
+
|
|
843
|
+
try {
|
|
844
|
+
const { prerenderSite } = await import('@uniweb/build/prerender')
|
|
845
|
+
|
|
846
|
+
const result = await prerenderSite(projectDir, {
|
|
847
|
+
foundationDir:
|
|
848
|
+
foundationDir || resolveFoundationDir(projectDir, siteConfig),
|
|
849
|
+
host,
|
|
850
|
+
onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
success(
|
|
854
|
+
`Pre-rendered ${result.pages} page${result.pages !== 1 ? 's' : ''} to static HTML`
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
// Summary
|
|
858
|
+
log('')
|
|
859
|
+
log(`${colors.green}${colors.bright}SSG Build complete!${colors.reset}`)
|
|
860
|
+
log('')
|
|
861
|
+
log(`Output:`)
|
|
862
|
+
for (const file of result.files) {
|
|
863
|
+
const relativePath = file.replace(projectDir + '/', '')
|
|
864
|
+
log(` ${colors.dim}${relativePath}${colors.reset}`)
|
|
865
|
+
}
|
|
866
|
+
} catch (err) {
|
|
867
|
+
error(`Pre-rendering failed: ${err.message}`)
|
|
868
|
+
|
|
869
|
+
// Provide helpful guidance for common errors
|
|
870
|
+
if (err.message.includes('Foundation not found')) {
|
|
871
|
+
log('')
|
|
872
|
+
log(`${colors.yellow}This usually means:${colors.reset}`)
|
|
873
|
+
log(
|
|
874
|
+
` 1. The foundation hasn't been built yet (run foundation build first)`
|
|
875
|
+
)
|
|
876
|
+
log(` 2. The foundation name in site.yml doesn't match your setup`)
|
|
877
|
+
log('')
|
|
878
|
+
log(`${colors.dim}Check that:${colors.reset}`)
|
|
879
|
+
log(
|
|
880
|
+
` • site.yml 'foundation:' matches the package name in your foundation's package.json`
|
|
881
|
+
)
|
|
882
|
+
log(
|
|
883
|
+
` • site's package.json has a dependency pointing to the correct foundation path`
|
|
884
|
+
)
|
|
885
|
+
log(
|
|
886
|
+
` • The foundation's dist/entry.js exists (build the foundation first)`
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (process.env.UNIWEB_DEBUG) {
|
|
891
|
+
console.error(err.stack)
|
|
892
|
+
}
|
|
893
|
+
process.exit(1)
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
showNextSteps(false, true)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Check if a directory is a foundation
|
|
902
|
+
*/
|
|
903
|
+
function isFoundation(dir) {
|
|
904
|
+
return classifyPackage(dir) === 'foundation'
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Check if a foundation directory declares extension: true in its
|
|
909
|
+
* authored declarations file (main.js or legacy foundation.js).
|
|
910
|
+
*/
|
|
911
|
+
function isExtensionDir(dir) {
|
|
912
|
+
return isExtensionPackage(dir)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Check if a directory is a site
|
|
917
|
+
*/
|
|
918
|
+
function isSite(dir) {
|
|
919
|
+
return classifyPackage(dir) === 'site'
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Discover workspace packages based on workspace config (pnpm-workspace.yaml globs)
|
|
924
|
+
*/
|
|
925
|
+
async function discoverWorkspacePackages(workspaceDir) {
|
|
926
|
+
const foundations = []
|
|
927
|
+
const extensions = []
|
|
928
|
+
const sites = []
|
|
929
|
+
|
|
930
|
+
const { packages } = await readWorkspaceConfig(workspaceDir)
|
|
931
|
+
|
|
932
|
+
for (const pattern of packages) {
|
|
933
|
+
const dirs = await resolveGlob(workspaceDir, pattern)
|
|
934
|
+
for (const dir of dirs) {
|
|
935
|
+
const fullPath = join(workspaceDir, dir)
|
|
936
|
+
const name = dir.split('/').pop()
|
|
937
|
+
|
|
938
|
+
if (isFoundation(fullPath)) {
|
|
939
|
+
if (isExtensionDir(fullPath)) {
|
|
940
|
+
extensions.push({ name, path: fullPath })
|
|
941
|
+
} else {
|
|
942
|
+
foundations.push({ name, path: fullPath })
|
|
943
|
+
}
|
|
944
|
+
} else if (isSite(fullPath)) {
|
|
945
|
+
sites.push({ name, path: fullPath })
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return { foundations, extensions, sites }
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Build all packages in a workspace
|
|
955
|
+
*/
|
|
956
|
+
async function buildWorkspace(workspaceDir, options = {}) {
|
|
957
|
+
const { prerenderFlag, noPrerenderFlag, host = null } = options
|
|
958
|
+
|
|
959
|
+
log(`${colors.cyan}${colors.bright}Building workspace...${colors.reset}`)
|
|
960
|
+
log('')
|
|
961
|
+
|
|
962
|
+
const { foundations, extensions, sites } =
|
|
963
|
+
await discoverWorkspacePackages(workspaceDir)
|
|
964
|
+
|
|
965
|
+
if (
|
|
966
|
+
foundations.length === 0 &&
|
|
967
|
+
extensions.length === 0 &&
|
|
968
|
+
sites.length === 0
|
|
969
|
+
) {
|
|
970
|
+
error('No foundations, extensions, or sites found in workspace')
|
|
971
|
+
log('')
|
|
972
|
+
log('Packages are the folders pnpm-workspace.yaml lists. Add one with:')
|
|
973
|
+
log(' uniweb add foundation [folder] (src/ when none is named)')
|
|
974
|
+
log(' uniweb add site [folder] (site/ when none is named)')
|
|
975
|
+
log(' uniweb add extension <folder>')
|
|
976
|
+
process.exit(1)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Build foundations first (sites depend on them)
|
|
980
|
+
for (const foundation of foundations) {
|
|
981
|
+
log(`${colors.bright}[${foundation.name}]${colors.reset}`)
|
|
982
|
+
await buildFoundation(foundation.path)
|
|
983
|
+
log('')
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Build extensions (they are foundations, but logged distinctly)
|
|
987
|
+
for (const extension of extensions) {
|
|
988
|
+
const label = isExtensionDir(extension.path) ? 'extension' : 'foundation'
|
|
989
|
+
log(
|
|
990
|
+
`${colors.bright}[${extension.name}]${colors.reset} ${colors.dim}(${label})${colors.reset}`
|
|
991
|
+
)
|
|
992
|
+
await buildFoundation(extension.path)
|
|
993
|
+
log('')
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Build sites
|
|
997
|
+
for (const site of sites) {
|
|
998
|
+
log(`${colors.bright}[${site.name}]${colors.reset}`)
|
|
999
|
+
|
|
1000
|
+
const siteConfig = readSiteConfig(site.path)
|
|
1001
|
+
const configPrerender = siteConfig.build?.prerender === true
|
|
1002
|
+
|
|
1003
|
+
let prerender = configPrerender
|
|
1004
|
+
if (prerenderFlag) prerender = true
|
|
1005
|
+
if (noPrerenderFlag) prerender = false
|
|
1006
|
+
|
|
1007
|
+
// Resolve foundation directory for this site
|
|
1008
|
+
const foundationDir = resolveFoundationDir(site.path, siteConfig)
|
|
1009
|
+
|
|
1010
|
+
await buildSite(site.path, { prerender, foundationDir, siteConfig, host })
|
|
1011
|
+
log('')
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Summary
|
|
1015
|
+
const totalBuilt = foundations.length + extensions.length + sites.length
|
|
1016
|
+
const parts = []
|
|
1017
|
+
if (foundations.length > 0) parts.push(`${foundations.length} foundation(s)`)
|
|
1018
|
+
if (extensions.length > 0) parts.push(`${extensions.length} extension(s)`)
|
|
1019
|
+
if (sites.length > 0) parts.push(`${sites.length} site(s)`)
|
|
1020
|
+
|
|
1021
|
+
log(`${colors.green}${colors.bright}Workspace build complete!${colors.reset}`)
|
|
1022
|
+
log('')
|
|
1023
|
+
log(`Built ${parts.join(', ')}`)
|
|
1024
|
+
|
|
1025
|
+
showNextSteps(foundations.length > 0, sites.length > 0)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Show next-step hints after workspace build
|
|
1030
|
+
*/
|
|
1031
|
+
function showNextSteps(hasFoundations, hasSites) {
|
|
1032
|
+
if (hasFoundations) {
|
|
1033
|
+
log('')
|
|
1034
|
+
log(`${colors.bright}Share with clients:${colors.reset}`)
|
|
1035
|
+
log(
|
|
1036
|
+
` ${colors.bright}uniweb register${colors.reset} Release your foundation to the catalog (alias: uniweb release)`
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
if (hasSites) {
|
|
1040
|
+
log('')
|
|
1041
|
+
log(`${colors.bright}Ship a site:${colors.reset}`)
|
|
1042
|
+
log(
|
|
1043
|
+
` ${colors.bright}uniweb deploy${colors.reset} Pick a host and ship (asks where, then remembers)`
|
|
1044
|
+
)
|
|
1045
|
+
log(
|
|
1046
|
+
` ${colors.bright}uniweb add ci --host${colors.reset}=… Deploy on every push (free hosts; adds PR previews)`
|
|
1047
|
+
)
|
|
1048
|
+
log(
|
|
1049
|
+
` ${colors.bright}uniweb publish${colors.reset} Uniweb Cloud (brings the foundation along)`
|
|
1050
|
+
)
|
|
1051
|
+
log(
|
|
1052
|
+
` Or upload ${colors.cyan}dist/${colors.reset} (\`uniweb export\`) to any static host`
|
|
1053
|
+
)
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Main build command handler
|
|
1059
|
+
*/
|
|
1060
|
+
export async function build(args = []) {
|
|
1061
|
+
const projectDir = process.cwd()
|
|
1062
|
+
|
|
1063
|
+
// Parse arguments
|
|
1064
|
+
let targetType = null
|
|
1065
|
+
const targetIndex = args.indexOf('--target')
|
|
1066
|
+
if (targetIndex !== -1 && args[targetIndex + 1]) {
|
|
1067
|
+
targetType = args[targetIndex + 1]
|
|
1068
|
+
if (!['foundation', 'site'].includes(targetType)) {
|
|
1069
|
+
error(`Invalid target: ${targetType}`)
|
|
1070
|
+
log('Valid targets: foundation, site')
|
|
1071
|
+
process.exit(1)
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Check for --prerender / --no-prerender flags
|
|
1076
|
+
const prerenderFlag = args.includes('--prerender')
|
|
1077
|
+
const noPrerenderFlag = args.includes('--no-prerender')
|
|
1078
|
+
|
|
1079
|
+
// Internal flags — called by `uniweb publish` (always --link) and
|
|
1080
|
+
// `uniweb deploy --host` / `uniweb export` (always --bundle). After Phase 2
|
|
1081
|
+
// of the CLI ergonomics overhaul, users don't see these flags directly; they
|
|
1082
|
+
// pick the verb (publish vs deploy vs export) and the corresponding
|
|
1083
|
+
// pipeline runs. Bare `uniweb build` for a site defaults to --bundle
|
|
1084
|
+
// (mostly used during development to inspect the vite output).
|
|
1085
|
+
const linkFlag = args.includes('--link')
|
|
1086
|
+
const bundleFlag = args.includes('--bundle')
|
|
1087
|
+
if (linkFlag && bundleFlag) {
|
|
1088
|
+
error(
|
|
1089
|
+
'Cannot pass both --link and --bundle (they select different build pipelines)'
|
|
1090
|
+
)
|
|
1091
|
+
process.exit(1)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Check for --foundation-dir flag (for prerendering)
|
|
1095
|
+
let foundationDir = null
|
|
1096
|
+
const foundationDirIndex = args.indexOf('--foundation-dir')
|
|
1097
|
+
if (foundationDirIndex !== -1 && args[foundationDirIndex + 1]) {
|
|
1098
|
+
foundationDir = resolve(args[foundationDirIndex + 1])
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// --host names the host adapter for this build's prerender step.
|
|
1102
|
+
// Default = 'cloudflare-pages' (resolved inside prerender.js, via the
|
|
1103
|
+
// registry). Build does not read deploy.yml; that is the deploy
|
|
1104
|
+
// orchestrator's job.
|
|
1105
|
+
//
|
|
1106
|
+
// `--host` with no value → interactive picker (errors in CI / non-TTY).
|
|
1107
|
+
const { readFlagValue } = await import('../utils/args.js')
|
|
1108
|
+
const hostFlag = readFlagValue(args, '--host')
|
|
1109
|
+
let host = null
|
|
1110
|
+
if (hostFlag === null) {
|
|
1111
|
+
const { promptForHost } = await import('../utils/host-prompt.js')
|
|
1112
|
+
try {
|
|
1113
|
+
host = await promptForHost({ args })
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
error(err.message)
|
|
1116
|
+
process.exit(1)
|
|
1117
|
+
}
|
|
1118
|
+
} else if (typeof hostFlag === 'string') {
|
|
1119
|
+
host = hostFlag
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Auto-detect project type if not specified
|
|
1123
|
+
if (!targetType) {
|
|
1124
|
+
targetType = detectProjectType(projectDir)
|
|
1125
|
+
|
|
1126
|
+
if (!targetType) {
|
|
1127
|
+
error('Could not detect project type')
|
|
1128
|
+
log(
|
|
1129
|
+
'Use --target foundation or --target site, or run from workspace root'
|
|
1130
|
+
)
|
|
1131
|
+
process.exit(1)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (targetType !== 'workspace') {
|
|
1135
|
+
info(`Detected project type: ${targetType}`)
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Validate prerender flags are only used with site/workspace target
|
|
1140
|
+
if ((prerenderFlag || noPrerenderFlag) && targetType === 'foundation') {
|
|
1141
|
+
error(
|
|
1142
|
+
'--prerender/--no-prerender can only be used with site or workspace builds'
|
|
1143
|
+
)
|
|
1144
|
+
process.exit(1)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Validate --link / --bundle are only used with site target.
|
|
1148
|
+
// (Foundation builds have no equivalent split — they always produce
|
|
1149
|
+
// dist/entry.js + schema.json regardless of how a downstream
|
|
1150
|
+
// site consumes the result.)
|
|
1151
|
+
if ((linkFlag || bundleFlag) && targetType === 'foundation') {
|
|
1152
|
+
error('--link and --bundle apply to site builds only')
|
|
1153
|
+
process.exit(1)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Run appropriate build
|
|
1157
|
+
try {
|
|
1158
|
+
if (targetType === 'workspace') {
|
|
1159
|
+
await buildWorkspace(projectDir, { prerenderFlag, noPrerenderFlag, host })
|
|
1160
|
+
} else if (targetType === 'foundation') {
|
|
1161
|
+
await buildFoundation(projectDir)
|
|
1162
|
+
} else {
|
|
1163
|
+
// For sites, read config to determine prerender default
|
|
1164
|
+
const siteConfig = readSiteConfig(projectDir)
|
|
1165
|
+
|
|
1166
|
+
// Link mode: data-only pipeline, no vite. The deployed Uniweb-edge
|
|
1167
|
+
// site never consumes the JS bundle, so we skip producing it.
|
|
1168
|
+
// Worker generates HTML at request time using its own runtime +
|
|
1169
|
+
// the foundation served from the registry. See
|
|
1170
|
+
// `framework/build/src/site/build-site-data.js` for what gets
|
|
1171
|
+
// emitted (and what doesn't).
|
|
1172
|
+
if (linkFlag) {
|
|
1173
|
+
if (prerenderFlag) {
|
|
1174
|
+
error(
|
|
1175
|
+
'--prerender does not apply to link mode (no static HTML is produced)'
|
|
1176
|
+
)
|
|
1177
|
+
process.exit(1)
|
|
1178
|
+
}
|
|
1179
|
+
await buildSiteLink(projectDir, { siteConfig })
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Bundle mode (default for sites, or explicit --bundle).
|
|
1184
|
+
const configPrerender = siteConfig.build?.prerender === true
|
|
1185
|
+
|
|
1186
|
+
// CLI flags override config: --prerender forces on, --no-prerender forces off
|
|
1187
|
+
let prerender = configPrerender
|
|
1188
|
+
if (prerenderFlag) prerender = true
|
|
1189
|
+
if (noPrerenderFlag) prerender = false
|
|
1190
|
+
|
|
1191
|
+
// If `--foundation-dir` wasn't passed, resolve the local foundation
|
|
1192
|
+
// from site.yml + package.json. Required so buildSite() can cascade
|
|
1193
|
+
// to the local foundation when the user runs `uniweb build` from a
|
|
1194
|
+
// site dir on a fresh checkout where dist/ doesn't exist yet.
|
|
1195
|
+
const resolvedFoundationDir =
|
|
1196
|
+
foundationDir ||
|
|
1197
|
+
(await resolveFoundationDirForSite(projectDir, siteConfig).catch(
|
|
1198
|
+
() => null
|
|
1199
|
+
))
|
|
1200
|
+
|
|
1201
|
+
await buildSite(projectDir, {
|
|
1202
|
+
prerender,
|
|
1203
|
+
foundationDir: resolvedFoundationDir,
|
|
1204
|
+
siteConfig,
|
|
1205
|
+
host
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
error(err.message)
|
|
1210
|
+
process.exit(1)
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
export default build
|