uniweb 0.23.0 → 0.24.0

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.23.0",
3
+ "version": "0.24.0",
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.9.0",
45
- "@uniweb/kit": "^0.12.1",
46
- "@uniweb/runtime": "^0.11.8"
44
+ "@uniweb/core": "^0.10.0",
45
+ "@uniweb/kit": "^0.12.2",
46
+ "@uniweb/runtime": "^0.12.0"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "^0.23.0",
50
- "@uniweb/content-reader": "^1.2.2",
51
- "@uniweb/semantic-parser": "^1.2.2"
49
+ "@uniweb/build": "^0.24.0",
50
+ "@uniweb/semantic-parser": "^1.2.2",
51
+ "@uniweb/content-reader": "^1.2.2"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -1962,17 +1962,27 @@ runtime emits by itself.
1962
1962
 
1963
1963
  ```yaml
1964
1964
  # site.yml — your own collector, on any host
1965
- tracking: https://plausible.io/api/event
1965
+ tracking: https://collector.example.com/events
1966
1966
 
1967
1967
  # or, when it needs more than an address
1968
1968
  tracking:
1969
- endpoint: https://plausible.io/api/event
1969
+ endpoint: /collect
1970
1970
  consent: required
1971
1971
  ```
1972
1972
 
1973
1973
  A host may also supply one under `services.tracking`, and the usual precedence
1974
1974
  applies: yours wins, then the host's, then neither.
1975
1975
 
1976
+ ⚠️ **The endpoint has to accept the framework's own format** — a batched
1977
+ `{ "events": [ … ] }` POST, documented in `reference/site-configuration.md`. It
1978
+ is not a third-party analytics product's public API, which expects that
1979
+ product's own shape.
1980
+
1981
+ A site may also name a vendor's own script under `tracking.scripts`, which the
1982
+ runtime loads once, after consent when a gate is declared, and never in a frame
1983
+ or during prerender. That is a **separate path with no connection to the stream
1984
+ below** — the vendor measures its own way, and nothing you `track()` reaches it.
1985
+
1976
1986
  **The runtime reports `page_view` on every route change, including the first.**
1977
1987
  That is the only thing it emits on its own — everything else is yours to report:
1978
1988
 
@@ -1991,7 +2001,12 @@ const { track } = useTracker()
1991
2001
  <button onClick={() => track('brochure_download', { file: 'specs.pdf' })}>…</button>
