uniweb 1.18.2 → 1.19.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": "1.18.2",
3
+ "version": "1.19.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,14 +42,14 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "~1.3.0",
45
- "@uniweb/core": "~1.4.4",
45
+ "@uniweb/core": "~1.4.5",
46
46
  "@uniweb/schemas": "~1.6.5",
47
47
  "@uniweb/semantic-parser": "~1.13.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.8.5",
51
- "@uniweb/semantic-parser": "~1.13.0",
52
- "@uniweb/content-reader": "~1.5.6"
50
+ "@uniweb/build": "~1.9.0",
51
+ "@uniweb/content-reader": "~1.5.6",
52
+ "@uniweb/semantic-parser": "~1.13.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -179,6 +179,8 @@ pnpm preview # preview the production build
179
179
 
180
180
  Markdown, `theme.yml`, and component edits hot-reload. New section types are picked up without a restart.
181
181
 
182
+ **The dev server says where it is — read it, never assume a port.** `uniweb dev` takes a free port and, once the server listens, prints `Serving <site> at <url>`. The usual port may be another project's server, and reviewing that page reports success on the wrong site. Run `uniweb dev` in the background when you need your shell back: `uniweb dev --status` prints the address later, a second `uniweb dev` points at the running server instead of starting another, and `uniweb dev --stop` stops it when you are done. They know the server `uniweb dev` started, not one you start with `vite` or `npm run dev` inside the site. Pass `--port <n>` only when a port is required: you get that one or an error, never another.
183
+
182
184
  ---
183
185
 
184
186
  ## Part 2 — Working in an existing project
@@ -286,6 +288,8 @@ The architecture exists to keep content and code separate. Your task sits in one
286
288
 
287
289
  ```bash
288
290
  uniweb dev # from the workspace root; it picks the site
291
+ uniweb dev --status # the address it is serving at — read it, never assume a port
292
+ uniweb dev --stop # stop it when you are done
289
293
  ```
290
294
 
