uniweb 0.21.0 → 0.22.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.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,13 +42,13 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.8.5",
45
- "@uniweb/runtime": "^0.11.7",
46
- "@uniweb/kit": "^0.11.3"
45
+ "@uniweb/kit": "^0.12.0",
46
+ "@uniweb/runtime": "^0.11.7"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "^0.21.0",
50
- "@uniweb/semantic-parser": "^1.2.2",
51
- "@uniweb/content-reader": "^1.2.2"
49
+ "@uniweb/content-reader": "^1.2.2",
50
+ "@uniweb/build": "^0.22.0",
51
+ "@uniweb/semantic-parser": "^1.2.2"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -649,6 +649,50 @@ seo:
649
649
 
650
650
  **Localized URLs:** on a multilingual site (`languages:` in site.yml), `slug: { <lang>: <segment> }` gives a page a native URL segment per language; the folder name stays the canonical route. Nested folders compose automatically, and localized URLs flow through navigation, the language switcher, and the sitemap. See Part 5 for the translation workflow.
651
651
 
652
+ **Knowledge pages — content for an AI agent, not for a visitor.** `knowledge: true` in a `page.yml` or `folder.yml` marks a page as agent-only: it is **never rendered** — no route, no HTML — and it cascades to the whole subtree by route prefix, so one marker on `pages/kb/` covers everything under it and `/kb` never claims `/kbase`. Write plain markdown; a section `type:` there never selects a component, because nothing renders these pages.
653
+
654
+ ⚠️ **A knowledge page is not published to anyone — it powers a service your site offers.** Everything your site *does* publish is published on equal terms: an AI visiting your site sees exactly what a person sees. `llms.txt` and the per-page `.md` files are a convenience for that reader, not a privileged tier — no visitor, human or machine, gets more than another.
655
+
656
+ A knowledge page is different in kind, not in audience. It is never a page anyone reads; it is source material for an assistant the site offers **to its visitors** — they ask, they get an answer, and the material behind it stays unpublished. That is why it is kept out of `llms.txt`, the `.md` projections and the search index: not because some readers are trusted less, but because a knowledge page was never a page.
657
+
658
+ ⇒ If what you want is "make this readable by any AI that visits", write an ordinary page. It is already published to them, on the same terms as to everyone else.
659
+
660
+ ⛔ **`knowledge:` is not a security control — don't put secrets there.** "Not published" is about who the prose is *written for*, not about confidentiality. The assistant's job is to answer visitors using this material and it can quote it back to whoever asks, so a visitor who prompts it can surface what a knowledge page says — **by design, not by leak.** The test for what belongs here is "is this written for the assistant to reason with?", not "do I want to keep this from people?" Anything that genuinely must not reach a visitor doesn't belong in your site's content at all.
661
+
662
+ ```yaml
663
+ # pages/kb/page.yml
664
+ title: Product Knowledge
665
+ knowledge: true
666
+ ```
667
+
668
+ What can actually *read* it depends on where you deploy, because reading it needs a server:
669
+
670
+ - **`uniweb deploy`** (backend-hosted) — the content travels with the site, so an assistant running over it can grep and read your knowledge material alongside the public content when answering a visitor's question.
671
+ - **`uniweb export` / `uniweb deploy --host <adapter>`** (static host) — **the build drops these pages and says so**: `Dropped 2 knowledge page(s) from this build: /kb, /kb/pricing`. Files are served as files here; there is no assistant, so nothing would read them. The warning means the flag was honoured.
672
+
673
+ The artifacts **your build emits** to describe the public site never name them — `llms.txt`, the per-page `.md` projections, and the search index all describe pages a visitor reads, and a knowledge page's prose was written for the assistant instead. Two settings **outrank** `knowledge:`, so a contradiction resolves toward the narrower reach: `agents.exclude` in `site.yml`, and any `_`-prefixed route segment. *(A host that derives these artifacts itself, rather than serving the ones your build produced, applies its own rules — ask your host what it does with `knowledge:` before relying on it there.)*
674
+
675
+ Don't confuse it with the visibility flags: `hidden: true` is a **draft** (not published at all), `hideIn` only controls **nav placement** (still reachable by URL), and `knowledge: true` is a **different audience** (never rendered for anyone).
676
+
677
+ **A site can be nothing but knowledge — that is a real and useful shape.** A site is a set of routes; it does not have to have HTML pages. Mark the root `knowledge: true` and every page inherits it, so the site renders nothing at all and exists to *be* an agent: a `/_agent/chat` URL that a web app, a mobile app, or someone else's backend sends requests to.
678
+
679
+ ```
680
+ site/pages/
681
+ ├── page.yml # knowledge: true — cascades to everything
682
+ ├── house-style.md
683
+ ├── pricing.md
684
+ └── support.md
685
+ ```
686
+
687
+ Measured on exactly that shape: **the agent corpus holds every page; the public projections hold nothing** — `llms.txt` lists no pages, the search index has 0 entries, and no `.md` files are emitted. Nothing leaks, by construction rather than by configuration, because there is no public surface to leak onto.
688
+
689
+ ⛔ **This shape needs a host that actually runs an agent, and deploying is not the same as having one.** Two ways to end up with nothing:
690
+
691
+ - **A static host** (`uniweb export`, `deploy --host`) drops every page — they have no reader there — and you get an empty SPA shell. The build still says *"complete"*, because 0 pages is not an error: it reports `Collected 0 pages` and pre-renders none.
692
+ - **A backend-hosted deployment that does not offer the service.** Whether an agent endpoint exists is the host's to decide, per site — it is not implied by deploying successfully.
693
+
694
+ ⇒ **The way to know is to ask, at render:** `resolveService(website, 'assistant')` returns a `url` only where the host declared one. On a site that is *only* knowledge there is no component to ask — so confirm with your host that the agent is enabled for that site before you build an integration against it. If you meant to build an agent endpoint and got silence, this is where to look.
695
+
652
696
  ### Your site is readable by agents, automatically