1992
2002
  ```
1993
2003
 
1994
- The event name is yours — there is no list of permitted names.
2004
+ The event name is yours — there is no list of permitted names. Three are already
2005
+ in use, so reach for these rather than inventing a synonym: **`page_view`** (the
2006
+ runtime), **`scroll_depth`** (kit's `useScrollDepth()`, with a `depth` of 25 /
2007
+ 50 / 75 / 100) and **`video_milestone`** (kit's `<Media>`, with `milestone` and
2008
+ `src`). Put the varying part in a **field**, never in the name — four names for
2009
+ one event turn a collector's event dimension into a cardinality problem.
1995
2010
 
1996
2011
  ⛔ **Never guard a `track()` call.** A site with **no** tracking destination is
1997
2012
  the default and the majority: the call returns having done nothing, opened no
@@ -307,7 +307,7 @@ export function checkAgentsBlock({ siteName, siteYml, issues }) {
307
307
  .join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
308
308
  )
309
309
  for (const key of unknown) {
310
- const near = nearestAgentsKey(key, known)
310
+ const near = nearestKnownKey(key, known)
311
311
  if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
312
312
  }
313
313
  log(
@@ -326,7 +326,7 @@ export function checkAgentsBlock({ siteName, siteYml, issues }) {
326
326
  * for `origins`, and a wrong suggestion is worse than none — it sends the
327
327
  * author to change a line that was not their mistake.
328
328
  */
329
- function nearestAgentsKey(input, known) {
329
+ function nearestKnownKey(input, known) {
330
330
  let best = null
331
331
  let bestScore = Infinity
332
332
  for (const candidate of known) {
@@ -339,6 +339,148 @@ function nearestAgentsKey(input, known) {
339
339
  return bestScore <= 3 ? best : null
340
340
  }
341
341
 
342
+ /**
343
+ * The options `tracking:` actually has a reader.
344
+ *
345
+ * ⚠️ **Kept here rather than imported, because nothing at runtime enumerates
346
+ * them** — `wireTracker` reads named properties off the resolved declaration, it
347
+ * does not iterate a list. So there is no existing array to import and this
348
+ * duplicates nothing. It does mean the list can drift: the readers are
349
+ * `runtime/src/wire-foundation.js::wireTracker` (`consent`, `scripts`, `debug`)
350
+ * and `core/src/services.js::readEndpoint` (`endpoint`). Add a key there, add it
351
+ * here.
352
+ */
353
+ const TRACKING_KEYS = ['endpoint', 'consent', 'scripts', 'debug']
354
+
355
+ /**
356
+ * Spellings that read as an ATTEMPT to require consent but do not require it.
357
+ *
358
+ * `consentRequired` is `options.consent === 'required'` — an exact match — so
359
+ * every one of these silently means *no gate*. That is the one field in this
360
+ * block where a silent no-op has a consequence beyond a missing metric.
361
+ *
362
+ * ⚖️ Deliberately not "anything that is not `required`": `consent: none` is a
363
+ * documented, useful value — it is how a site overrides a gate its **host**
364
+ * declared, under the per-key tier fill. Flagging that would flag correct code.
365
+ */
366
+ const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true', 'yes', 'on', '1'])
367
+
368
+ /**
369
+ * `tracking:` — flag the keys and values that are carried and never acted on.
370
+ *
371
+ * The block is forwarded to the host as opaque data and resolved at render, so
372
+ * nothing downstream rejects a mistake in it. Every error here therefore fails
373
+ * the same way: **silently, at a visitor's browser, as an absence** — which is
374
+ * also exactly what a site that configured nothing looks like. There is no
375
+ * symptom to notice and nothing to grep for.
376
+ *
377
+ * ⭐ That is the whole argument for checking it at `doctor` time: it is the only
378
+ * moment in the chain where a person who can fix it is looking at it.
379
+ *
380
+ * A bare `tracking: <url>` string is the documented shorthand and carries no
381
+ * options — there is nothing in it to be wrong.
382
+ */
383
+ export function checkTrackingBlock({ siteName, siteYml, issues }) {
384
+ const tracking = siteYml?.tracking
385
+ if (tracking === undefined || tracking === null) return
386
+ if (typeof tracking === 'string') return
387
+
388
+ if (typeof tracking !== 'object' || Array.isArray(tracking)) {
389
+ const id = 'tracking-not-an-object'
390
+ issues.push({
391
+ id,
392
+ type: 'warning',
393
+ site: siteName,
394
+ message: `site.yml: \`tracking:\` should be an endpoint string, or a map of options`
395
+ })
396
+ warn(`[${id}] ${siteName}: \`tracking:\` is neither an endpoint string nor a map of options.`)
397
+ return
398
+ }
399
+
400
+ const known = new Set(TRACKING_KEYS)
401
+ const unknown = Object.keys(tracking).filter((k) => !known.has(k))
402
+ if (unknown.length > 0) {
403
+ const id = 'tracking-unknown-key'
404
+ issues.push({
405
+ id,
406
+ type: 'warning',
407
+ site: siteName,
408
+ message: `site.yml: \`tracking:\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
409
+ })
410
+ warn(
411
+ `[${id}] ${siteName}: \`tracking:\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
412
+ .map((k) => `'${k}'`)
413
+ .join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
414
+ )
415
+ for (const key of unknown) {
416
+ const near = nearestKnownKey(key, known)
417
+ if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
418
+ }
419
+ log(
420
+ ` ${colors.dim}Carried to the host as opaque data and never read. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
421
+ )
422
+ }
423
+
424
+ // `consent` is exact-matched against 'required'. A near miss is not a typo
425
+ // with a cosmetic cost — it is a consent gate the author believes they asked
426
+ // for and did not get.
427
+ if ('consent' in tracking) {
428
+ const value = tracking.consent
429
+ const spelling = value === null || value === undefined ? '' : String(value).trim().toLowerCase()
430
+
431
+ // ⛔ The gate test is against the RAW value, because the runtime's is:
432
+ // `options.consent === 'required'`, exact and case-sensitive. Lowercasing
433
+ // before this comparison made `Required` look correct to the checker — the
434
+ // check stopped testing the thing it was written to catch. The normalized
435
+ // spelling is only for deciding whether a wrong value reads as an ATTEMPT.
436
+ const gateIsOn = value === 'required'
437
+ const looksIntended =
438
+ spelling === '' || spelling === 'required' || CONSENT_NEAR_MISSES.has(spelling)
439
+
440
+ if (!gateIsOn && looksIntended) {
441
+ const id = 'tracking-consent-not-required'
442
+ const shown = spelling === '' ? 'an empty value' : `\`${String(value)}\``
443
+ issues.push({
444
+ id,
445
+ type: 'warning',
446
+ site: siteName,
447
+ message: `site.yml: \`tracking.consent:\` is ${shown} — only the exact value \`required\` turns the gate on`
448
+ })
449
+ warn(`[${id}] ${siteName}: \`tracking.consent:\` is ${shown}; the gate is OFF.`)
450
+ log(
451
+ ` ${colors.dim}Only \`consent: required\` holds events until a visitor answers. Anything else${colors.reset}`
452
+ )
453
+ log(
454
+ ` ${colors.dim}sends immediately — including a blank value, which YAML reads as null.${colors.reset}`
455
+ )
456
+ }
457
+ }
458
+
459
+ // An entry that yields no URL is dropped by the loader without a sound.
460
+ const declared = tracking.scripts
461
+ if (declared !== undefined) {
462
+ const list = Array.isArray(declared) ? declared : [declared]
463
+ const bad = list.filter(
464
+ (e) => !(typeof e === 'string' ? e.trim() : typeof e?.src === 'string' && e.src.trim())
465
+ )
466
+ if (bad.length > 0) {
467
+ const id = 'tracking-script-without-url'
468
+ issues.push({
469
+ id,
470
+ type: 'warning',
471
+ site: siteName,
472
+ message: `site.yml: \`tracking.scripts:\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
473
+ })
474
+ warn(
475
+ `[${id}] ${siteName}: ${bad.length} \`tracking.scripts:\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
476
+ )
477
+ log(
478
+ ` ${colors.dim}An entry is a URL, or an object with a \`src\`. Anything else is dropped silently.${colors.reset}`
479
+ )
480
+ }
481
+ }
482
+ }
483
+
342
484
  /** Levenshtein distance, iterative two-row form. */
343
485
  function editDistance(a, b) {
344
486
  if (a === b) return 0
@@ -760,6 +902,7 @@ export async function doctor(args = []) {
760
902
  // Forms need a destination, and having none is only visible on the page.
761
903
  await checkFormSubmitTarget({ sitePath, siteName, siteYml, issues })
762
904
  checkAgentsBlock({ siteName, siteYml, issues })
905
+ checkTrackingBlock({ siteName, siteYml, issues })
763
906
 
764
907
  checkGeneratedDataDir({
765
908
  sitePath,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-14T23:58:32.904Z",
3
+ "generatedAt": "2026-08-16T05:48:07.516Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.23.0",
6
+ "version": "0.24.0",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -28,7 +28,7 @@
28
28
  "deps": []
29
29
  },
30
30
  "@uniweb/core": {
31
- "version": "0.9.0",
31
+ "version": "0.10.0",
32
32
  "path": "framework/core",
33
33
  "deps": [
34
34
  "@uniweb/semantic-parser",
@@ -46,7 +46,7 @@
46
46
  "deps": []
47
47
  },
48
48
  "@uniweb/kit": {
49
- "version": "0.12.1",
49
+ "version": "0.12.2",
50
50
  "path": "framework/kit",
51
51
  "deps": [
52
52
  "@uniweb/core",
@@ -65,7 +65,7 @@
65
65
  "deps": []
66
66
  },
67
67
  "@uniweb/projections": {
68
- "version": "0.3.2",
68
+ "version": "0.3.3",
69
69
  "path": "framework/projections",
70
70
  "deps": [
71
71
  "@uniweb/content-writer",
@@ -73,7 +73,7 @@
73
73
  ]
74
74
  },
75
75
  "@uniweb/runtime": {
76
- "version": "0.11.8",
76
+ "version": "0.12.0",
77
77
  "path": "framework/runtime",
78
78
  "deps": [
79
79
  "@uniweb/core",
@@ -111,7 +111,7 @@
111
111
  "deps": []
112
112
  },
113
113
  "@uniweb/unipress": {
114
- "version": "0.8.8",
114
+ "version": "0.8.9",
115
115
  "path": "framework/unipress",
116
116
  "deps": [
117
117
  "@uniweb/build",