uniweb 1.18.3 → 1.19.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 +6 -6
- package/partials/agents.md +19 -8
- package/src/commands/dev.js +126 -7
- package/src/commands/doctor.js +92 -32
- package/src/framework-index.json +7 -7
- package/src/index.js +9 -0
- package/src/utils/flag-guard.js +10 -1
- package/src/utils/git.js +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/core": "~1.4.4",
|
|
45
|
-
"@uniweb/semantic-parser": "~1.13.0",
|
|
46
44
|
"@uniweb/content-writer": "~1.3.0",
|
|
45
|
+
"@uniweb/core": "~1.4.6",
|
|
46
|
+
"@uniweb/semantic-parser": "~1.13.0",
|
|
47
47
|
"@uniweb/schemas": "~1.6.5"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "~1.
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
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": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
|
@@ -656,6 +660,9 @@ background: primary-900 # Palette token (bare n
|
|
|
656
660
|
background: # Object form for more control
|
|
657
661
|
image: { src: /img.jpg, position: center top }
|
|
658
662
|
overlay: { enabled: true, type: dark, opacity: 0.5 }
|
|
663
|
+
|
|
664
|
+
background:
|
|
665
|
+
video: { src: /videos/hero.mp4, poster: /images/hero.jpg } # the poster shows until the video plays
|
|
659
666
|
```
|
|
660
667
|
|
|
661
668
|
Components that render their own background declare `background: 'self'` in `meta.js`, and the runtime then draws none; to draw the author's background themselves they render kit's `<SectionBackground block={block} />`.
|
|
@@ -717,21 +724,24 @@ end the list with `...`.**
|
|
|
717
724
|
|
|
718
725
|
**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
726
|
|
|
720
|
-
**SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps).
|
|
727
|
+
**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
728
|
|
|
722
729
|
```yaml
|
|
723
730
|
# site.yml
|
|
724
731
|
keywords: [components, react, cms]
|
|
725
732
|
seo:
|
|
733
|
+
origin: https://acme.com # where the site is served from — only where no host states it
|
|
726
734
|
image: /og-default.png
|
|
727
735
|
noindex: false # true keeps the WHOLE site out of search
|
|
728
736
|
|
|
729
737
|
# page.yml
|
|
730
738
|
seo:
|
|
731
739
|
image: /og-about.png
|
|
732
|
-
canonical: https://acme.com/
|
|
740
|
+
canonical: https://acme.com/team # only to name ANOTHER URL — a page is its own canonical by default
|
|
733
741
|
```
|
|
734
742
|
|
|
743
|
+
**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.
|
|
744
|
+
|
|
735
745
|
**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
746
|
|
|
737
747
|
**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 +845,7 @@ Server-to-server callers are authenticated by other means, arranged with your ho
|
|
|
835
845
|
|
|
836
846
|
⚠️ 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
847
|
|
|
838
|
-
**
|
|
848
|
+
**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
849
|
|
|
840
850
|
**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
851
|
|
|
@@ -1174,7 +1184,7 @@ Header.className = 'p-0'
|
|
|
1174
1184
|
Header.as = 'header'
|
|
1175
1185
|
```
|
|
1176
1186
|
|
|
1177
|
-
**A header that stays in view is a setting, not a class.** `sticky top-0` on your component's
|
|
1187
|
+
**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
1188
|
|
|
1179
1189
|
### meta.js
|
|
1180
1190
|
|
|
@@ -1867,7 +1877,7 @@ const page = website.activePage
|
|
|
1867
1877
|
| `block.themeName` | `"light"`, `"medium"`, `"dark"` |
|
|
1868
1878
|
| `block.stableId` / `block.key` | Stable ID from filename or `id:` / unique key across pages — use as React key |
|
|
1869
1879
|
| `block.path` | Page route this block belongs to |
|
|
1870
|
-
| `block.dataLoading` | True while declared data is still resolving |
|
|
1880
|
+
| `block.dataLoading` | True while declared data is still resolving — also on a pre-rendered page, for data the browser fetches, so the loading state is what that page first shows |
|
|
1871
1881
|
| `block.dataError` | `{ <key>: message }` when the fetch filling a declared key FAILED, else `null`. A failed key is `null` in `content.data` — never `[]`, which means "no records" |
|
|
1872
1882
|
|
|
1873
1883
|
```jsx
|
|
@@ -2793,7 +2803,8 @@ them, and any other in full; it matches a name exactly. State is in memory; rest
|
|
|
2793
2803
|
## Part 5 — Commands, shipping, and migration
|
|
2794
2804
|
|
|
2795
2805
|
```bash
|
|
2796
|
-
uniweb dev # Start dev server (picks the site for you)
|
|
2806
|
+
uniweb dev # Start dev server (picks the site for you; prints its address)
|
|
2807
|
+
uniweb dev --status / --stop # The running dev server's address / stop it
|
|
2797
2808
|
pnpm build / pnpm preview # Build for production / preview the build (SSG + SPA)
|
|
2798
2809
|
|
|
2799
2810
|
uniweb deploy # Wizard: pick a destination, then deploy (or set up CI)
|
|
@@ -3099,7 +3110,7 @@ Most Uniweb failures are **silent** — the build succeeds and the page is wrong
|
|
|
3099
3110
|
|
|
3100
3111
|
**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
3112
|
|
|
3102
|
-
**A sticky header scrolls away** — it is `sticky top-0` on the component's
|
|
3113
|
+
**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
3114
|
|
|
3104
3115
|
Loud failures:
|
|
3105
3116
|
|
package/src/commands/dev.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 = { ...
|
|
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
|
/**
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-25T16:50:54.453Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "1.6.4"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.
|
|
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.
|
|
18
|
+
"version": "1.4.6"
|
|
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.
|
|
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.
|
|
36
|
+
"version": "1.2.0"
|
|
37
37
|
},
|
|
38
38
|
"@uniweb/runtime": {
|
|
39
|
-
"version": "1.1
|
|
39
|
+
"version": "1.2.1"
|
|
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.
|
|
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
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -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 {
|