653
697
 
654
698
  Every build emits two things an AI agent can use directly, alongside the HTML. **Free, on by default, nothing to install.**
@@ -674,6 +718,38 @@ agents:
674
718
  agents: false # or turn the whole thing off in one word
675
719
  ```
676
720
 
721
+ #### Expecting agent traffic from somewhere specific
722
+
723
+ If your site runs an agent (see *Knowledge pages* above), you can declare where its requests are expected to come from. Useful when the site exists to serve someone else's app rather than its own visitors — a chat endpoint your web app calls.
724
+
725
+ ```yaml
726
+ # site.yml
727
+ agents:
728
+ expectedOrigins:
729
+ - https://app.example.com
730
+ - https://partner.example.org
731
+ ```
732
+
733
+ **It is a declaration, not a gate — the name is the contract.** You are telling the host where you expect callers from; a host that offers an agent may then decline requests whose browser-sent `Origin` is not one of them, before it spends anything answering. Behaviour where it is honoured:
734
+
735
+ | | |
736
+ |---|---|
737
+ | not declared | no check at all — nothing happens unless you ask for it |
738
+ | your own site's origin | always fine, without listing it — declaring a partner must not take down your own chat box |
739
+ | a request with **no** `Origin` | **allowed** — see below, this is deliberate |
740
+ | matching | exact, per entry; no wildcards |
741
+
742
+ ⛔ **It is not a spend limit, and it cannot be.** `Origin` is set by the browser and enforced by the browser: anything that is not a browser sends whatever it likes, or nothing at all. So this stops **casual misuse and third-party embedding** — someone dropping your endpoint into their own page — and it does **not** stop anyone determined to run up your bill. Do not size your budget on it.
743
+
744
+ ⭐ **And that is why a request with no `Origin` is allowed rather than refused.** There are two shapes of caller and this check only sees one:
745
+
746
+ - your app's **JavaScript**, in a browser — sends `Origin`, covered here;
747
+ - your app's **server**, calling from its own backend — never sends one, and refusing it would break the integration this feature partly exists for.
748
+
749
+ Server-to-server callers are authenticated by other means, arranged with your host — not by anything in `site.yml`. **Never put a shared secret in `site.yml`:** everything in it is delivered to every visitor as part of the site payload.
750
+
751
+ ⚠️ Spelling matters and nothing downstream will catch it — the block is forwarded to your host as opaque data, so a typo is carried and simply never acted on. `uniweb doctor` flags unknown keys in `agents:` for exactly this reason; run it before you rely on one.
752
+
677
753
  **Set `seo.baseUrl` if you want absolute links** in the index — without it the links are root-relative, which still works for an agent that arrived via the index. `uniweb doctor` warns when it's unset.
678
754
 
679
755
  **What's excluded, and it's deliberate:** `seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, and dynamic route templates. An index *describes* pages rather than merely listing them, so an unlinked page would become both discoverable and summarized — which is why these exclusions are load-bearing rather than tidy-up. `noindex` or `hidden` on a **folder** takes the whole branch with it.