291
295
  **When you're unsure what shape your markdown produces, don't reason about it — run it.** From the *site* directory (paths resolve against your shell's working directory):
@@ -590,7 +594,7 @@ submitLabel: Send
590
594
  ```
591
595
  ````
592
596
 
593
- → `content.data?.form` = `{ fields: [...], submitLabel: "Send" }`, when the component declares `data: { form: … }`. A block under a key the component does not declare stays out of `content.data` (the dev console says so) and is still in `content.sequence`.
597
+ → `content.data?.form` = `{ fields: [...], submitLabel: "Send" }`, when the component declares `data: { form: … }`. A block under a key the component does not declare stays out of `content.data` (the dev console says so) and is still in `content.sequence`. A block inside an item is that item's (`content.items[n].data`) and needs no declaration.
594
598
 
595
599
  **Code snippets** — display content with a language for syntax highlighting, collected in `content.snippets` as `[{ language, code }]`. Filter with `content.snippets.filter(s => s.language === 'css')`.
596
600
 
@@ -717,21 +721,24 @@ end the list with `...`.**
717
721
 
718
722
  **A local folder for a mounted route should use `folder.yml`, not `page.yml`.** The filename is how you say what the folder holds — `page.yml` for a page built from sections, `folder.yml` for a folder of pages — and for a mount that answer also decides how the mounted tree is read. A `page.yml` stub over a mounted folder of pages says "sections", and top-level markdown in the mounted repo collapses into one page instead of becoming pages of its own. Keep `layout:`, `title:` and SEO in that stub; the mounted repo's own `folder.yml` supplies its ordering and title where the stub is silent.
719
723
 
720
- **SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps). These render into every page's static `<head>` — Open Graph, Twitter Card, canonical, robots — so shares and crawlers see them without running JS. The social `image` is the field most worth setting once at site level.
724
+ **SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps). Each page's static `<head>` carries its own tags — Open Graph, Twitter Card, canonical, robots, and hreflang on a multilingual site — so shares and crawlers see them without running JS. The social `image` is the field most worth setting once at site level; the site's `description` is the home page's alone. These tags are the framework's: a `head.html` tag of the same name is replaced.
721
725
 
722
726
  ```yaml
723
727
  # site.yml
724
728
  keywords: [components, react, cms]
725
729
  seo:
730
+ origin: https://acme.com # where the site is served from — only where no host states it
726
731
  image: /og-default.png
727
732
  noindex: false # true keeps the WHOLE site out of search
728
733
 
729
734
  # page.yml
730
735
  seo:
731
736
  image: /og-about.png
732
- canonical: https://acme.com/about
737
+ canonical: https://acme.com/team # only to name ANOTHER URL — a page is its own canonical by default
733
738
  ```
734
739
 
740
+ **Where the site lives — every absolute URL is made on it:** a page's canonical and `og:url`, its social image, its sitemap entry, the links in `llms.txt`. It is the site's origin plus its base path, and whoever serves the site states it: the GitHub Pages workflow `uniweb add ci --host=github-pages` writes asks GitHub; Netlify and Vercel state their production URL during the build; on any other static host, set `seo.origin` — a scheme and a host, `https://acme.com`, never with a path (a path is the site's `base:`; `seo.origin: https://user.github.io/repo` is refused). `seo.baseUrl` is retired, and refused. With no address, pages state no canonical, no `sitemap.xml` is written, and `llms.txt` links are root-relative — `uniweb doctor` says when that is so, and how to fix it for your host.
741
+
735
742
  **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.
736
743
 
737
744
  **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.
@@ -835,7 +842,7 @@ Server-to-server callers are authenticated by other means, arranged with your ho
835
842
 
836
843
  ⚠️ 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.
837
844
 
838
- **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.
845
+ **The index links absolutely on the site's address** (see *Where the site lives*) — without one the links are root-relative, which still works for an agent that arrived via the index. Each record of a parametric page is listed as a page, with a `.md` of its own.
839
846
 
840
847
  **What's excluded, and it's deliberate:** `seo.noindex` pages, `hidden` pages, `_`-prefixed drafts, and a parametric page's URL pattern (`/blog/:slug`). 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.
841
848
 
@@ -1174,7 +1181,7 @@ Header.className = 'p-0'
1174
1181
  Header.as = 'header'
1175
1182
  ```
1176
1183
 
1177
- **A header that stays in view is a setting, not a class.** `sticky top-0` on your component's markup does nothing: a sticky element moves only within its parent, and the section's wrapper and the area's are as tall as your header. Declare `sticky: true` in the type's `meta.js` — an author turns it off with `sticky: false` — and the runtime makes sticky the box that can stay in view. A section in the body sticks the same way and stays below the stuck header by itself: the runtime measures how far the header reaches and publishes it as `--uw-sticky-top` (a type's `top` overrides it). A link to a section lands below the stuck header too. A child section in kit's `<ChildGrid>` stays in view beside the column it sits next to, and on a phone, at one column, scrolls with the page. A sticky section inside a box with `overflow: hidden` cannot stick — clip with `overflow: clip`. A layout of your own that wraps an area in an element makes that element sticky when `page.isSticky('header')`. `uniweb dev` names a sticky element that cannot move in the console.
1184
+ **A header that stays in view is a setting, not a class.** `sticky top-0` on your component's outermost element does nothing: a sticky element moves only within its parent, and the section's wrapper and the area's are as tall as your header. (An element inside your component is different: a form beside a long list sticks within the row the two share, a box you draw.) Declare `sticky: true` in the type's `meta.js` — an author turns it off with `sticky: false` — and the runtime makes sticky the box that can stay in view. A section in the body sticks the same way and stays below the stuck header by itself: the runtime measures how far the header reaches and publishes it as `--uw-sticky-top` (a type's `top` overrides it). A link to a section lands below the stuck header too. A child section in kit's `<ChildGrid>` stays in view beside the column it sits next to, and on a phone, at one column, scrolls with the page. A sticky section inside a box with `overflow: hidden` cannot stick — clip with `overflow: clip`. A layout of your own that wraps an area in an element makes that element sticky when `page.isSticky('header')`. `uniweb dev` names a sticky element that cannot move in the console, once the page's data is in, and says which box holds it.
1178
1185
 
1179
1186
  ### meta.js
1180
1187
 
@@ -2793,7 +2800,8 @@ them, and any other in full; it matches a name exactly. State is in memory; rest
2793
2800
  ## Part 5 — Commands, shipping, and migration
2794
2801
 
2795
2802
  ```bash
2796
- uniweb dev # Start dev server (picks the site for you)
2803
+ uniweb dev # Start dev server (picks the site for you; prints its address)
2804
+ uniweb dev --status / --stop # The running dev server's address / stop it
2797
2805
  pnpm build / pnpm preview # Build for production / preview the build (SSG + SPA)
2798
2806
 
2799
2807
  uniweb deploy # Wizard: pick a destination, then deploy (or set up CI)
@@ -3099,7 +3107,7 @@ Most Uniweb failures are **silent** — the build succeeds and the page is wrong
3099
3107
 
3100
3108
  **A var in frontmatter does nothing** — component vars only apply when declared in that section type's `meta.js` `vars:`. Unknown names are ignored silently.
3101
3109
 
3102
- **A sticky header scrolls away** — it is `sticky top-0` on the component's own markup, which has no room to move. Make the section sticky instead: `sticky: true` in its type's `meta.js`. A sticky section that still scrolls away sits in a box with `overflow: hidden` — clip with `overflow: clip`. `uniweb dev` names the element in the console.
3110
+ **A sticky header scrolls away** — it is `sticky top-0` on the component's outermost element, which has no room to move. Make the section sticky instead: `sticky: true` in its type's `meta.js`. A sticky section that still scrolls away sits in a box with `overflow: hidden` — clip with `overflow: clip`. `uniweb dev` names the element in the console.
3103
3111
 
3104
3112
  Loud failures:
3105
3113
 
@@ -12,6 +12,17 @@
12
12
  * uniweb dev Start dev server for the (single) site
13
13
  * uniweb dev <site> Start dev server for a specific site
14
14
  * uniweb dev --site <name> Same, with explicit flag form
15
+ * uniweb dev --port <n> On that port, or not at all
16
+ * uniweb dev --status Print the running dev server's address
17
+ * uniweb dev --stop Stop the running dev server
18
+ *
19
+ * ⭐ THE DEV SERVER SAYS WHERE IT IS; NOTHING GUESSES. It takes a free port (Vite moves past a
20
+ * busy one), and once it listens, the server `dev` starts — only that one — records the address
21
+ * it was given (`@uniweb/build/site`'s `dev-server.js`). `dev` reads that record: it prints the
22
+ * address the moment the server states it, `--status` prints it later, a second `dev` for the
23
+ * site points at the running server rather than starting another on another port, and `--stop`
24
+ * asks it to stop, which it does with exit 0. ⛔ Until 2026-10-25 a caller had to pick a port to
25
+ * know the address, and `--port` was read as the site's name.
15
26
  *
16
27
  * Resolution order for which site to launch:
17
28
  * 1. --site <name> (if passed)
@@ -48,6 +59,7 @@ import { readWorkspaceConfig } from '../utils/config.js'
48
59
  import { discoverSites } from '../utils/discover.js'
49
60
  import { findWorkspaceRoot } from '../utils/workspace.js'
50
61
  import { readFlagValue } from '../utils/args.js'
62
+ import { checkFlags } from '../utils/flag-guard.js'
51
63
  import {
52
64
  checkSiteInstall,
53
65
  readDeclaredFoundation
@@ -55,12 +67,62 @@ import {
55
67
  import { discoverFoundations } from '../utils/discover.js'
56
68
 
57
69
  const RED = '\x1b[31m'
70
+ const GREEN = '\x1b[32m'
58
71
  const YELLOW = '\x1b[33m'
59
72
  const DIM = '\x1b[2m'
60
73
  const CYAN = '\x1b[36m'
61
74
  const RESET = '\x1b[0m'
62
75
 
76
+ /** The flags `dev` reads a value after — none of which is ever the site's name. */
77
+ const VALUE_FLAGS = ['--site', '--port', '--org']
78
+
79
+ /**
80
+ * What `uniweb dev` was asked: the site, by `--site` or a bare name; the port, if one; and
81
+ * whether to report on the running server or stop it rather than start one.
82
+ *
83
+ * @param {string[]} args
84
+ * @returns {{ site: string|null, port: number|null, status: boolean, stop: boolean, error?: string }}
85
+ */
86
+ export function readDevArgs(args) {
87
+ let positional = null
88
+ for (let i = 0; i < args.length && positional === null; i++) {
89
+ if (args[i] === '--') break
90
+ if (VALUE_FLAGS.includes(args[i])) i++
91
+ else if (!args[i].startsWith('-')) positional = args[i]
92
+ }
93
+ const siteFlag = readFlagValue(args, '--site')
94
+ const wanted = {
95
+ site: typeof siteFlag === 'string' ? siteFlag : positional,
96
+ port: null,
97
+ status: args.includes('--status'),
98
+ stop: args.includes('--stop')
99
+ }
100
+ const port = readFlagValue(args, '--port')
101
+ if (port !== undefined) {
102
+ const n = Number(port)
103
+ if (port === null || !Number.isInteger(n) || n < 1 || n > 65535) {
104
+ return { ...wanted, error: '`--port` takes a port number, 1–65535: uniweb dev --port 5199' }
105
+ }
106
+ wanted.port = n
107
+ }
108
+ if (wanted.status && wanted.stop) {
109
+ return { ...wanted, error: '`--status` and `--stop` ask for different things; pass one.' }
110
+ }
111
+ return wanted
112
+ }
113
+
63
114
  export async function dev(args = []) {
115
+ const badFlag = checkFlags('dev', args)
116
+ if (badFlag) {
117
+ console.error(`${RED}✗${RESET} ${badFlag.message}`)
118
+ process.exit(2)
119
+ }
120
+ const wanted = readDevArgs(args)
121
+ if (wanted.error) {
122
+ console.error(`${RED}✗${RESET} ${wanted.error}`)
123
+ process.exit(2)
124
+ }
125
+
64
126
  const cwd = process.cwd()
65
127
  const rootDir = findWorkspaceRoot(cwd) || cwd
66
128
 
@@ -90,10 +152,7 @@ export async function dev(args = []) {
90
152
  }
91
153
 
92
154
  // Pick the site
93
- const siteFlag = readFlagValue(args, '--site')
94
- const positional = args.find((a) => !a.startsWith('-'))
95
- const requested =
96
- (typeof siteFlag === 'string' ? siteFlag : null) || positional || null
155
+ const requested = wanted.site
97
156
 
98
157
  let site
99
158
  if (requested) {
@@ -116,6 +175,42 @@ export async function dev(args = []) {
116
175
  console.error(` Available: ${sites.map((s) => s.name).join(', ')}`)
117
176
  console.error('')
118
177
  }
178
+ const sitePath = join(rootDir, site.path)
179
+
180
+ const { runningDevServer, stopDevServer, DEV_PORT_ENV, DEV_RECORD_ENV } = await import('@uniweb/build/site')
181
+ if (wanted.status) {
182
+ const running = await runningDevServer(sitePath)
183
+ if (!running) {
184
+ console.error(`No dev server started by \`uniweb dev\` is running for ${site.name}.`)
185
+ process.exit(1)
186
+ }
187
+ console.log(running.url)
188
+ return
189
+ }
190
+ if (wanted.stop) {
191
+ const result = await stopDevServer(sitePath)
192
+ if (result.none) console.error(`No dev server started by \`uniweb dev\` is running for ${site.name}.`)
193
+ else if (result.stuck) {
194
+ console.error(`${RED}✗${RESET} The dev server for ${site.name} (pid ${result.stuck.pid}) did not stop.`)
195
+ console.error(` Stop it with: kill -9 ${result.stuck.pid}`)
196
+ process.exit(1)
197
+ } else console.error(`${GREEN}✓${RESET} Stopped the dev server for ${site.name} (${result.stopped.url}).`)
198
+ return
199
+ }
200
+
201
+ // One dev server per site: a second `dev` points at the running one rather than start another
202
+ // on the next free port.
203
+ const running = await runningDevServer(sitePath)
204
+ if (running) {
205
+ if (wanted.port !== null && wanted.port !== running.port) {
206
+ console.error(`${RED}✗${RESET} ${site.name} is already being served at ${running.url}, not on port ${wanted.port}.`)
207
+ console.error(' Stop it first: uniweb dev --stop')
208
+ process.exit(1)
209
+ }
210
+ console.error(`${GREEN}✓${RESET} ${site.name} is already being served at ${CYAN}${running.url}${RESET} (pid ${running.pid}).`)
211
+ console.error(' Stop it with: uniweb dev --stop')
212
+ return
213
+ }
119
214
 
120
215
  // A `file:` foundation dependency can be satisfied by a link or by a copy,
121
216
  // and a copy is a snapshot: the dev server serves it while builds read the
@@ -127,7 +222,6 @@ export async function dev(args = []) {
127
222
  const pm = detectPackageManager()
128
223
  const command = filterCmd(pm, site.name, 'dev')
129
224
  const [bin, ...rest] = command.split(' ')
130
- const sitePath = join(rootDir, site.path)
131
225
 
132
226
  // ⭐ A SITE WHOSE FOUNDATION IS A CATALOG REF — a clone — is previewed with what the backend
133
227
  // it is on says: where that version is served. Asked here, for this dev server alone, and
@@ -140,14 +234,18 @@ export async function dev(args = []) {
140
234
  for (const line of asked.refused.slice(1)) console.error(` ${line}`)
141
235
  process.exit(1)
142
236
  }
143
- let env = process.env
237
+ // The server this starts keeps the record the next `dev`, `--status` and `--stop` read; one
238
+ // started any other way keeps none (`dev-server.js`).
239
+ let env = { ...process.env, [DEV_RECORD_ENV]: '1' }
144
240
  if (asked?.preview) {
145
241
  const { PREVIEW_ENV, encodePreview } = await import('@uniweb/build/site')
146
- env = { ...process.env, [PREVIEW_ENV]: encodePreview(asked.preview) }
242
+ env = { ...env, [PREVIEW_ENV]: encodePreview(asked.preview) }
147
243
  console.error(
148
244
  `${DIM}→ Previewing with ${asked.preview.foundation.ref}, served by ${asked.preview.backend}${RESET}`
149
245
  )
150
246
  }
247
+ // That port or none: the dev server refuses to move past it (`dev-server.js`).
248
+ if (wanted.port !== null) env = { ...env, [DEV_PORT_ENV]: String(wanted.port) }
151
249
 
152
250
  await ensureSiteExtensionsBuilt(sitePath)
153
251
 
@@ -156,12 +254,33 @@ export async function dev(args = []) {
156
254
  )
157
255
  console.error('')
158
256
 
257
+ const startedAt = Date.now()
159
258
  const child = spawn(bin, rest, { cwd: rootDir, stdio: 'inherit', env })
160
259
  child.on('close', (code) => process.exit(code ?? 0))
161
260
  child.on('error', (err) => {
162
261
  console.error(`${RED}✗${RESET} Failed to start dev server: ${err.message}`)
163
262
  process.exit(1)
164
263
  })
264
+ // A line of help, never a reason for the server to stop.
265
+ announceWhenListening({ site, sitePath, startedAt, child, runningDevServer }).catch(() => {})
266
+ }
267
+
268
+ /**
269
+ * Say where the dev server is once it states it: one line, the same every time, for whoever
270
+ * started it to read. Waits on the server's own record, written when it listens, so the
271
+ * address is the one it was given, not one this derived from a port.
272
+ */
273
+ async function announceWhenListening({ site, sitePath, startedAt, child, runningDevServer }) {
274
+ let exited = false
275
+ child.once('close', () => { exited = true })
276
+ for (const until = Date.now() + 120_000; !exited && Date.now() < until; ) {
277
+ const running = await runningDevServer(sitePath, { timeout: 1000 })
278
+ if (running && Date.parse(running.startedAt) >= startedAt) {
279
+ console.error(`${GREEN}✓${RESET} Serving ${site.name} at ${CYAN}${running.url}${RESET}`)
280
+ return
281
+ }
282
+ await new Promise((resolve) => setTimeout(resolve, 250))
283
+ }
165
284
  }
166
285
 
167
286
  /**
@@ -19,7 +19,7 @@ import {
19
19
  extensionNameOf,
20
20
  findSiteExtension
21
21
  } from '@uniweb/build'
22
- import { loadDeployYml, AGENTS_KEYS, originOf } from '@uniweb/build/site'
22
+ import { loadDeployYml, AGENTS_KEYS, originOf, refuseUnreadableAddress } from '@uniweb/build/site'
23
23
  import { listAdapters } from '@uniweb/build/hosts'
24
24
  import { RUNTIME_KEYS } from '@uniweb/build/uwx'
25
25
  import { getCliVersion } from '../versions.js'
@@ -30,6 +30,7 @@ import { discoverFoundations, discoverSites } from '../utils/discover.js'
30
30
  import { checkSiteInstall } from '../utils/install-integrity.js'
31
31
  import { editWorkspaceFilePackages } from '../utils/workspace-packages.js'
32
32
  import { findWorkspaceRoot } from '../utils/workspace.js'
33
+ import { ignoredByRepo } from '../utils/git.js'
33
34
  import { DATA_DIR } from '@uniweb/core/data-paths'
34
35
  import { FAMILIES, resolveFamily } from '@uniweb/schemas/families'
35
36
  import { suggestFamily } from '@uniweb/schemas/family-aliases'
@@ -1008,6 +1009,79 @@ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issue
1008
1009
  )
1009
1010
  }
1010
1011
 
1012
+ /** The host a site's default deploy target names (`deploy.yml`), or null. */
1013
+ function defaultDeployHost(deployYml) {
1014
+ const targets = deployYml?.targets || {}
1015
+ const names = Object.keys(targets)
1016
+ const name = deployYml?.default || (names.length === 1 ? names[0] : null)
1017
+ return name ? targets[name]?.host || name : null
1018
+ }
1019
+
1020
+ /** Hosts that state, at build, where they serve a site — so it needs no `seo.origin` of its own. */
1021
+ const ADDRESS_STATING_HOSTS = new Set(['uniweb', 'netlify', 'vercel'])
1022
+
1023
+ /**
1024
+ * Where the site lives — what every absolute URL it states is made on: a page's canonical and
1025
+ * `og:url`, its `sitemap.xml`, its agent index's links (`@uniweb/build`'s `site/site-address.js`).
1026
+ * A host may state it: Netlify and Vercel at build, a Uniweb host when it serves the site, GitHub
1027
+ * Pages through the workflow `uniweb add ci` writes. Otherwise `seo.origin` does. ⛔ Until
1028
+ * 2026-10-25 this asked for `seo.baseUrl` — which, once set, gave every page the home page's
1029
+ * canonical — and named only the agent index.
1030
+ */
1031
+ export async function checkSiteAddress({ sitePath, siteName, siteYml, workspaceDir, issues }) {
1032
+ try {
1033
+ refuseUnreadableAddress(siteYml, 'site.yml')
1034
+ } catch (err) {
1035
+ const id = 'site-address-config'
1036
+ const message = err.message.replace(/^\[uniweb\] /, '')
1037
+ issues.push({ id, type: 'error', site: siteName, message })
1038
+ error(`[${id}] ${message}`)
1039
+ return
1040
+ }
1041
+ if (siteYml?.seo?.origin) return
1042
+
1043
+ let deployYml = null
1044
+ try {
1045
+ deployYml = await loadDeployYml(sitePath)
1046
+ } catch {
1047
+ // malformed deploy.yml is reported by its own check; treat as absent here
1048
+ }
1049
+ const host = defaultDeployHost(deployYml)
1050
+ if (ADDRESS_STATING_HOSTS.has(host)) return
1051
+
1052
+ const without = 'Without one, no page states a canonical URL, no sitemap.xml is written, and llms.txt links root-relative.'
1053
+ if (host === 'github-pages') {
1054
+ const workflowPath = join(workspaceDir, '.github', 'workflows', 'deploy-github-pages.yml')
1055
+ const workflow = existsSync(workflowPath) ? readFileSync(workflowPath, 'utf8') : null
1056
+ if (workflow?.includes('UNIWEB_ORIGIN')) return
1057
+ if (workflow) {
1058
+ const id = 'site-address-from-github'
1059
+ issues.push({
1060
+ id,
1061
+ type: 'warning',
1062
+ site: siteName,
1063
+ message: "The GitHub Pages workflow does not hand the build the address GitHub serves the site at"
1064
+ })
1065
+ warn(`[${id}] GitHub Pages states where it serves this site, but the workflow predates reading it`)
1066
+ log(` Regenerate it: ${colors.green}uniweb add ci --host=github-pages --force${colors.reset} — or set ${colors.green}seo.origin${colors.reset} in site.yml`)
1067
+ log(` ${colors.dim}${without}${colors.reset}`)
1068
+ return
1069
+ }
1070
+ }
1071
+
1072
+ const id = 'site-address-unknown'
1073
+ issues.push({
1074
+ id,
1075
+ type: 'warning',
1076
+ site: siteName,
1077
+ message: 'The site states no address: no canonical URLs, no sitemap.xml, root-relative llms.txt'
1078
+ })
1079
+ warn(`[${id}] Nothing states where this site is served`)
1080
+ log(` Set ${colors.green}seo.origin${colors.reset} in site.yml — the origin it is served from, e.g. https://example.com`)
1081
+ log(` ${colors.dim}A host that states it needs none: the GitHub Pages workflow \`uniweb add ci\` writes, Netlify, Vercel.${colors.reset}`)
1082
+ log(` ${colors.dim}${without}${colors.reset}`)
1083
+ }
1084
+
1011
1085
  /** The bare map in `queries.yml`, or `{}` when there is none. */
1012
1086
  function readQueriesYml(sitePath) {
1013
1087
  try {
@@ -1018,7 +1092,7 @@ function readQueriesYml(sitePath) {
1018
1092
  }
1019
1093
  }
1020
1094
 
1021
- function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix, fixed }) {
1095
+ export function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix, fixed }) {
1022
1096
  const dataDir = join(sitePath, 'public', DATA_DIR)
1023
1097
  // ⚠️ THE QUERIES, not the pool. `public/<DATA_DIR>/x.json` is a query's
1024
1098
  // MATERIALIZATION — one file per named query — so the bijection is with the
@@ -1074,15 +1148,24 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
1074
1148
 
1075
1149
  // The ignore rule. Checked whether or not the directory exists yet — the
1076
1150
  // point is to have it in place *before* the first build writes there.
1151
+ //
1152
+ // ⭐ ASKED OF GIT when a repository holds the site (`ignoredByRepo`): git reads every
1153
+ // rule, a workspace's root `.gitignore` naming `site/public/data/` among them, and only
1154
+ // it can say whether the build's files would be committed. Outside a repository nothing
1155
+ // is committed yet, and the site's own `.gitignore` is read for the rule `--fix` writes.
1156
+ // ⛔ Until 2026-10-25 that file was read in a repository too, so a rule in the root
1157
+ // `.gitignore` was reported as missing.
1077
1158
  const gitignorePath = join(sitePath, '.gitignore')
1078
1159
  const rule = `public/${DATA_DIR}/`
1079
1160
  const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : null
1080
1161
  const covered =
1081
- existing !== null &&
1082
- existing
1083
- .split('\n')
1084
- .map((line) => line.trim().replace(/\/$/, ''))
1085
- .includes(rule.replace(/\/$/, ''))
1162
+ // Any file the build writes there: `x.json` is a query's list.
1163
+ ignoredByRepo(sitePath, `${rule}x.json`) ??
1164
+ (existing !== null &&
1165
+ existing
1166
+ .split('\n')
1167
+ .map((line) => line.trim().replace(/\/$/, ''))
1168
+ .includes(rule.replace(/\/$/, '')))
1086
1169
 
1087
1170
  if (!covered) {
1088
1171
  const id = 'generated-data-not-ignored'
@@ -1092,7 +1175,7 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
1092
1175
  site: siteName,
1093
1176
  message: `public/${DATA_DIR}/ is generated but not gitignored`
1094
1177
  })
1095
- warn(`[${id}] public/${DATA_DIR}/ is build output but not in .gitignore`)
1178
+ warn(`[${id}] public/${DATA_DIR}/ is build output but not gitignored`)
1096
1179
  log(` Committing it means a diff on every build, and it invites editing what the build overwrites.`)
1097
1180
  if (shouldFix(id)) {
1098
1181
  const body = existing === null ? '' : existing.replace(/\n*$/, '\n')
@@ -1503,30 +1586,7 @@ export async function doctor(args = []) {
1503
1586
  continue
1504
1587
  }
1505
1588
 
1506
- // The agent index ships by default, but it can only carry absolute links
1507
- // when the site declares where it lives. Root-relative links still work
1508
- // for an agent that arrived via the index, so this warns rather than
1509
- // suppressing the artifact the way sitemap.xml does — a silently absent
1510
- // index is the exact failure the projections exist to prevent.
1511
- const agentsConfig = siteYml.agents
1512
- const indexEnabled = agentsConfig !== false && agentsConfig?.index !== false
1513
- if (indexEnabled && !siteYml.seo?.baseUrl) {
1514
- const issue = {
1515
- id: 'agents-index-relative-links',
1516
- type: 'warning',
1517
- site: siteName,
1518
- message:
1519
- 'llms.txt will use root-relative links because seo.baseUrl is unset'
1520
- }
1521
- issues.push(issue)
1522
- warn(`[agents-index-relative-links] llms.txt links will be root-relative`)
1523
- log(
1524
- ` Set ${colors.green}seo.baseUrl${colors.reset} in site.yml so agents get absolute URLs`
1525
- )
1526
- log(
1527
- ` (this also turns on sitemap.xml and robots.txt, which are skipped without it)`
1528
- )
1529
- }
1589
+ await checkSiteAddress({ sitePath, siteName, siteYml, workspaceDir, issues })
1530
1590
 
1531
1591
  // `public/<DATA_DIR>/` is the build's output directory and nothing else —
1532
1592
  // `records/` is the only supported way to provide structured data. That makes the mapping a bijection: every entry there should be backed
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-24T19:34:01.778Z",
3
+ "generatedAt": "2026-10-25T15:30:53.604Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.8.5"
9
+ "version": "1.9.0"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.6"
@@ -15,7 +15,7 @@
15
15
  "version": "1.3.0"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.4.4"
18
+ "version": "1.4.5"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
@@ -24,7 +24,7 @@
24
24
  "version": "1.0.6"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.6.2"
27
+ "version": "1.6.4"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.2"
@@ -33,10 +33,10 @@
33
33
  "version": "1.1.4"
34
34
  },
35
35
  "@uniweb/projections": {
36
- "version": "1.1.7"
36
+ "version": "1.2.0"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.1.19"
39
+ "version": "1.2.0"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
@@ -60,7 +60,7 @@
60
60
  "version": "1.2.1"
61
61
  },
62
62
  "@uniweb/unipress": {
63
- "version": "1.1.14"
63
+ "version": "1.1.15"
64
64
  }
65
65
  },
66
66
  "templates": {
package/src/index.js CHANGED
@@ -1537,6 +1537,15 @@ ${colors.bright}Usage:${colors.reset}
1537
1537
  uniweb dev Start dev server for the (single) site
1538
1538
  uniweb dev <site> Start dev server for a specific site
1539
1539
  uniweb dev --site <name> Same, with explicit flag form
1540
+ uniweb dev --port <n> On that port, or not at all
1541
+ uniweb dev --status Print the running dev server's address
1542
+ uniweb dev --stop Stop the running dev server
1543
+
1544
+ The dev server takes a free port and says where it is: once it listens,
1545
+ \`dev\` prints \`Serving <site> at <url>\`. Don't assume a port: another
1546
+ project's server may be on the one you would guess. While one is running,
1547
+ \`uniweb dev\` points at it rather than starting another. \`--status\` and
1548
+ \`--stop\` know the server \`uniweb dev\` started, not one started with \`vite\`.
1540
1549
 
1541
1550
  Thin wrapper around the package manager's workspace-filtered \`dev\`
1542
1551
  script (\`pnpm --filter <site> dev\` or \`npm -w <site> run dev\`). Picks
@@ -130,7 +130,16 @@ const VERBS = {
130
130
  refresh: [
131
131
  '--no-backend', '--no-git',
132
132
  '--org', '--personal', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
133
- ]
133
+ ],
134
+ /**
135
+ * `dev` = start a site's dev server, or report on (`--status`) or stop (`--stop`) the
136
+ * running one, on that port or none (`--port`). It reaches a backend only for a site whose
137
+ * foundation is a catalog ref, to ask where that version is served, and that asks in the
138
+ * workspace `--org` / `--personal` name (`backend/site-preview.js` — imported lazily, so
139
+ * `flag-guard-coverage.test.js`'s walk does not see it). ⛔ Until 2026-10-25 `dev` checked
140
+ * no flags, and read the value of `--port 5199` as the name of a site.
141
+ */
142
+ dev: ['--site', '--port', '--status', '--stop', '--org', '--personal']
134
143
  }
135
144
 
136
145
  /**
package/src/utils/git.js CHANGED
@@ -102,6 +102,26 @@ export function isGitRepo(dir) {
102
102
  return Boolean(repoOf(dir))
103
103
  }
104
104
 
105
+ /**
106
+ * Would the repository that holds `dir` ignore `path` (relative to `dir`)? True or false —
107
+ * or null when no repository holds `dir` (`repoOf`) or git cannot say.
108
+ *
109
+ * Asked of git, never read from a `.gitignore`: the rule may sit in a parent's file (a
110
+ * workspace root naming `site/public/data/`), be spelled with or without its slashes, or
111
+ * come from `.git/info/exclude`, and git reads them all. `--no-index` asks about the rules
112
+ * alone, so a file committed despite them does not read as unignored, and `path` need not
113
+ * exist.
114
+ */
115
+ export function ignoredByRepo(dir, path) {
116
+ if (!repoOf(dir)) return null
117
+ try {
118
+ git(['check-ignore', '-q', '--no-index', path], dir)
119
+ return true
120
+ } catch (err) {
121
+ return err?.status === 1 ? false : null
122
+ }
123
+ }
124
+
105
125
  /** Does this repo have a remote configured to pull from? */
106
126
  export function hasRemote(dir) {
107
127
  try {