uniweb 0.22.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 +7 -7
- package/partials/agents.md +102 -0
- package/src/commands/doctor.js +145 -2
- package/src/framework-index.json +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "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.
|
|
45
|
-
"@uniweb/kit": "^0.12.
|
|
46
|
-
"@uniweb/runtime": "^0.
|
|
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/
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
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": {
|
package/partials/agents.md
CHANGED
|
@@ -1954,6 +1954,108 @@ from. `values` keeps the `File` so your input can show its selection.
|
|
|
1954
1954
|
|
|
1955
1955
|
Full reference: `development/receiving-form-submissions.md`.
|
|
1956
1956
|
|
|
1957
|
+
### Tracking (`tracking:`)
|
|
1958
|
+
|
|
1959
|
+
A site may declare one **tracking destination**, and everything worth counting
|
|
1960
|
+
goes there as an event on a single stream. A page visit is just the event the
|
|
1961
|
+
runtime emits by itself.
|
|
1962
|
+
|
|
1963
|
+
```yaml
|
|
1964
|
+
# site.yml — your own collector, on any host
|
|
1965
|
+
tracking: https://collector.example.com/events
|
|
1966
|
+
|
|
1967
|
+
# or, when it needs more than an address
|
|
1968
|
+
tracking:
|
|
1969
|
+
endpoint: /collect
|
|
1970
|
+
consent: required
|
|
1971
|
+
```
|
|
1972
|
+
|
|
1973
|
+
A host may also supply one under `services.tracking`, and the usual precedence
|
|
1974
|
+
applies: yours wins, then the host's, then neither.
|
|
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
|
+
|
|
1986
|
+
**The runtime reports `page_view` on every route change, including the first.**
|
|
1987
|
+
That is the only thing it emits on its own — everything else is yours to report:
|
|
1988
|
+
|
|
1989
|
+
```jsx
|
|
1990
|
+
// In a section type, the block is already in your props.
|
|
1991
|
+
block.track('video_milestone', { milestone: 50 })
|
|
1992
|
+
```
|
|
1993
|
+
|
|
1994
|
+
`block.track` attaches the page path and the section type for you. For an event
|
|
1995
|
+
with no block in hand, use the hook:
|
|
1996
|
+
|
|
1997
|
+
```jsx
|
|
1998
|
+
import { useTracker } from '@uniweb/kit'
|
|
1999
|
+
|
|
2000
|
+
const { track } = useTracker()
|
|
2001
|
+
<button onClick={() => track('brochure_download', { file: 'specs.pdf' })}>…</button>
|
|
2002
|
+
```
|
|
2003
|
+
|
|
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.
|
|
2010
|
+
|
|
2011
|
+
⛔ **Never guard a `track()` call.** A site with **no** tracking destination is
|
|
2012
|
+
the default and the majority: the call returns having done nothing, opened no
|
|
2013
|
+
connection, and thrown nothing. Absent is the normal state, not an error — so
|
|
2014
|
+
don't check whether tracking is on, and never render differently because of it.
|
|
2015
|
+
There is no "is tracking enabled" question a component should be asking.
|
|
2016
|
+
|
|
2017
|
+
⚠️ **If a site asks for consent**, tracking holds everything until a visitor
|
|
2018
|
+
answers; granting sends what was buffered, denying discards it. A consent banner
|
|
2019
|
+
is an ordinary component:
|
|
2020
|
+
|
|
2021
|
+
```jsx
|
|
2022
|
+
import { useTrackingConsent } from '@uniweb/kit'
|
|
2023
|
+
|
|
2024
|
+
const { status, grant, deny } = useTrackingConsent()
|
|
2025
|
+
if (status !== 'pending') return null
|
|
2026
|
+
return <CookieBanner onAccept={grant} onReject={deny} />
|
|
2027
|
+
```
|
|
2028
|
+
|
|
2029
|
+
Without `consent: required`, tracking starts immediately — declaring a
|
|
2030
|
+
destination is the site owner's decision to make, not the framework's.
|
|
2031
|
+
|
|
2032
|
+
Every event carries a **`visit`** key on the envelope — opaque, generated at page
|
|
2033
|
+
load, the same for every event of that page load — so a collector can order what
|
|
2034
|
+
happened into a journey.
|
|
2035
|
+
|
|
2036
|
+
> **What is never sent:** no visitor id, no fingerprint, no session spanning days
|
|
2037
|
+
> or tabs, and **nothing is written to the visitor's device** — no cookie, no
|
|
2038
|
+
> local storage. The `visit` key lives in memory and dies with the document, so
|
|
2039
|
+
> it identifies one page load rather than a person. A `page_view` carries the
|
|
2040
|
+
> path, and — captured once when the page first loads, then replayed on each view
|
|
2041
|
+
> — the external referrer and any `utm_*` the visitor arrived with. Nothing else.
|
|
2042
|
+
|
|
2043
|
+
### ⛔ Use kit. Never touch the `uniweb` global
|
|
2044
|
+
|
|
2045
|
+
Everything above reaches the runtime through `@uniweb/kit` or through something
|
|
2046
|
+
handed to your component as a prop. That is the rule, not a stylistic preference:
|
|
2047
|
+
|
|
2048
|
+
- ✅ `useWebsite()`, `useTracker()`, `resolveService(website, …)` — kit hooks and
|
|
2049
|
+
utilities.
|
|
2050
|
+
- ✅ `block.track(…)`, `block.page`, `block.website` — the block **arrives in your
|
|
2051
|
+
props**, so calling methods on it is not reaching for a global.
|
|
2052
|
+
- ⛔ `globalThis.uniweb`, `window.uniweb` — never, in a foundation.
|
|
2053
|
+
|
|
2054
|
+
The singleton is framework internals: its shape is not part of the contract with
|
|
2055
|
+
foundations, and code that reads it directly breaks on changes that were never
|
|
2056
|
+
breaking changes. If you need something kit does not expose yet, that is worth
|
|
2057
|
+
reporting — not worth reaching around.
|
|
2058
|
+
|
|
1957
2059
|
<!-- template:loom -->
|
|
1958
2060
|
### Content handlers
|
|
1959
2061
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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,
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-16T05:48:07.516Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
114
|
+
"version": "0.8.9",
|
|
115
115
|
"path": "framework/unipress",
|
|
116
116
|
"deps": [
|
|
117
117
|
"@uniweb/build",
|