@@ -1781,6 +1857,21 @@ resolution for anything, so a foundation can define a service the framework has
1781
1857
  never heard of and a host can fill it. Same escalation `fetcher.transports`
1782
1858
  offers for data.
1783
1859
 
1860
+ **Building an "Ask AI" component?** The service name is `assistant`, and a host that runs an agent for a site typically serves it at the conventional path `/_agent`. **You should never need to write that path** — ask the runtime instead:
1861
+
1862
+ ```jsx
1863
+ const { url } = resolveService(website, 'assistant')
1864
+ if (!url) return null // this site has no agent — render nothing, or a static fallback
1865
+ ```
1866
+
1867
+ **This is a synchronous read, not a request.** Which services a site has was settled when the site was last published and travels in its metadata, so the runtime already holds the answer — there is no handshake, no probe, no await. Your component asks the runtime; the runtime does not ask anyone.
1868
+
1869
+ ⛔ **Absent is the answer, not a lookup that failed.** No `url` means the site has no agent — never enabled, or this host runs none. Render for that case; don't retry it. And don't hardcode `/_agent/chat` when nothing was declared: on a static host that turns "no agent here" into a 404 your component can't tell from a broken endpoint. The path is named above so you recognize the shape, not so you can construct it.
1870
+
1871
+ ⛔ **And don't tell the visitor.** `resolveService` also returns a `reason`, and it is **not visitor copy** — a visitor has no stake in which services the operator provisioned, and "this site has no assistant configured" reports someone's billing state to the public while reading like a breakage. It is neither. **Absence is a rendering decision, not a message**: a generic component is expected to be smart about it. No assistant → no Ask-AI affordance. No submit endpoint → no form, or degrade to a `mailto:` the site already carries in its content. The `reason` string is there for *you*, while you wire a site up.
1872
+
1873
+ *(A live agent that errors mid-conversation is a different problem — that's ordinary request failure, handled where you make the request.)*
1874
+
1784
1875
  ```yaml
1785
1876
  # site.yml — only when YOU are providing the endpoint. Publishing to Uniweb
1786
1877
  # Cloud needs nothing here; `uniweb export` and most `deploy --host` targets do.
@@ -1791,14 +1882,16 @@ submit: https://forms.example.com/intake # or another origin
1791
1882
  ```jsx
1792
1883
  import { useFormSubmit } from '@uniweb/kit'
1793
1884
 
1794
- const { submit, status, error, canSubmit, unavailableReason } = useFormSubmit({
1885
+ const { submit, status, error, canSubmit } = useFormSubmit({
1795
1886
  block, // supplies section + page context
1796
1887
  context: { formId: 'contact' },
1797
1888
  summary: (v) => ({ title: v.name, subtitle: v.email }),
1798
1889
  })
1799
1890
 
1800
- <button type="submit" disabled={!canSubmit || status === 'submitting'}>Send</button>
1801
- {!canSubmit && <p role="status">{unavailableReason}</p>}
1891
+ if (!canSubmit) return null // nowhere to send — render no form, or fall back
1892
+ // to contact details from the site's own content
1893
+
1894
+ <button type="submit" disabled={status === 'submitting'}>Send</button>
1802
1895
  ```
1803
1896
 
1804
1897
  > **The framework never invents an endpoint — but a host may supply one.** Don't
@@ -1807,11 +1900,18 @@ const { submit, status, error, canSubmit, unavailableReason } = useFormSubmit({
1807
1900
  > one hosting.
1808
1901
  >
1809
1902
  > `canSubmit` is false only when neither a declaration nor a host supplies a
1810
- > destination. **Check it when you render, not only on the button press**, so the
1811
- > control is visibly disabled before anyone fills the form in, and show
1812
- > `unavailableReason` — it explains which case you are in. A read that 404s
1903
+ > destination. **Check it when you render, not only on the button press** a
1904
+ > form nobody can send should not be on the page at all. A read that 404s
1813
1905
  > degrades to `[]` and the page still renders; a write that 404s loses what a
1814
1906
  > person typed, so it gets no silent fallback.
1907
+ >
1908
+ > ⛔ **Don't tell the visitor why.** There is deliberately no explanatory string:
1909
+ > which services an operator bought is none of a visitor's business, "not enabled
1910
+ > for this site" reads like a breakage when nothing is broken, and any such
1911
+ > sentence would be one language on a site that is often multilingual and rarely
1912
+ > English. Text a visitor reads is **site content** — authored, and localized with
1913
+ > everything else. Degrade to something useful instead: a `mailto:`, a phone
1914
+ > number, an address the site already carries.
1815
1915
 
1816
1916
  `status` runs `idle → submitting → success | error`; a non-2xx becomes a thrown
1817
1917
  `Error` carrying the endpoint's own `error` message when it sends one. For file
@@ -17,7 +17,7 @@ import {
17
17
  classifyPackage,
18
18
  isExtensionPackage as buildIsExtensionPackage
19
19
  } from '@uniweb/build'
20
- import { loadDeployYml } from '@uniweb/build/site'
20
+ import { loadDeployYml, AGENTS_KEYS } from '@uniweb/build/site'
21
21
  import { listAdapters } from '@uniweb/build/hosts'
22
22
  import { getCliVersion } from '../versions.js'
23
23
  import { readAgentsVersion } from '../utils/agents-stamp.js'
@@ -255,6 +255,108 @@ export function findFormContent(sitePath, siteYml) {
255
255
  return found
256
256
  }
257
257
 
258
+ /**
259
+ * A misspelled key in `agents:` is silent everywhere else, forever.
260
+ *
261
+ * The block reaches a backend as **opaque JSON** — measured 2026-08-13, and it
262
+ * is the right design: the vocabulary is the framework's, and a backend that
263
+ * validated it would be inventing our language. The cost is that nothing
264
+ * downstream can object. A host receives `expectedOrgins`, stores it, forwards
265
+ * it, and enforces nothing; the author sees a configured site and gets no
266
+ * check. **This is the only lane that can catch it.**
267
+ *
268
+ * ⛔ Severity is deliberate. It is a `warning` for an unknown key generally,
269
+ * but the failure it prevents is worst for the keys that RESTRICT something —
270
+ * an author who writes `expectedOrgins` believes they asked for a check they
271
+ * did not get. A key that silently does nothing is the recurring wart of this
272
+ * block: `exclude`'s glob spelling excludes nothing, and a carried key nobody
273
+ * reads enforces nothing.
274
+ *
275
+ * `agents: false` turns the capability off in one word and is not an object —
276
+ * flagging its absence of keys would be nonsense.
277
+ */
278
+ export function checkAgentsBlock({ siteName, siteYml, issues }) {
279
+ const agents = siteYml?.agents
280
+ if (agents === undefined || agents === false) return
281
+ if (!agents || typeof agents !== 'object' || Array.isArray(agents)) {
282
+ const id = 'agents-not-an-object'
283
+ issues.push({
284
+ id,
285
+ type: 'warning',
286
+ site: siteName,
287
+ message: `site.yml: \`agents:\` should be a map of options, or \`false\` to turn it off`
288
+ })
289
+ warn(`[${id}] ${siteName}: \`agents:\` is neither a map of options nor \`false\`.`)
290
+ return
291
+ }
292
+
293
+ const known = new Set(AGENTS_KEYS)
294
+ const unknown = Object.keys(agents).filter((k) => !known.has(k))
295
+ if (unknown.length === 0) return
296
+
297
+ const id = 'agents-unknown-key'
298
+ issues.push({
299
+ id,
300
+ type: 'warning',
301
+ site: siteName,
302
+ message: `site.yml: \`agents:\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
303
+ })
304
+ warn(
305
+ `[${id}] ${siteName}: \`agents:\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
306
+ .map((k) => `'${k}'`)
307
+ .join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
308
+ )
309
+ for (const key of unknown) {
310
+ const near = nearestAgentsKey(key, known)
311
+ if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
312
+ }
313
+ log(
314
+ ` ${colors.dim}Nothing downstream will reject it: the block is forwarded to the host as opaque data,${colors.reset}`
315
+ )
316
+ log(
317
+ ` ${colors.dim}so an unrecognized key is carried and never acted on. Known: ${AGENTS_KEYS.join(', ')}.${colors.reset}`
318
+ )
319
+ }
320
+
321
+ /**
322
+ * The closest known key within a small edit distance, or null.
323
+ *
324
+ * Suggestions are capped at distance 3 rather than "closest wins": on a
325
+ * six-word vocabulary an uncapped nearest-match confidently proposes `index`
326
+ * for `origins`, and a wrong suggestion is worse than none — it sends the
327
+ * author to change a line that was not their mistake.
328
+ */
329
+ function nearestAgentsKey(input, known) {
330
+ let best = null
331
+ let bestScore = Infinity
332
+ for (const candidate of known) {
333
+ const score = editDistance(input.toLowerCase(), candidate.toLowerCase())
334
+ if (score < bestScore) {
335
+ bestScore = score
336
+ best = candidate
337
+ }
338
+ }
339
+ return bestScore <= 3 ? best : null
340
+ }
341
+
342
+ /** Levenshtein distance, iterative two-row form. */
343
+ function editDistance(a, b) {
344
+ if (a === b) return 0
345
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
346
+ for (let i = 1; i <= a.length; i++) {
347
+ const row = [i]
348
+ for (let j = 1; j <= b.length; j++) {
349
+ row[j] = Math.min(
350
+ prev[j] + 1,
351
+ row[j - 1] + 1,
352
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
353
+ )
354
+ }
355
+ prev = row
356
+ }
357
+ return prev[b.length]
358
+ }
359
+
258
360
  /**
259
361
  * A site whose content declares a form needs somewhere to send it.
260
362
  *
@@ -657,6 +759,7 @@ export async function doctor(args = []) {
657
759
  // anyone who knows the URL, listed by nothing.
658
760
  // Forms need a destination, and having none is only visible on the page.
659
761
  await checkFormSubmitTarget({ sitePath, siteName, siteYml, issues })
762
+ checkAgentsBlock({ siteName, siteYml, issues })
660
763
 
661
764
  checkGeneratedDataDir({
662
765
  sitePath,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-13T15:51:25.141Z",
3
+ "generatedAt": "2026-08-13T20:52:45.396Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.21.0",
6
+ "version": "0.22.0",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -46,7 +46,7 @@
46
46
  "deps": []
47
47
  },
48
48
  "@uniweb/kit": {
49
- "version": "0.11.3",
49
+ "version": "0.12.0",
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.0",
68
+ "version": "0.3.1",
69
69
  "path": "framework/projections",
70
70
  "deps": [
71
71
  "@uniweb/content-writer",
@@ -111,7 +111,7 @@
111
111
  "deps": []
112
112
  },
113
113
  "@uniweb/unipress": {
114
- "version": "0.8.6",
114
+ "version": "0.8.7",
115
115
  "path": "framework/unipress",
116
116
  "deps": [
117
117
  "@uniweb/build",