uniweb 0.10.9 → 0.10.11

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",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.7.7",
45
- "@uniweb/kit": "0.9.7",
46
- "@uniweb/runtime": "0.8.8"
44
+ "@uniweb/kit": "0.9.8",
45
+ "@uniweb/core": "0.7.8",
46
+ "@uniweb/runtime": "0.8.9"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/semantic-parser": "1.1.14",
50
- "@uniweb/content-reader": "1.1.7",
51
- "@uniweb/build": "0.11.6"
49
+ "@uniweb/build": "0.11.7",
50
+ "@uniweb/content-reader": "1.1.8",
51
+ "@uniweb/semantic-parser": "1.1.15"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -1316,6 +1316,8 @@ import LessonHeader from '../../components/LessonHeader'
1316
1316
 
1317
1317
  Within the same directory (e.g., one component importing a sibling), use normal relative imports (`./AIFeedbackCard`).
1318
1318
 
1319
+ **Foundation entry shape (`src/foundation.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — e.g. `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers`. Optionally a named `vars` export for theme-variable metadata (see *Foundation variables*). Everything else (section types, layouts) is auto-discovered from `src/sections/` and `src/layouts/` and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in the produced `dist/foundation.js`; you don't write that wrapper yourself, and most foundation code never sees it. The one place it matters: when you import your **own** `src/foundation.js` from a foundation component (e.g., a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1320
+
1319
1321
  ### Website and Page APIs
1320
1322
 
1321
1323
  ```jsx
@@ -29,6 +29,8 @@
29
29
  * uniweb deploy --skip-build Don't rebuild even if dist/ is stale
30
30
  * uniweb deploy --dry-run Resolve everything but skip the Worker POST
31
31
  * uniweb deploy --skip-billing Admin-only: bypass billing gate (dev/testing)
32
+ * uniweb deploy --review Force the browser review path even when
33
+ * there's no drift (e.g., to change features)
32
34
  *
33
35
  * See kb/platform/plans/cli-site-deploy-decisions.md for the full design.
34
36
  */
@@ -103,6 +105,12 @@ export async function deploy(args = []) {
103
105
  const dryRun = args.includes('--dry-run')
104
106
  const skipAssets = args.includes('--skip-assets')
105
107
  const skipBilling = args.includes('--skip-billing')
108
+ // --review forces the browser review path even when there's no drift.
109
+ // Useful for "I want to look at / change my features" without first
110
+ // having to edit site.yml. The toggles in the review page persist live
111
+ // to DB, so any change the user makes there ends up in site.yml after
112
+ // the loopback finalize roundtrip.
113
+ const forceReview = args.includes('--review')
106
114
 
107
115
  const siteDir = await resolveSiteDir(args)
108
116
  const backendUrl = getBackendUrl()
@@ -130,6 +138,12 @@ export async function deploy(args = []) {
130
138
  say.dim(`Runtime: ${runtimeVersion} (latest; pin via \`runtime:\` in site.yml)`)
131
139
  }
132
140
 
141
+ // Optional `features:` declaration. Acts as a "request" — CLI sends to
142
+ // PHP, PHP routes through review when it differs from the site's current
143
+ // metadata. Unknown names get warned + dropped before sending so a typo
144
+ // doesn't fail the whole deploy.
145
+ const desiredFeatures = readFeaturesFromYaml(siteYml)
146
+
133
147
  const cliToken = await ensureAuth({ command: 'Deploying' })
134
148
 
135
149
  // Always rebuild unless the user explicitly opts out with --skip-build.
@@ -167,6 +181,22 @@ export async function deploy(args = []) {
167
181
  const defaultLanguage = siteContent?.config?.defaultLanguage || languages[0] || 'en'
168
182
  const theme = await readTheme(siteDir, siteContent)
169
183
 
184
+ // Multi-locale: @uniweb/build emits dist/<lang>/site-content.json per
185
+ // non-default locale via buildLocalizedContent (translations applied via
186
+ // locales/<lang>.json + freeform/). Load each one so we can ship a full
187
+ // locales: map in the publish payload — same shape as Editor publish.
188
+ // Single-locale sites just have the default and skip the loop.
189
+ const localeContents = { [defaultLanguage]: siteContent }
190
+ for (const lang of languages) {
191
+ if (lang === defaultLanguage) continue
192
+ const localeContentPath = join(distDir, lang, 'site-content.json')
193
+ if (existsSync(localeContentPath)) {
194
+ localeContents[lang] = JSON.parse(await readFile(localeContentPath, 'utf8'))
195
+ } else {
196
+ say.warn(`Locale "${lang}" listed in site config but no dist/${lang}/site-content.json found — skipping.`)
197
+ }
198
+ }
199
+
170
200
  if (dryRun) {
171
201
  say.info('Dry run — showing what would be deployed:')
172
202
  say.dim(`Site dir : ${siteDir}`)
@@ -185,7 +215,7 @@ export async function deploy(args = []) {
185
215
  // needsReview=true on first deploy / billing drift in future phases).
186
216
  const loopback = await startLoopback()
187
217
 
188
- let publishToken, siteIdResolved, handleResolved, publishUrl, validateUrl
218
+ let publishToken, siteIdResolved, handleResolved, publishUrl, validateUrl, mintedFeatures
189
219
  try {
190
220
  say.info('Requesting deploy authorization…')
191
221
  const authorizeBody = {
@@ -200,6 +230,14 @@ export async function deploy(args = []) {
200
230
  callbackUrl: loopback.callbackUrl,
201
231
  // Dev-only: admin-gated server-side. PHP rejects for non-admins.
202
232
  skipBilling: skipBilling || undefined,
233
+ // site.yml-declared target feature set. PHP routes through review
234
+ // (with the desired set pre-applied) when it differs from DB.
235
+ // Always sent as an array; missing/empty `features:` in site.yml
236
+ // is normalized to `[]`, meaning "no paid features".
237
+ desiredFeatures,
238
+ // User-forced review (`uniweb deploy --review`). PHP refuses to
239
+ // fast-path even when nothing else has drifted.
240
+ forceReview: forceReview || undefined,
203
241
  }
204
242
  let authRes
205
243
  try {
@@ -255,6 +293,10 @@ export async function deploy(args = []) {
255
293
  publishToken = cb.publishToken
256
294
  siteIdResolved = cb.siteId
257
295
  handleResolved = cb.handle
296
+ // PHP echoes the live feature set in the loopback callback so the
297
+ // CLI can write `features:` back into site.yml accurately. Older
298
+ // PHP that doesn't include this field is a no-op.
299
+ mintedFeatures = Array.isArray(cb.features) ? cb.features : null
258
300
  // Review path: Worker URLs are implicit (we derive them from config).
259
301
  publishUrl = `${workerUrl}/api/publish/process`
260
302
  validateUrl = `${workerUrl}/api/publish/validate`
@@ -264,6 +306,7 @@ export async function deploy(args = []) {
264
306
  handleResolved = authRes.handle
265
307
  publishUrl = authRes.publishUrl
266
308
  validateUrl = authRes.validateUrl
309
+ mintedFeatures = Array.isArray(authRes.features) ? authRes.features : null
267
310
  }
268
311
  } finally {
269
312
  loopback.close()
@@ -286,14 +329,16 @@ export async function deploy(args = []) {
286
329
  process.exit(1)
287
330
  }
288
331
 
289
- // Asset pipeline — upload dist/assets/* to S3, rewrite siteContent to use
290
- // identifier-based references so semantic-parser resolves CDN URLs (+
291
- // optimized variants) at render time. Skipped with --skip-assets.
292
- // Mutates siteContent in place: image/document nodes get info.identifier.
332
+ // Asset pipeline — upload dist/assets/* + favicon + fonts to S3, then
333
+ // rewrite each locale's siteContent so semantic-parser resolves CDN URLs
334
+ // at render time. Assets themselves are locale-shared (they live in
335
+ // dist/assets/ regardless of language), so the diff/upload runs once
336
+ // and the rewrite walks every locale's content tree in localeContents.
337
+ // Skipped with --skip-assets.
293
338
  if (!skipAssets) {
294
339
  await uploadAssetsAndRewriteContent({
295
340
  siteDir,
296
- siteContent,
341
+ localeContents,
297
342
  siteYml,
298
343
  theme,
299
344
  backendUrl,
@@ -311,21 +356,41 @@ export async function deploy(args = []) {
311
356
  theme,
312
357
  languages,
313
358
  defaultLanguage,
314
- // Phase 1 single-locale wraps the content under the active locale. Multi-
315
- // locale CLI deploy needs per-locale collection (deferred see plan §6).
316
- locales: { [defaultLanguage]: siteContent },
359
+ // Same shape as Editor publish one entry per language. Single-locale
360
+ // sites end up with `{ [defaultLanguage]: siteContent }`; multi-locale
361
+ // sites carry per-locale translated content emitted by buildLocalizedContent.
362
+ locales: localeContents,
317
363
  }
318
364
  await callPublish({ url: publishUrl, token: publishToken, body: publishPayload })
319
365
 
320
- // Write site.id / site.handle back to site.yml so next `uniweb deploy`
321
- // fast-paths. Only touches the file on first deploy (or when the handle
322
- // drifted server-side).
323
- if (siteIdResolved && !siteYml.site?.id) {
324
- await writeSiteBinding(siteYmlPath, siteYml, { id: siteIdResolved, handle: handleResolved })
325
- say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
326
- } else if (siteIdResolved && handleResolved && siteYml.site?.handle !== handleResolved) {
327
- await writeSiteBinding(siteYmlPath, siteYml, { id: siteIdResolved, handle: handleResolved })
328
- say.dim(`Updated site.yml handle → ${handleResolved}`)
366
+ // Write site.id / site.handle / features back to site.yml so the file
367
+ // stays in sync with the live billing state. site.id and site.handle
368
+ // are written on first deploy and any time the server-side handle drifts.
369
+ // `features:` is rewritten whenever the live (server-confirmed) set
370
+ // differs from what's declared including the case where the user
371
+ // declared `[]` and the live set is `[]` (no diff, no write).
372
+ const siteIdChanged = !!siteIdResolved && !siteYml.site?.id
373
+ const handleChanged = !!siteIdResolved && !!handleResolved && siteYml.site?.handle !== handleResolved
374
+ // desiredFeatures is what we sent to PHP (the simplified model: missing
375
+ // == empty), so comparing mintedFeatures against it tells us whether
376
+ // the file needs updating. Skip the write when nothing changed.
377
+ const featuresChanged = mintedFeatures !== null
378
+ && !arrayEqualsAsSets(desiredFeatures, mintedFeatures)
379
+
380
+ if (siteIdChanged || handleChanged || featuresChanged) {
381
+ const updates = {}
382
+ if (siteIdChanged || handleChanged) {
383
+ updates.site = { id: siteIdResolved, handle: handleResolved }
384
+ }
385
+ if (featuresChanged) {
386
+ updates.features = mintedFeatures
387
+ }
388
+ await writeSiteYmlUpdates(siteYmlPath, siteYml, updates)
389
+ if (siteIdChanged) say.dim(`Linked site.yml to site.id=${siteIdResolved}`)
390
+ else if (handleChanged) say.dim(`Updated site.yml handle → ${handleResolved}`)
391
+ if (featuresChanged) {
392
+ say.dim(`Updated site.yml features → [${mintedFeatures.join(', ') || '(none)'}]`)
393
+ }
329
394
  }
330
395
 
331
396
  console.log('')
@@ -348,17 +413,62 @@ async function readSiteYml(path) {
348
413
  }
349
414
  }
350
415
 
416
+ // Recognized paid features. `features:` in site.yml uses these short
417
+ // names; the PHP backend maps them to internal metadata flags. Anything
418
+ // else gets dropped with a warning so a typo doesn't block a deploy.
419
+ const KNOWN_FEATURES = new Set(['search', 'analytics', 'lowTtl', 'intelligence'])
420
+
421
+ function readFeaturesFromYaml(siteYml) {
422
+ // site.yml's `features:` is the developer's declarative intent for what
423
+ // paid features they want billed. We treat absence and `features: []` as
424
+ // the same thing — both mean "no paid features". This keeps the model
425
+ // simple: what's in the file is what the user wants. No "no opinion"
426
+ // escape hatch. Legacy sites that have paid features in DB but no
427
+ // features: line yet will see a downgrade-review on their next deploy
428
+ // (they cancel and add the explicit list, or proceed and downgrade).
429
+ const raw = siteYml?.features
430
+ if (raw === undefined) return []
431
+ if (!Array.isArray(raw)) {
432
+ say.warn('site.yml `features:` should be a list (e.g. `features: [search]`). Treating as empty.')
433
+ return []
434
+ }
435
+ const valid = []
436
+ const unknown = []
437
+ for (const v of raw) {
438
+ if (typeof v !== 'string') continue
439
+ if (KNOWN_FEATURES.has(v)) valid.push(v)
440
+ else unknown.push(v)
441
+ }
442
+ if (unknown.length > 0) {
443
+ say.warn(`site.yml features: unknown name(s) ignored: ${unknown.join(', ')}`)
444
+ say.dim(`Known features: ${[...KNOWN_FEATURES].join(', ')}`)
445
+ }
446
+ // Dedupe + stable order so authorize compares the same way every time.
447
+ return [...new Set(valid)].sort()
448
+ }
449
+
450
+ function arrayEqualsAsSets(a, b) {
451
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
452
+ if (a.length !== b.length) return false
453
+ const sa = new Set(a)
454
+ for (const x of b) if (!sa.has(x)) return false
455
+ return true
456
+ }
457
+
351
458
  /**
352
- * Write site.id + site.handle back to site.yml, preserving other fields.
459
+ * Write a partial set of updates back to site.yml, preserving other fields.
353
460
  *
354
461
  * Note: this is not a full YAML-preserving write — comments and exact
355
462
  * formatting are NOT preserved. js-yaml's `dump` re-emits the document.
356
463
  * Acceptable for now; the Phase 1 plan doesn't promise comment preservation.
357
464
  */
358
- async function writeSiteBinding(path, current, binding) {
359
- const next = {
360
- ...current,
361
- site: { ...(current.site || {}), id: binding.id, handle: binding.handle },
465
+ async function writeSiteYmlUpdates(path, current, updates) {
466
+ const next = { ...current }
467
+ if (updates.site) {
468
+ next.site = { ...(current.site || {}), ...updates.site }
469
+ }
470
+ if (updates.features !== undefined) {
471
+ next.features = [...updates.features].sort()
362
472
  }
363
473
  const dumped = yaml.dump(next, { lineWidth: 120, noRefs: true, quotingType: "'" })
364
474
  await writeFile(path, dumped)
@@ -416,8 +526,8 @@ async function fetchLatestRuntime(workerUrl) {
416
526
  function extractLanguages(siteContent) {
417
527
  const langs = siteContent?.config?.languages
418
528
  if (!Array.isArray(langs) || langs.length === 0) return ['en']
419
- // Editor-shape `[{ value, label }]` vs plain `[string]`.
420
- return langs.map((l) => (typeof l === 'string' ? l : l?.value)).filter(Boolean)
529
+ // Three accepted shapes: plain `'en'`, Editor `{ value, label }`, site.yml `{ code, label }`.
530
+ return langs.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
421
531
  }
422
532
 
423
533
  /**
@@ -535,7 +645,7 @@ async function callPublish({ url, token, body }) {
535
645
  * siteContent is mutated in place so the caller's publish payload picks up
536
646
  * the rewritten nodes without passing anything back.
537
647
  */
538
- async function uploadAssetsAndRewriteContent({ siteDir, siteContent, siteYml, theme, backendUrl, cliToken, siteId }) {
648
+ async function uploadAssetsAndRewriteContent({ siteDir, localeContents, siteYml, theme, backendUrl, cliToken, siteId }) {
539
649
  const distAssetsDir = join(siteDir, 'dist', 'assets')
540
650
  const hasDistAssets = existsSync(distAssetsDir)
541
651
 
@@ -659,24 +769,32 @@ async function uploadAssetsAndRewriteContent({ siteDir, siteContent, siteYml, th
659
769
  for (const u of confirmed) fresh.set(u.filename, u.identifier)
660
770
  }
661
771
 
662
- // 6. Rewrite siteContent in place. Each image/document node whose
663
- // src/href references a local /assets/{filename} gets an info.identifier
664
- // pointing to the uploaded (or reused) asset.
772
+ // 6. Rewrite each locale's content in place. Image/document nodes whose
773
+ // src/href references a local /assets/{filename} get an info.identifier
774
+ // pointing to the uploaded (or reused) asset. Walking every locale
775
+ // means translated content (which still references the same image
776
+ // files via the source ProseMirror tree) gets the same rewrite.
665
777
  const byFilenameAll = new Map([...reused, ...fresh])
666
- const rewritten = rewriteAssetReferences(siteContent, byFilenameAll)
778
+ let rewritten = 0
779
+ for (const lang of Object.keys(localeContents)) {
780
+ rewritten += rewriteAssetReferences(localeContents[lang], byFilenameAll)
781
+ }
667
782
  if (rewritten > 0) {
668
- say.dim(`Rewrote ${rewritten} asset reference(s) in site content.`)
783
+ say.dim(`Rewrote ${rewritten} asset reference(s) across ${Object.keys(localeContents).length} locale(s).`)
669
784
  }
670
785
 
671
786
  // 7. If a favicon was included above, inject its resolved CDN URL into
672
- // siteContent.config.favicon. Matches how Editor publish composes the
673
- // payload; Worker bakes <link rel="icon"> from this field.
787
+ // every locale's config.favicon. Matches Editor publish (which sets
788
+ // favicon per-locale); Worker bakes <link rel="icon"> from the active
789
+ // locale's content.config.favicon.
674
790
  if (faviconPath) {
675
791
  const favName = faviconPath.split(sep).pop()
676
792
  const favIdentifier = byFilenameAll.get(favName)
677
793
  if (favIdentifier) {
678
794
  const faviconUrl = resolveAssetCdnUrl(favIdentifier)
679
- siteContent.config = { ...(siteContent.config || {}), favicon: faviconUrl }
795
+ for (const lang of Object.keys(localeContents)) {
796
+ localeContents[lang].config = { ...(localeContents[lang].config || {}), favicon: faviconUrl }
797
+ }
680
798
  say.dim(`Favicon: ${favName}`)
681
799
  }
682
800
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-04-27T05:49:24.697Z",
3
+ "generatedAt": "2026-04-27T18:15:52.437Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.11.6",
6
+ "version": "0.11.7",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -14,7 +14,7 @@
14
14
  ]
15
15
  },
16
16
  "@uniweb/content-reader": {
17
- "version": "1.1.7",
17
+ "version": "1.1.8",
18
18
  "path": "framework/content-reader",
19
19
  "deps": []
20
20
  },
@@ -24,7 +24,7 @@
24
24
  "deps": []
25
25
  },
26
26
  "@uniweb/core": {
27
- "version": "0.7.7",
27
+ "version": "0.7.8",
28
28
  "path": "framework/core",
29
29
  "deps": [
30
30
  "@uniweb/semantic-parser",
@@ -42,7 +42,7 @@
42
42
  "deps": []
43
43
  },
44
44
  "@uniweb/kit": {
45
- "version": "0.9.7",
45
+ "version": "0.9.8",
46
46
  "path": "framework/kit",
47
47
  "deps": [
48
48
  "@uniweb/core"
@@ -54,12 +54,12 @@
54
54
  "deps": []
55
55
  },
56
56
  "@uniweb/press": {
57
- "version": "0.4.4",
57
+ "version": "0.4.5",
58
58
  "path": "framework/press",
59
59
  "deps": []
60
60
  },
61
61
  "@uniweb/runtime": {
62
- "version": "0.8.8",
62
+ "version": "0.8.9",
63
63
  "path": "framework/runtime",
64
64
  "deps": [
65
65
  "@uniweb/core",
@@ -77,7 +77,7 @@
77
77
  "deps": []
78
78
  },
79
79
  "@uniweb/semantic-parser": {
80
- "version": "1.1.14",
80
+ "version": "1.1.15",
81
81
  "path": "framework/semantic-parser",
82
82
  "deps": []
83
83
  },
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.2.6",
95
+ "version": "0.2.7",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",