uniweb 0.46.1 → 0.47.1

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.46.1",
3
+ "version": "0.47.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,13 +41,13 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/kit": "^0.17.1",
45
- "@uniweb/runtime": "^0.18.1",
46
- "@uniweb/core": "^0.24.0",
47
- "@uniweb/semantic-parser": "^1.4.0"
44
+ "@uniweb/core": "^0.24.1",
45
+ "@uniweb/kit": "^0.18.0",
46
+ "@uniweb/semantic-parser": "^1.4.0",
47
+ "@uniweb/runtime": "^0.19.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.42.1",
50
+ "@uniweb/build": "^0.43.0",
51
51
  "@uniweb/content-reader": "^1.2.4",
52
52
  "@uniweb/semantic-parser": "^1.4.0"
53
53
  },
@@ -195,7 +195,7 @@ function sourceMatches(dir, pattern) {
195
195
  * question is always "did the developer write this".
196
196
  */
197
197
  function forEachSourceFile(dir, fn) {
198
- const roots = ['sections', 'layouts', 'components']
198
+ const roots = ['sections', 'layouts', 'components', 'utils']
199
199
  const walk = (d, depth = 0) => {
200
200
  if (depth > 6) return
201
201
  let entries
@@ -211,7 +211,7 @@ function forEachSourceFile(dir, fn) {
211
211
  walk(p, depth + 1)
212
212
  } else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
213
213
  try {
214
- fn(readFileSync(p, 'utf8'))
214
+ fn(readFileSync(p, 'utf8'), relative(dir, p))
215
215
  } catch {
216
216
  // unreadable file — not this check's problem to report
217
217
  }
@@ -221,34 +221,6 @@ function forEachSourceFile(dir, fn) {
221
221
  for (const r of roots) walk(join(dir, r))
222
222
  }
223
223
 
224
- /**
225
- * The host services a foundation's own source actually reaches for.
226
- *
227
- * ⚠️ A HINT, NEVER THE ANSWER. This is a regex over source, so it sees only the
228
- * literal spellings below: `resolveService(website, name)` with a variable name
229
- * is invisible to it, and so is a service reached through a helper. That makes
230
- * it usable for "you used this and did not declare it" (a false negative just
231
- * means no warning) and useless as a source of truth (a false negative would
232
- * mean a capability silently dropped from what we publish).
233
- *
234
- * ⛔ Which is why the declaration is authored and this only checks it. Deriving
235
- * `uniweb.supports` from a scan would make a missed match into a service the
236
- * operator cannot buy, with nothing anywhere naming the cause.
237
- */
238
- function servicesUsedInSource(dir) {
239
- const found = new Set()
240
- forEachSourceFile(dir, (text) => {
241
- for (const m of text.matchAll(/resolveService\s*\(\s*[^,()]+,\s*['"`]([\w-]+)['"`]/g)) {
242
- found.add(m[1])
243
- }
244
- // The service-specific readers, which name no service string of their own.
245
- if (/\bisSearchEnabled\s*\(/.test(text)) found.add('search')
246
- if (/\buseTracker\s*\(/.test(text)) found.add('tracking')
247
- if (/from\s+['"`]@uniweb\/api['"`]/.test(text)) found.add('api')
248
- })
249
- return found
250
- }
251
-
252
224
  // A fenced data block tagged `form`, in any of the serialization formats the
253
225
  // parser accepts for one. Matched on the info string only — the body is the
254
226
  // author's business.
@@ -426,68 +398,201 @@ const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true',
426
398
  * options — there is nothing in it to be wrong.
427
399
  */
428
400
  /**
429
- * `uniweb doctor` — a foundation's `uniweb.supports` declaration.
401
+ * `uniweb doctor` — is `package.json` behind what the build derived?
430
402
  *
431
403
  * A host offers services (`config.services.<name>`); a foundation has to render
432
404
  * something against one, or an operator who provisioned it gets nothing and no
433
- * error. `package.json::uniweb.supports` is how a foundation states which ones
434
- * it honours, and it rides to the registry as `info.supports`
435
- * (`build/src/uwx/registry-package.js`).
405
+ * error. `uniweb.supports` states which ones a foundation honours, and it rides
406
+ * to the registry as `info.supports` (`build/src/uwx/registry-package.js`).
407
+ *
408
+ * ## ⭐ THE BUILD ALREADY ANSWERED THIS — so read its answer, do not re-derive
436
409
  *
437
- * ## WHY THIS WARNS RATHER THAN THE BUILD FILLING IT IN
410
+ * `@uniweb/build` derives the set from the post-tree-shake module graph and
411
+ * writes it to `dist/meta/schema.json`'s `_self.supports`
412
+ * (`build/src/foundation/derive-supports.js`). That value is what publishes,
413
+ * with or without this check, so the foundation is already correct on the wire.
438
414
  *
439
- * `servicesUsedInSource` is a regex and cannot see a service reached through a
440
- * variable or a helper. Deriving the declaration from it would publish a list
441
- * SHORTER than the truth, and a capability missing from that list is one the
442
- * operator cannot buy with nothing anywhere naming the cause. A false negative
443
- * in a warning costs a missing warning; a false negative in a derivation costs a
444
- * silently unsellable feature. So the developer authors it and this checks it,
445
- * at the one moment the person who can fix it is looking.
415
+ * **What was here before was a regex over `sections/`, `layouts/` and
416
+ * `components/`, and it did not work.** It knew four spellings and
417
+ * `useFormSubmit()` the official submit gate was not one, so it was silent
418
+ * on `templates/services`, the template written to demonstrate the feature. Its
419
+ * test could not find that, because the fixture was a source string written to
420
+ * match the regex. **Do not reintroduce a second, weaker derivation here.**
421
+ * There is one, it runs in the build, and this reads it.
446
422
  *
447
- * ## ⚖️ Nothing here defaults a service ON
423
+ * ## So what is left for `doctor` to say
448
424
  *
449
- * Absent means UNKNOWN, not "none" and not "all". Assuming search because most
450
- * foundations have one would claim a service for the developer who never met
451
- * this key who is the same developer who forgot to declare it — turning a loud
452
- * failure (reported unknown) into a silent one (an operator paying for something
453
- * their site will never render).
425
+ * Only that the FILE disagrees with the artifact. Nothing is broken when it
426
+ * does but `uniweb.supports` is the one place a human can add a service the
427
+ * graph cannot see (a computed name), so a developer who never sees the derived
428
+ * set never learns the key exists, and never adds the one thing only they know.
454
429
  *
455
- * Silent when a foundation reaches for no service at all: a check that fires on
456
- * correct configuration is how everyone learns to ignore the checker.
430
+ * `--fix` writes the derived names in, where they land in a diff the developer
431
+ * reads. The build never does this itself: rewriting source on every build is
432
+ * a surprise, and the artifact needs no help.
433
+ *
434
+ * Silent when the foundation has not been built — nothing has been measured, so
435
+ * there is nothing to compare and a guess would be worse than quiet.
457
436
  */
458
- export function checkFoundationSupports({ foundationName, folderName, srcDir, pkg, issues }) {
459
- const used = servicesUsedInSource(srcDir)
460
- if (used.size === 0) return
437
+ export async function checkFoundationSupports({
438
+ foundationName,
439
+ folderName,
440
+ foundationDir,
441
+ pkg,
442
+ issues,
443
+ shouldFix,
444
+ fixed,
445
+ }) {
446
+ const schemaPath = join(foundationDir, 'dist', 'meta', 'schema.json')
447
+ if (!existsSync(schemaPath)) return
448
+
449
+ let derived
450
+ try {
451
+ derived = JSON.parse(readFileSync(schemaPath, 'utf8'))?._self?.supports
452
+ } catch {
453
+ return // unreadable or malformed build output — not this check's to report
454
+ }
455
+ // Absent means the build derived nothing and saw a computed name, so it
456
+ // reported UNKNOWN. There is no gap to name.
457
+ if (!Array.isArray(derived) || derived.length === 0) return
461
458
 
462
459
  const declared = pkg?.uniweb?.supports
463
460
  const hasDeclaration = Array.isArray(declared)
464
- const missing = [...used]
465
- .filter((name) => !hasDeclaration || !declared.includes(name))
466
- .sort()
461
+ const missing = derived.filter((name) => !hasDeclaration || !declared.includes(name)).sort()
467
462
  if (missing.length === 0) return
468
463
 
469
464
  const id = 'foundation-supports-incomplete'
470
465
  const list = missing.join(', ')
466
+ const merged = [...new Set([...(hasDeclaration ? declared : []), ...derived])].sort()
467
+
468
+ if (shouldFix?.(id)) {
469
+ const pkgPath = join(foundationDir, 'package.json')
470
+ try {
471
+ const src = readFileSync(pkgPath, 'utf8')
472
+ const parsed = JSON.parse(src)
473
+ parsed.uniweb = { ...(parsed.uniweb || {}), supports: merged }
474
+ writeJsonPreservingStyle(pkgPath, parsed, src)
475
+ fixed?.(`${folderName}/package.json now declares uniweb.supports: ${merged.join(', ')}`)
476
+ return
477
+ } catch (e) {
478
+ // Fall through to the warning — a failed write must not read as fixed.
479
+ warn(`[${id}] could not write ${folderName}/package.json: ${e.message}`)
480
+ }
481
+ }
482
+
471
483
  issues.push({
472
484
  id,
473
485
  type: 'warning',
474
486
  foundation: foundationName,
475
487
  message: hasDeclaration
476
- ? `${foundationName} uses ${list} but does not declare ${missing.length === 1 ? 'it' : 'them'} in uniweb.supports`
477
- : `${foundationName} uses ${list} but declares no uniweb.supports`,
478
- details: { used: [...used].sort(), declared: hasDeclaration ? declared : null }
488
+ ? `${foundationName} builds against ${list} but does not declare ${missing.length === 1 ? 'it' : 'them'} in uniweb.supports`
489
+ : `${foundationName} builds against ${list} but declares no uniweb.supports`,
490
+ details: { derived, declared: hasDeclaration ? declared : null },
491
+ fixable: true,
479
492
  })
480
493
 
481
- if (hasDeclaration) {
482
- warn(`[${id}] ${foundationName} uses ${list}, not listed in uniweb.supports`)
483
- } else {
484
- warn(`[${id}] ${foundationName} uses ${list} and declares no uniweb.supports`)
485
- log(` Without it a host cannot tell "this foundation supports nothing" from`)
486
- log(` "nobody said" — so what it renders may never be offered to the operator.`)
494
+ warn(`[${id}] ${foundationName} builds against ${list}, not in ${folderName}/package.json`)
495
+ log(` The build already publishes ${colors.dim}${derived.join(', ')}${colors.reset}, so nothing is missing on the wire.`)
496
+ log(` Declaring it matters when a service is named by a computed value — the`)
497
+ log(` build cannot see those, and yours is the only place they can be added.`)
498
+ log(` ${colors.dim}uniweb doctor --fix ${id}${colors.reset}`)
499
+ }
500
+
501
+ /**
502
+ * The gates kit exposes for a host service, and the field that IS the gate.
503
+ *
504
+ * Each hook returns a boolean saying whether the site actually has the service.
505
+ * A component that ignores it draws a control the visitor cannot use — a search
506
+ * box that searches nothing, a form whose answers have nowhere to go.
507
+ */
508
+ const SERVICE_GATES = [
509
+ { hook: 'useFormSubmit', gate: 'canSubmit', draws: 'a form' },
510
+ { hook: 'useSearch', gate: 'isEnabled', draws: 'a search control' },
511
+ { hook: 'useSearchIndex', gate: 'isEnabled', draws: 'a search control' },
512
+ // Returns everything `useSearch` does, `isEnabled` included, so it gates the
513
+ // same way. It was missing from this list until the hook was renamed out of
514
+ // `useSearchWithIntent` — the kind of gap a list of names grows quietly.
515
+ { hook: 'useSearchPrefetch', gate: 'isEnabled', draws: 'a search control' },
516
+ ]
517
+
518
+ /**
519
+ * `uniweb doctor` — a control drawn for a service the site may not have.
520
+ *
521
+ * `resolveService` answers with an address or nothing, and nothing is a normal
522
+ * state: the operator did not provision that service. ⭐ **The rule is that a
523
+ * control for a service the site does not have must not be drawn** — no error,
524
+ * no apology, no explanatory string (there is deliberately none to show, since
525
+ * any wording would be ours to invent, in one language, for a visitor with no
526
+ * stake in it). The hooks hand you a boolean for exactly this, and a component
527
+ * that never reads it renders something permanently dead.
528
+ *
529
+ * ## ⚖️ WHY A REGEX IS ACCEPTABLE HERE, HAVING JUST BEEN REJECTED NEXT DOOR
530
+ *
531
+ * `checkFoundationSupports` above no longer scans source, because deriving
532
+ * *which services a foundation reaches* requires following calls through
533
+ * `@uniweb/kit` into `@uniweb/core`, and a missed match there silently costs a
534
+ * capability on the wire. This question is a different shape:
535
+ *
536
+ * - it is **local to one file** — the hook call and its gate are in the same
537
+ * component, in the developer's own source, with no indirection to follow;
538
+ * - the gate names are **fixed by kit's API**, not inferred;
539
+ * - it only ever **warns**, and changes no artifact. A miss costs a missing
540
+ * warning, which is the safe direction.
541
+ *
542
+ * ⛔ Its limits, stated rather than discovered: a component that gates in its
543
+ * parent reads as ungated here, and one that destructures the gate without
544
+ * using it reads as gated. Both are warnings about a real smell either way, and
545
+ * neither can produce a wrong artifact.
546
+ */
547
+ /**
548
+ * Source with its comments removed.
549
+ *
550
+ * ⚠️ MEASURED, NOT PRECAUTIONARY. The first cut of this check searched the raw
551
+ * text, and passed on `templates/services` with the gate deliberately deleted
552
+ * from the code — because the component's JSDoc still said `canSubmit`. Prose
553
+ * about a gate is not a gate.
554
+ *
555
+ * ⛔ Whole-line and block comments only, deliberately. A line that begins with
556
+ * `//` or `*` is unambiguously a comment, so stripping it can never remove real
557
+ * code; hunting trailing comments would mean deciding whether a `//` sits
558
+ * inside a string, and getting that wrong deletes code and invents a warning.
559
+ * The residue is that a gate named ONLY in a trailing comment still reads as
560
+ * used, which is a missed warning — the safe direction.
561
+ */
562
+ function stripComments(src) {
563
+ return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
564
+ }
565
+
566
+ export function checkUngatedServiceControls({ foundationName, folderName, srcDir, issues }) {
567
+ const found = []
568
+
569
+ forEachSourceFile(srcDir, (raw, file) => {
570
+ const text = stripComments(raw)
571
+ for (const { hook, gate, draws } of SERVICE_GATES) {
572
+ // A call, not merely an import: re-exporting a hook is not drawing with it.
573
+ if (!new RegExp(`\\b${hook}\\s*\\(`).test(text)) continue
574
+ if (new RegExp(`\\b${gate}\\b`).test(text)) continue
575
+ found.push({ file, hook, gate, draws })
576
+ }
577
+ })
578
+
579
+ if (found.length === 0) return
580
+
581
+ const id = 'ungated-service-control'
582
+ for (const f of found) {
583
+ issues.push({
584
+ id,
585
+ type: 'warning',
586
+ foundation: foundationName,
587
+ message: `${foundationName}/${f.file} calls ${f.hook}() without reading ${f.gate}`,
588
+ details: f,
589
+ })
590
+ warn(`[${id}] ${folderName}/${f.file} calls ${f.hook}() and never reads ${f.gate}`)
591
+ log(` It draws ${f.draws} even on a site with no such service, where the`)
592
+ log(` control cannot work. Gate on it instead:`)
593
+ log(` ${colors.dim}const { ${f.gate} } = ${f.hook}(…)${colors.reset}`)
594
+ log(` ${colors.dim}if (!${f.gate}) return null${colors.reset}`)
487
595
  }
488
- const merged = [...new Set([...(hasDeclaration ? declared : []), ...missing])].sort()
489
- log(` Add to ${folderName}/package.json:`)
490
- log(` ${colors.dim}"uniweb": { "supports": ${JSON.stringify(merged)} }${colors.reset}`)
491
596
  }
492
597
 
493
598
  export function checkTrackingBlock({ siteName, siteYml, issues }) {
@@ -941,11 +1046,41 @@ export async function doctor(args = []) {
941
1046
 
942
1047
  // ── uniweb.supports — the services this foundation says it is built against ──
943
1048
  for (const f of foundations) {
944
- checkFoundationSupports({
1049
+ await checkFoundationSupports({
945
1050
  foundationName: f.name,
946
1051
  folderName: f.folderName,
947
- srcDir: resolveFoundationSrcPath(f.path),
1052
+ foundationDir: f.path,
948
1053
  pkg: loadPackageJson(f.path),
1054
+ issues,
1055
+ shouldFix,
1056
+ fixed
1057
+ })
1058
+ checkUngatedServiceControls({
1059
+ foundationName: f.name,
1060
+ folderName: f.folderName,
1061
+ srcDir: resolveFoundationSrcPath(f.path),
1062
+ issues
1063
+ })
1064
+ }
1065
+
1066
+ // Extensions carry `uniweb.supports` too — same key, same field, and a site's
1067
+ // answer is the union across its primary foundation and its extensions. The
1068
+ // regex-era check looked at foundations only, so an extension that drew a
1069
+ // search box was never asked about one.
1070
+ for (const e of extensions) {
1071
+ await checkFoundationSupports({
1072
+ foundationName: e.name,
1073
+ folderName: e.folderName,
1074
+ foundationDir: e.path,
1075
+ pkg: loadPackageJson(e.path),
1076
+ issues,
1077
+ shouldFix,
1078
+ fixed
1079
+ })
1080
+ checkUngatedServiceControls({
1081
+ foundationName: e.name,
1082
+ folderName: e.folderName,
1083
+ srcDir: resolveFoundationSrcPath(e.path),
949
1084
  issues
950
1085
  })
951
1086
  }
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-06T21:37:52.800Z",
3
+ "generatedAt": "2026-09-08T01:58:45.617Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.2.10",
6
+ "version": "0.2.11",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.42.1",
13
+ "version": "0.43.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -34,7 +34,7 @@
34
34
  "deps": []
35
35
  },
36
36
  "@uniweb/core": {
37
- "version": "0.24.0",
37
+ "version": "0.24.1",
38
38
  "path": "framework/core",
39
39
  "deps": [
40
40
  "@uniweb/semantic-parser",
@@ -54,7 +54,7 @@
54
54
  ]
55
55
  },
56
56
  "@uniweb/kit": {
57
- "version": "0.17.1",
57
+ "version": "0.18.0",
58
58
  "path": "framework/kit",
59
59
  "deps": [
60
60
  "@uniweb/core",
@@ -82,7 +82,7 @@
82
82
  ]
83
83
  },
84
84
  "@uniweb/runtime": {
85
- "version": "0.18.1",
85
+ "version": "0.19.1",
86
86
  "path": "framework/runtime",
87
87
  "deps": [
88
88
  "@uniweb/core",
@@ -110,7 +110,7 @@
110
110
  "deps": []
111
111
  },
112
112
  "@uniweb/templates": {
113
- "version": "0.12.1",
113
+ "version": "0.12.2",
114
114
  "path": "framework/templates",
115
115
  "deps": []
116
116
  },
@@ -120,7 +120,7 @@
120
120
  "deps": []
121
121
  },
122
122
  "@uniweb/unipress": {
123
- "version": "0.9.9",
123
+ "version": "0.9.11",
124
124
  "path": "framework/unipress",
125
125
  "deps": [
126
126
  "@uniweb/build",