uniweb 0.12.21 → 0.12.23
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/README.md +29 -0
- package/package.json +7 -7
- package/partials/agents.md +41 -11
- package/partials/config-reference.hbs +1 -2
- package/src/commands/add.js +1 -87
- package/src/commands/build.js +2 -2
- package/src/commands/clone.js +337 -0
- package/src/commands/content.js +199 -0
- package/src/commands/deploy.js +27 -6
- package/src/commands/docs.js +2 -3
- package/src/commands/org.js +66 -0
- package/src/commands/pull.js +238 -0
- package/src/commands/push.js +400 -0
- package/src/commands/register.js +274 -0
- package/src/commands/validate.js +288 -0
- package/src/framework-index.json +11 -10
- package/src/index.js +123 -3
- package/src/templates/processor.js +41 -19
- package/src/utils/config.js +21 -0
- package/src/utils/placement.js +100 -0
- package/src/utils/registry-auth.js +380 -0
- package/src/utils/registry-orgs.js +179 -0
- package/src/utils/scaffold.js +14 -3
- package/src/utils/site-content-refs.js +21 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/site/_gitignore +5 -0
- package/templates/site/package.json.hbs +2 -2
- package/templates/workspace/_gitignore +33 -0
package/README.md
CHANGED
|
@@ -361,6 +361,35 @@ Both paths use the same framework. The difference is who edits content, where it
|
|
|
361
361
|
|
|
362
362
|
---
|
|
363
363
|
|
|
364
|
+
## Keeping a Project in Sync
|
|
365
|
+
|
|
366
|
+
Uniweb projects pin to specific `@uniweb/*` versions in their `package.json` files and ship an `AGENTS.md` stamped with the CLI version that wrote it. As the framework releases, both can drift from what the CLI you're running expects.
|
|
367
|
+
|
|
368
|
+
Two paired verbs cover the drift:
|
|
369
|
+
|
|
370
|
+
- **`uniweb doctor`** — diagnoses only. Flags `@uniweb/*` / `uniweb` declarations that lag the running CLI, plus `AGENTS.md` staleness, plus a small set of safely auto-fixable structural issues.
|
|
371
|
+
- **`uniweb update`** — the mutator. Rewrites `@uniweb/*` + `uniweb` version keys in every workspace `package.json` to the CLI's bundled matrix (deps that are *ahead* are left alone — never downgraded), runs your workspace's package manager, and refreshes `AGENTS.md`. Each file's existing indentation is preserved.
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
npx uniweb@latest update # canonical: pin to the latest release (no install)
|
|
375
|
+
npx uniweb@latest update --yes # same, non-interactive (CI, agents, automation)
|
|
376
|
+
uniweb update --dry-run # preview the plan without touching files
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`update` aligns your project to *the CLI that's running it*. `npx uniweb@latest update` is the "pin to the newest release" path. A project-local copy (`node_modules/.bin/uniweb`) deliberately pins to your committed `uniweb` version — bump it in `package.json` and re-install, or use the npx form, to go further.
|
|
380
|
+
|
|
381
|
+
**Updating the CLI itself is separate**, and it's your package manager's job:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
npm i -g uniweb@latest # npm
|
|
385
|
+
pnpm add -g uniweb@latest # pnpm
|
|
386
|
+
yarn global add uniweb@latest # yarn
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`uniweb update` prints the right command for your install when a newer release exists. It will *not* update the CLI itself — running it twice was never the intent.
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
364
393
|
## Documentation
|
|
365
394
|
|
|
366
395
|
Full documentation is available at **[github.com/uniweb/docs](https://github.com/uniweb/docs)**:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.23",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/core": "0.7.12",
|
|
45
|
+
"@uniweb/kit": "0.9.14",
|
|
46
|
+
"@uniweb/runtime": "0.8.15"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/build": "0.14.
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/build": "0.14.7",
|
|
50
|
+
"@uniweb/semantic-parser": "1.1.17",
|
|
51
|
+
"@uniweb/content-reader": "1.1.12"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -149,10 +149,12 @@ uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, n
|
|
|
149
149
|
uniweb deploy --dry-run # Resolve foundation/runtime + print summary; no writes
|
|
150
150
|
uniweb export # Build dist/ for any static host (no Uniweb account)
|
|
151
151
|
uniweb publish # Publish a foundation to the Uniweb registry
|
|
152
|
+
uniweb register # Register a foundation + the data schemas it defines
|
|
152
153
|
uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
|
|
153
|
-
uniweb
|
|
154
|
-
|
|
155
|
-
# Use --dry-run to preview, --
|
|
154
|
+
uniweb validate # Check your file-based data against your declared schemas (--strict for CI)
|
|
155
|
+
uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix.
|
|
156
|
+
# Use --dry-run to preview, --yes for non-interactive.
|
|
157
|
+
# `npx uniweb@latest update` pins to the latest release.
|
|
156
158
|
|
|
157
159
|
# Help
|
|
158
160
|
uniweb --help # Top-level help
|
|
@@ -161,7 +163,11 @@ uniweb <command> --help # Per-command help (no side effects)
|
|
|
161
163
|
|
|
162
164
|
`uniweb deploy` auto-publishes a workspace-local foundation as part of the deploy under a site-scoped slot — no separate `uniweb publish` step needed for site-bound foundations.
|
|
163
165
|
|
|
164
|
-
|
|
166
|
+
**Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the publish org with `--scope @org` (default: the foundation's `package.json` `uniweb.scope`).
|
|
167
|
+
|
|
168
|
+
**Registering standard or shared schemas (no foundation).** Data schemas can also be registered on their own — without a foundation — straight from a schemas package. Run `uniweb register` from a package that *exports* schemas (`@uniweb/schemas`, or any `@org/schemas` you maintain), or from a bare folder of `schemas/*.{yml,json,js}` files: it detects the schemas-only package automatically and submits just the data schemas (no foundation) under `--scope`. This is how the standard schemas are published under `@std`, and how an org publishes its own shared `@org/schemas` once for many foundations to reference. The same flags apply (`--scope`, `--dry-run`, `-o`, `--token`, `--registry`); `--dry-run` (or `-o <file>`) previews the exact submission without authenticating.
|
|
169
|
+
|
|
170
|
+
**Staying current.** `uniweb update` aligns this project's `@uniweb/*` deps and `AGENTS.md` to the CLI that runs it; `uniweb doctor` reports drift without mutating. To pin to the newest published release, run `npx uniweb@latest update --yes` — no global install needed. The verb won't refresh AGENTS.md while declared deps still lag the CLI, or while edited deps haven't been installed: both would put the doc ahead of the code. Updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`, …); `uniweb update` does not do that.
|
|
165
171
|
|
|
166
172
|
---
|
|
167
173
|
|
|
@@ -276,8 +282,6 @@ The system has multi-layer fallbacks so missing or partial information is always
|
|
|
276
282
|
|
|
277
283
|
Bottom line: a foundation that doesn't set `runtimePolicy` gets `auto-minor` behavior automatically. A foundation that doesn't ship `runtime-pin.json` at all (e.g. a legacy build) still serves correctly through the platform's compatibility path — you just don't get the propagation benefits. Set `runtimePolicy` explicitly only when you want to override the default (typically to `exact` for stability-critical builds).
|
|
278
284
|
|
|
279
|
-
(For platform operators interested in how propagation walks consume this field, see `kb/platform/operations/version-propagation.md` in the private docs.)
|
|
280
|
-
|
|
281
285
|
---
|
|
282
286
|
|
|
283
287
|
## Content Authoring
|
|
@@ -1038,18 +1042,44 @@ All non-reserved frontmatter fields become `params`. Reserved: `type`, `preset`,
|
|
|
1038
1042
|
|
|
1039
1043
|
### Data
|
|
1040
1044
|
|
|
1041
|
-
A component on a page with a `data:` or `fetch:` declaration automatically receives that data in `content.data.{
|
|
1045
|
+
A component on a page with a `data:` or `fetch:` declaration automatically receives that data in `content.data.{key}`. No opt-in required in `meta.js`. **Bound collections always arrive as arrays.** On a list page, `content.data.articles` is the full collection. On a template page (`[slug]/`), the matched record is delivered under the *same* collection key as a single-element array — the detail section reads `content.data.articles[0]`. When nothing matches, the key is `[]`. The runtime never coerces the array to a single object and never synthesizes a separate singular key.
|
|
1042
1046
|
|
|
1043
1047
|
```jsx
|
|
1044
1048
|
function Article({ content, block }) {
|
|
1045
1049
|
if (block.dataLoading) return <DataPlaceholder />
|
|
1046
|
-
const article = content.data.
|
|
1050
|
+
const article = content.data.articles?.[0] // focused record on a [slug] page
|
|
1047
1051
|
if (!article) return <NotFound />
|
|
1048
1052
|
return <ArticleView article={article} />
|
|
1049
1053
|
}
|
|
1050
1054
|
```
|
|
1051
1055
|
|
|
1052
|
-
Components can ignore keys in `content.data` they don't need — the same way unused `params` are ignored.
|
|
1056
|
+
Components can ignore keys in `content.data` they don't need — the same way unused `params` are ignored.
|
|
1057
|
+
|
|
1058
|
+
**Declaring data schemas.** `meta.js` declares the schema for each `content.data` key with a single `data:` field — there is no separate `schemas:` key. Each entry's value is one of: a **named ref** (`'@/article'` resolves to this foundation's `foundation/schemas/article.{js,json,yml}`; `'@std/person'` is a shared standard), an **inline field map** (`{ field: { type, default } }`), or an **inline rich-form** (`{ fields: [...] }`, an editor form). Refs use Uniweb namespacing, resolved on disk at build time, never fetched: `@/name` (this foundation's own `foundation/schemas/`), `@std/name` (the shared standard schemas, shipped in the `@uniweb/schemas` package), and `@org/name` (an org's own schemas, from its `@org/schemas` package — define a schema once and share it across foundations). The schema is a hint: it supplies field defaults and drives the editor, not delivery (which is default-on). For an explicit opt-out (rare), set `data: false`.
|
|
1059
|
+
|
|
1060
|
+
```js
|
|
1061
|
+
// meta.js
|
|
1062
|
+
export default {
|
|
1063
|
+
data: {
|
|
1064
|
+
articles: '@/article', // named ref (this foundation)
|
|
1065
|
+
authors: '@std/person', // named ref (shared standard)
|
|
1066
|
+
pricing: { tier: { type: 'string', default: '' } }, // inline field map
|
|
1067
|
+
},
|
|
1068
|
+
}
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
**Routing a scope to a folder.** By default `@org/name` resolves from that org's `@org/schemas` package. A foundation can instead route a scope to a plain folder of schema files — anywhere on disk, no package, no install — via an optional `schemas.config.js` at its root. This is how a team shares one set of schemas across many foundations:
|
|
1072
|
+
|
|
1073
|
+
```js
|
|
1074
|
+
// schemas.config.js — @acme/person → ../shared/acme-schemas/person.{js,yml}
|
|
1075
|
+
export default { '@acme': '../shared/acme-schemas', '@brand': process.env.BRAND_SCHEMAS }
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
Plain JS, so paths can be relative, absolute, or read from an env var. A routed scope wins over the package convention; `@/` and `@uniweb` are never routable; an empty value (e.g. an unset env var) falls back to the package convention.
|
|
1079
|
+
|
|
1080
|
+
**Validate your data.** `uniweb validate` checks your file-based data against these declared schemas — missing required fields, type/enum/format mismatches, nested fields — before you ship. Warns by default, `--strict` for a non-zero CI exit. It's distinct from `uniweb doctor` (which checks project structure): `validate` checks your *data* against the schemas you *declared*. Remote (`url:`), `ref`/`options`, and rich `sections`-form inputs are reported deferred — validate those against live data.
|
|
1081
|
+
|
|
1082
|
+
When the same record needs to be a single object rather than a one-element array, that's the foundation's job: read `content.data.articles[0]`, or reshape `content.data` once with a `handlers.data` hook.
|
|
1053
1083
|
|
|
1054
1084
|
**Authoring queries.** Fetch declarations (`fetch:` or the `data:` shorthand) accept query operators that describe which records you want, in what order, how many: `where:` (a where-object predicate), `sort:` (e.g. `date desc`), `limit:` (first N records). Whether the source evaluates them or the framework applies them as a runtime fallback is a transport detail controlled by the site's `fetcher.supports:` declaration.
|
|
1055
1085
|
|
|
@@ -1478,7 +1508,7 @@ const page = website.activePage
|
|
|
1478
1508
|
// Returns [{ id, route, navigableRoute, translatedRoute, title, label, description, hasContent, version, children }]
|
|
1479
1509
|
//
|
|
1480
1510
|
// Options:
|
|
1481
|
-
// for: 'header' | 'footer' — filter by nav
|
|
1511
|
+
// for: 'header' | 'footer' | <area> — filter by nav area (respects the page's hideIn)
|
|
1482
1512
|
// nested: true (default) — nested hierarchy with children; false = flat list
|
|
1483
1513
|
// includeHidden: false — include hidden pages
|
|
1484
1514
|
// filter: (page) => bool — custom filter function
|
|
@@ -1490,7 +1520,7 @@ const page = website.activePage
|
|
|
1490
1520
|
// website.getAllPages() — flat list: getPageHierarchy({ nested: false })
|
|
1491
1521
|
//
|
|
1492
1522
|
// Common patterns:
|
|
1493
|
-
website.getPageHierarchy({ for: 'header' }) // Header nav (excludes
|
|
1523
|
+
website.getPageHierarchy({ for: 'header' }) // Header nav (excludes pages with 'header' in hideIn)
|
|
1494
1524
|
website.getPageHierarchy() // Full nested hierarchy (no nav filtering)
|
|
1495
1525
|
website.getPageHierarchy({ nested: false }) // Flat list of all visible pages
|
|
1496
1526
|
website.getPageHierarchy({ nested: false, includeHidden: true }) // Everything including hidden
|
|
@@ -40,8 +40,7 @@ index: intro # Or just name the index
|
|
|
40
40
|
|
|
41
41
|
# Navigation visibility
|
|
42
42
|
hidden: true # Hide from all navigation
|
|
43
|
-
|
|
44
|
-
hideInFooter: true # Hide from footer only
|
|
43
|
+
hideIn: [header] # Hide from named nav areas only (header, footer, sidebar, …)
|
|
45
44
|
|
|
46
45
|
# Layout overrides
|
|
47
46
|
layout:
|
package/src/commands/add.js
CHANGED
|
@@ -18,6 +18,7 @@ import prompts from 'prompts'
|
|
|
18
18
|
import yaml from 'js-yaml'
|
|
19
19
|
import { resolveFoundationSrcPath } from '@uniweb/build'
|
|
20
20
|
import { scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies } from '../utils/scaffold.js'
|
|
21
|
+
import { resolvePlacement, SITE_KIND, FOUNDATION_KIND } from '../utils/placement.js'
|
|
21
22
|
import {
|
|
22
23
|
readWorkspaceConfig,
|
|
23
24
|
addWorkspaceGlob,
|
|
@@ -212,7 +213,6 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
212
213
|
// name (`ui`) or a path (`foundations/ui`); resolvePlacement handles
|
|
213
214
|
// both. Format validation runs on the derived package name below, not
|
|
214
215
|
// on the raw input — slashes in the input are intentional path syntax.
|
|
215
|
-
const FOUNDATION_KIND = { defaultDir: 'src', defaultPkg: 'src', projectSub: 'src' }
|
|
216
216
|
const placement = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
|
|
217
217
|
const { relativePath } = placement
|
|
218
218
|
let { packageName } = placement
|
|
@@ -306,7 +306,6 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
306
306
|
const existingNames = await getExistingPackageNames(rootDir)
|
|
307
307
|
|
|
308
308
|
// Resolve placement first (path + package name); see notes in addFoundation.
|
|
309
|
-
const SITE_KIND = { defaultDir: 'site', defaultPkg: 'site', projectSub: 'site' }
|
|
310
309
|
const placement = resolvePlacement(rootDir, name, opts, SITE_KIND)
|
|
311
310
|
const { relativePath } = placement
|
|
312
311
|
let siteName = placement.packageName
|
|
@@ -651,91 +650,6 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
651
650
|
log(`Next: ${colors.cyan}${installCmd(pm)} && uniweb dev ${sitePkgName}${colors.reset}`)
|
|
652
651
|
}
|
|
653
652
|
|
|
654
|
-
/**
|
|
655
|
-
* Resolve where a foundation or site should be placed, given the user's input.
|
|
656
|
-
*
|
|
657
|
-
* The rule: **the user names a folder, and we create exactly that folder.**
|
|
658
|
-
* No silent nesting under `foundations/` / `sites/`, no inferring layout from
|
|
659
|
-
* pre-existing globs. The framework doesn't require any particular folder
|
|
660
|
-
* structure (the build classifies packages by their contents, not their
|
|
661
|
-
* location), so the CLI shouldn't impose one.
|
|
662
|
-
*
|
|
663
|
-
* Resolution priority (foundation example, same shape for site):
|
|
664
|
-
*
|
|
665
|
-
* 1. `--path <dir>` → explicit folder. Name is the path's
|
|
666
|
-
* last segment (used as the package
|
|
667
|
-
* name unless `name` was also given).
|
|
668
|
-
* 2. `name` contains `/` → treat as a path (e.g., `foundations/ui`).
|
|
669
|
-
* Folder = the path, package name =
|
|
670
|
-
* the last segment.
|
|
671
|
-
* 3. `name` (no slash) → folder = `<name>/`, package name = `<name>`.
|
|
672
|
-
* 4. `--project <project>` → folder = `<project>/<defaultSub>` and
|
|
673
|
-
* package name = `<project>-<defaultSub>`
|
|
674
|
-
* (the co-located convention; only this
|
|
675
|
-
* one uses the `-src` / `-site` suffix).
|
|
676
|
-
* 5. (no input) → folder = `<defaultDir>/`, package name
|
|
677
|
-
* = `<defaultPkg>` (`src/` + `src`
|
|
678
|
-
* for foundations; `site/` + `site` for
|
|
679
|
-
* sites).
|
|
680
|
-
*
|
|
681
|
-
* @param {string} rootDir
|
|
682
|
-
* @param {string|null} name - Either a bare name or a path-with-slash.
|
|
683
|
-
* @param {{ path?: string, project?: string }} opts
|
|
684
|
-
* @param {{ defaultDir: string, defaultPkg: string, projectSub: string }} kind
|
|
685
|
-
* @returns {{ relativePath: string, packageName: string }}
|
|
686
|
-
*/
|
|
687
|
-
function resolvePlacement(rootDir, name, opts, kind) {
|
|
688
|
-
// 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
|
|
689
|
-
// name was given, or `<path>` itself if not (the path's last segment
|
|
690
|
-
// is then taken as the package name).
|
|
691
|
-
if (opts.path) {
|
|
692
|
-
const parent = opts.path.replace(/\/+$/, '')
|
|
693
|
-
if (name) {
|
|
694
|
-
const last = name.split('/').filter(Boolean).pop()
|
|
695
|
-
return {
|
|
696
|
-
relativePath: `${parent}/${name}`.replace(/\/+/g, '/'),
|
|
697
|
-
packageName: last,
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
const lastSegment = parent.split('/').filter(Boolean).pop() || parent
|
|
701
|
-
return {
|
|
702
|
-
relativePath: parent,
|
|
703
|
-
packageName: lastSegment,
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// 2. name contains a slash → treat as a path.
|
|
708
|
-
if (name && name.includes('/')) {
|
|
709
|
-
const relativePath = name.replace(/\/+$/, '')
|
|
710
|
-
const lastSegment = relativePath.split('/').filter(Boolean).pop()
|
|
711
|
-
return {
|
|
712
|
-
relativePath,
|
|
713
|
-
packageName: lastSegment,
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// 3. Bare name.
|
|
718
|
-
if (name) {
|
|
719
|
-
return {
|
|
720
|
-
relativePath: name,
|
|
721
|
-
packageName: name,
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// 4. --project (co-located convention with -src / -site suffix).
|
|
726
|
-
if (opts.project) {
|
|
727
|
-
return {
|
|
728
|
-
relativePath: `${opts.project}/${kind.projectSub}`,
|
|
729
|
-
packageName: `${opts.project}-${kind.projectSub}`,
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// 5. Default placement.
|
|
734
|
-
return {
|
|
735
|
-
relativePath: kind.defaultDir,
|
|
736
|
-
packageName: kind.defaultPkg,
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
653
|
|
|
740
654
|
/**
|
|
741
655
|
* Resolve which foundation to wire a site to
|
package/src/commands/build.js
CHANGED
|
@@ -548,7 +548,7 @@ function resolveFoundationDir(projectDir, siteConfig) {
|
|
|
548
548
|
* and deploy was reading that stripped version, causing the worker to
|
|
549
549
|
* mis-detect split and serve blank pages. Link mode skips prerender
|
|
550
550
|
* entirely; `dist/site-content.json` keeps full sections; the worker
|
|
551
|
-
* splits correctly.
|
|
551
|
+
* splits correctly.
|
|
552
552
|
*/
|
|
553
553
|
async function buildSiteLink(projectDir, options = {}) {
|
|
554
554
|
const { siteConfig = null } = options
|
|
@@ -966,7 +966,7 @@ export async function build(args = []) {
|
|
|
966
966
|
// --host names the host adapter for this build's prerender step.
|
|
967
967
|
// Default = 'cloudflare-pages' (resolved inside prerender.js, via the
|
|
968
968
|
// registry). Build does not read deploy.yml; that is the deploy
|
|
969
|
-
// orchestrator's job.
|
|
969
|
+
// orchestrator's job.
|
|
970
970
|
//
|
|
971
971
|
// `--host` with no value → interactive picker (errors in CI / non-TTY).
|
|
972
972
|
const { readFlagValue } = await import('../utils/args.js')
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb clone <site-uuid> — materialize a backend site as a local file project.
|
|
3
|
+
*
|
|
4
|
+
* The "git clone" of the site-content remote model (see
|
|
5
|
+
* kb/framework/plans/site-content-remote-model.md): the backend is the remote, a
|
|
6
|
+
* file project is a working clone. `clone` is the create-side sibling of
|
|
7
|
+
* `uniweb pull`/`uniweb push` — it bootstraps a brand-new project from a site that
|
|
8
|
+
* already lives in the backend (typically authored in the visual app).
|
|
9
|
+
*
|
|
10
|
+
* What it does, and a key constraint: clone runs from a GLOBAL install before any
|
|
11
|
+
* project exists, so it must NOT statically import `@uniweb/build` (that would crash
|
|
12
|
+
* `npx uniweb clone`, same reason utils/workspace.js loads the classifier lazily).
|
|
13
|
+
* So clone does the minimum itself and delegates the heavy lifting:
|
|
14
|
+
*
|
|
15
|
+
* 1. plain `fetch` GET <origin>/dev/site/content/pull/<uuid> — read the `foundation`
|
|
16
|
+
* ref out of that one document (no `@uniweb/build` needed for a GET);
|
|
17
|
+
* 2. scaffold the HARNESS — a full Vite site package whose foundation is
|
|
18
|
+
* REFERENCED (runtime-loaded), no local foundation sibling (scaffoldSite with
|
|
19
|
+
* foundationRef and no foundationPath) + AGENTS.md + deps pinned to this CLI's
|
|
20
|
+
* version matrix; placement reuses create (new workspace / in-place) and add's
|
|
21
|
+
* resolver (into an existing workspace, any shape);
|
|
22
|
+
* 3. seed the site's one identity — site.yml::$uuid (a plain YAML scalar write).
|
|
23
|
+
* The folder is pulled by this same uuid, so there is no separate folder uuid to
|
|
24
|
+
* seed;
|
|
25
|
+
* 4. install, then delegate the projection to the project-local `uniweb pull` (which
|
|
26
|
+
* resolves the now-installed project-local `@uniweb/build`; clone forwards
|
|
27
|
+
* `--no-collections` to it when set).
|
|
28
|
+
*
|
|
29
|
+
* Sites are private — authenticate with `uniweb login` first; the session carries
|
|
30
|
+
* identity + the backend origin. There is no `--foundation` flag: the site carries
|
|
31
|
+
* its foundation ref and clone honors it verbatim (switching a site's foundation is a
|
|
32
|
+
* deliberate, high-risk operation, never a clone convenience).
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* uniweb login
|
|
36
|
+
* uniweb clone <site-uuid> [name|.] New workspace (or `.` in-place / a site in
|
|
37
|
+
* the current workspace when run inside one)
|
|
38
|
+
* uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
|
|
39
|
+
* uniweb clone <uuid> --project docs Co-located docs/site
|
|
40
|
+
* uniweb clone <uuid> --no-collections Pull pages only; skip collection records
|
|
41
|
+
*
|
|
42
|
+
* Endpoints: <origin>/dev/site/content/pull/<uuid>. Origin from
|
|
43
|
+
* --registry > UNIWEB_REGISTER_URL > the local default (internal dev overrides;
|
|
44
|
+
* not the user-facing path — `uniweb login` determines the origin).
|
|
45
|
+
* Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
49
|
+
import { join, resolve, basename, dirname } from 'node:path'
|
|
50
|
+
import { spawnSync } from 'node:child_process'
|
|
51
|
+
import { scaffoldWorkspace, scaffoldSite } from '../utils/scaffold.js'
|
|
52
|
+
import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
|
|
53
|
+
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
54
|
+
import { addWorkspaceGlob } from '../utils/config.js'
|
|
55
|
+
import { detectWorkspacePm, installCmd } from '../utils/pm.js'
|
|
56
|
+
import { ensureRegistryAuth } from '../utils/registry-auth.js'
|
|
57
|
+
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
58
|
+
import { extractFoundationRef } from '../utils/site-content-refs.js'
|
|
59
|
+
|
|
60
|
+
const DEFAULT_BACKEND_ORIGIN = 'http://localhost:8080'
|
|
61
|
+
|
|
62
|
+
const colors = {
|
|
63
|
+
reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
|
|
64
|
+
red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m', cyan: '\x1b[36m',
|
|
65
|
+
}
|
|
66
|
+
const log = console.log
|
|
67
|
+
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
68
|
+
const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
69
|
+
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
70
|
+
const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
71
|
+
|
|
72
|
+
function flagValue(args, name) {
|
|
73
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
74
|
+
if (eq) return eq.slice(name.length + 1)
|
|
75
|
+
const i = args.indexOf(name)
|
|
76
|
+
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function slugify(s) {
|
|
81
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Tolerant single-entity document extraction (mirrors pull.js; duplicated rather
|
|
85
|
+
// than imported because pull.js statically imports @uniweb/build).
|
|
86
|
+
export function extractDocument(payload) {
|
|
87
|
+
if (!payload || typeof payload !== 'object') return null
|
|
88
|
+
if (payload.$model || payload.$id || payload.info) return payload
|
|
89
|
+
return payload.document || payload.entity || null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Unwrap a possibly-localized scalar (a `{ <locale>: value }` map) to a plain string.
|
|
93
|
+
function unwrapScalar(v) {
|
|
94
|
+
if (typeof v === 'string') return v
|
|
95
|
+
if (v && typeof v === 'object') {
|
|
96
|
+
const first = Object.values(v).find((x) => typeof x === 'string')
|
|
97
|
+
if (first) return first
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the seeds clone needs out of a site-content `$`-document:
|
|
104
|
+
* - foundationRef: the `foundation` ref (a URL or our `@ns/name@ver`) — written
|
|
105
|
+
* verbatim into site.yml so the runtime loads it as a federated module;
|
|
106
|
+
* - name: a display name for the new project.
|
|
107
|
+
*
|
|
108
|
+
* No folder uuid is read: the site holds one identity (its site-content uuid), and
|
|
109
|
+
* the folder is pulled by that same uuid — the framework never holds a folder uuid.
|
|
110
|
+
*/
|
|
111
|
+
export function extractCloneSeeds(document) {
|
|
112
|
+
const info = document?.info || {}
|
|
113
|
+
return {
|
|
114
|
+
foundationRef: extractFoundationRef(info, document),
|
|
115
|
+
name: unwrapScalar(info.name) ?? unwrapScalar(document?.name) ?? null,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Insert/replace a top-level `$uuid:` scalar in a YAML file's text without
|
|
120
|
+
// disturbing the rest (the scaffolded site.yml is comment-heavy — don't round-trip
|
|
121
|
+
// through a YAML dumper). Inserts after the first `name:` line, else prepends.
|
|
122
|
+
function seedYamlUuid(filePath, uuid) {
|
|
123
|
+
let text = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
|
|
124
|
+
if (/^\$uuid:/m.test(text)) {
|
|
125
|
+
text = text.replace(/^\$uuid:.*$/m, `$uuid: ${uuid}`)
|
|
126
|
+
} else {
|
|
127
|
+
const nameMatch = text.match(/^name:.*$/m)
|
|
128
|
+
if (nameMatch) {
|
|
129
|
+
const idx = nameMatch.index + nameMatch[0].length
|
|
130
|
+
text = text.slice(0, idx) + `\n$uuid: ${uuid}` + text.slice(idx)
|
|
131
|
+
} else {
|
|
132
|
+
text = `$uuid: ${uuid}\n` + text
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
136
|
+
writeFileSync(filePath, text)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build the package-manager argv to run the project-local `uniweb pull`.
|
|
140
|
+
function pullExecArgv(pm, extra) {
|
|
141
|
+
// npm needs `exec --` to forward flags to the binary; pnpm/yarn don't.
|
|
142
|
+
if (pm === 'npm') return ['exec', '--', 'uniweb', 'pull', ...extra]
|
|
143
|
+
return ['exec', 'uniweb', 'pull', ...extra]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string[]} args
|
|
148
|
+
* @param {object} [deps] - injectable seams for testing:
|
|
149
|
+
* fetch, getToken, skipInstall, skipPull, runInstall(projectDir, pm),
|
|
150
|
+
* runPull(siteDir, pm, extraArgs).
|
|
151
|
+
*/
|
|
152
|
+
export async function clone(args = [], deps = {}) {
|
|
153
|
+
const fetchImpl = deps.fetch || globalThis.fetch
|
|
154
|
+
|
|
155
|
+
const positionals = args.filter((a) => !a.startsWith('-'))
|
|
156
|
+
const siteUuid = positionals[0]
|
|
157
|
+
const target = positionals[1] || null // [name|.]
|
|
158
|
+
|
|
159
|
+
if (!siteUuid) {
|
|
160
|
+
error('Missing site uuid.')
|
|
161
|
+
log(`\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-collections]`)
|
|
162
|
+
log(`${colors.dim}Sites are private — run \`uniweb login\` first.${colors.reset}`)
|
|
163
|
+
return { exitCode: 2 }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const noCollections = args.includes('--no-collections') || args.includes('--content-only')
|
|
167
|
+
const pathFlag = flagValue(args, '--path')
|
|
168
|
+
const projectFlag = flagValue(args, '--project')
|
|
169
|
+
const tokenFlag = flagValue(args, '--token')
|
|
170
|
+
const registryFlag = flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_BACKEND_ORIGIN
|
|
171
|
+
|
|
172
|
+
let apiBase
|
|
173
|
+
try {
|
|
174
|
+
apiBase = new URL(registryFlag).origin
|
|
175
|
+
} catch {
|
|
176
|
+
error(`Invalid --registry / UNIWEB_REGISTER_URL: ${registryFlag}`)
|
|
177
|
+
return { exitCode: 2 }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let cachedToken = null
|
|
181
|
+
const getToken =
|
|
182
|
+
deps.getToken ||
|
|
183
|
+
(async () => {
|
|
184
|
+
if (cachedToken) return cachedToken
|
|
185
|
+
cachedToken = tokenFlag || process.env.UNIWEB_TOKEN || (await ensureRegistryAuth({ apiBase, command: 'Cloning', args }))
|
|
186
|
+
return cachedToken
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// 1. GET the site-content document (plain fetch — no @uniweb/build).
|
|
190
|
+
const url = `${apiBase}/dev/site/content/pull/${encodeURIComponent(siteUuid)}`
|
|
191
|
+
info(`Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${url}${colors.reset} …`)
|
|
192
|
+
let payload
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${await getToken()}` } })
|
|
195
|
+
if (res.status === 404) {
|
|
196
|
+
error(`Site not found (404) — check the uuid, or you lack access.`)
|
|
197
|
+
return { exitCode: 1 }
|
|
198
|
+
}
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
error(`Could not read the site: HTTP ${res.status} ${res.statusText}`)
|
|
201
|
+
if (res.status === 401 || res.status === 403) note('Run `uniweb login` first (or pass --token <bearer>).')
|
|
202
|
+
return { exitCode: 1 }
|
|
203
|
+
}
|
|
204
|
+
payload = await res.json()
|
|
205
|
+
} catch (err) {
|
|
206
|
+
error(`Could not reach the backend at ${url}: ${err.message}`)
|
|
207
|
+
return { exitCode: 1 }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const document = extractDocument(payload)
|
|
211
|
+
if (!document) {
|
|
212
|
+
error('The site-content response carried no recognizable document.')
|
|
213
|
+
return { exitCode: 1 }
|
|
214
|
+
}
|
|
215
|
+
const { foundationRef, name: siteDisplayName } = extractCloneSeeds(document)
|
|
216
|
+
if (!foundationRef) {
|
|
217
|
+
note('! The pulled site declares no foundation ref — set `foundation:` in site.yml after clone.')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 2. Resolve placement (one verb, context-aware).
|
|
221
|
+
const cwd = deps.cwd || process.cwd()
|
|
222
|
+
const inPlace = target === '.'
|
|
223
|
+
const existingRoot = inPlace ? null : findWorkspaceRoot(cwd)
|
|
224
|
+
|
|
225
|
+
let projectDir // the package-manager root (for install)
|
|
226
|
+
let siteDir // where the site package lands
|
|
227
|
+
let sitePkgName
|
|
228
|
+
let workspaceName
|
|
229
|
+
let isNewWorkspace
|
|
230
|
+
let placement = null
|
|
231
|
+
|
|
232
|
+
if (inPlace) {
|
|
233
|
+
isNewWorkspace = true
|
|
234
|
+
projectDir = cwd
|
|
235
|
+
workspaceName = slugify(basename(cwd)) || 'site'
|
|
236
|
+
siteDir = join(projectDir, 'site')
|
|
237
|
+
sitePkgName = 'site'
|
|
238
|
+
} else if (existingRoot) {
|
|
239
|
+
isNewWorkspace = false
|
|
240
|
+
projectDir = existingRoot
|
|
241
|
+
placement = resolvePlacement(existingRoot, target, { path: pathFlag, project: projectFlag }, SITE_KIND)
|
|
242
|
+
siteDir = join(existingRoot, placement.relativePath)
|
|
243
|
+
sitePkgName = placement.packageName
|
|
244
|
+
workspaceName = sitePkgName
|
|
245
|
+
} else {
|
|
246
|
+
isNewWorkspace = true
|
|
247
|
+
workspaceName = target || slugify(siteDisplayName) || null
|
|
248
|
+
if (!workspaceName) {
|
|
249
|
+
error('Could not derive a project name from the site. Pass one: `uniweb clone <uuid> <name>`.')
|
|
250
|
+
return { exitCode: 2 }
|
|
251
|
+
}
|
|
252
|
+
if (!/^[a-z0-9-]+$/.test(workspaceName)) {
|
|
253
|
+
error(`Invalid project name "${workspaceName}" — use lowercase letters, numbers, and hyphens.`)
|
|
254
|
+
return { exitCode: 2 }
|
|
255
|
+
}
|
|
256
|
+
projectDir = resolve(cwd, workspaceName)
|
|
257
|
+
siteDir = join(projectDir, 'site')
|
|
258
|
+
sitePkgName = 'site'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Conflict guards.
|
|
262
|
+
if (isNewWorkspace && !inPlace && existsSync(projectDir)) {
|
|
263
|
+
error(`Directory already exists: ${workspaceName}`)
|
|
264
|
+
return { exitCode: 1 }
|
|
265
|
+
}
|
|
266
|
+
if (existsSync(join(siteDir, 'site.yml'))) {
|
|
267
|
+
error(`A site already exists at ${siteDir} — refusing to overwrite.`)
|
|
268
|
+
return { exitCode: 1 }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 3. Scaffold the harness (ref-only site: foundationRef, no foundationPath).
|
|
272
|
+
const onProgress = (m) => note(m)
|
|
273
|
+
const siteContext = { name: sitePkgName, projectName: workspaceName, ...(foundationRef ? { foundationRef } : {}) }
|
|
274
|
+
|
|
275
|
+
if (isNewWorkspace) {
|
|
276
|
+
info(`Scaffolding ${colors.bright}${workspaceName}${colors.reset} …`)
|
|
277
|
+
await scaffoldWorkspace(
|
|
278
|
+
projectDir,
|
|
279
|
+
{ projectName: workspaceName, workspaceGlobs: ['site'], scripts: { dev: 'uniweb dev', build: 'uniweb build' } },
|
|
280
|
+
{ onProgress },
|
|
281
|
+
)
|
|
282
|
+
await scaffoldSite(siteDir, siteContext, { onProgress })
|
|
283
|
+
} else {
|
|
284
|
+
info(`Adding site ${colors.bright}${sitePkgName}${colors.reset} to the workspace at ${colors.dim}${placement.relativePath}/${colors.reset} …`)
|
|
285
|
+
await scaffoldSite(siteDir, siteContext, { onProgress })
|
|
286
|
+
await addWorkspaceGlob(existingRoot, placement.relativePath)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 4. Seed the site's one identity — site.yml::$uuid. The folder is pulled by this
|
|
290
|
+
// same uuid (the backend resolves the site's @uniweb/folder from it), so there is no
|
|
291
|
+
// separate folder uuid to seed.
|
|
292
|
+
seedYamlUuid(join(siteDir, 'site.yml'), siteUuid)
|
|
293
|
+
success(`Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`)
|
|
294
|
+
|
|
295
|
+
// 5. Install, then delegate the projection to the project-local `uniweb pull`.
|
|
296
|
+
const pm = detectWorkspacePm(projectDir) || 'pnpm'
|
|
297
|
+
|
|
298
|
+
if (deps.skipInstall) {
|
|
299
|
+
note('Skipping install (test mode).')
|
|
300
|
+
} else if (deps.runInstall) {
|
|
301
|
+
await deps.runInstall(projectDir, pm)
|
|
302
|
+
} else {
|
|
303
|
+
info(`Installing dependencies (${installCmd(pm)}) …`)
|
|
304
|
+
const r = spawnSync(pm, ['install'], { cwd: projectDir, stdio: 'inherit' })
|
|
305
|
+
if (r.status !== 0) {
|
|
306
|
+
error(`Install failed. Once it succeeds, run \`uniweb pull\` from ${siteDir} to fetch the content.`)
|
|
307
|
+
return { exitCode: 1 }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const pullExtra = []
|
|
312
|
+
if (flagValue(args, '--registry')) pullExtra.push('--registry', registryFlag)
|
|
313
|
+
if (tokenFlag) pullExtra.push('--token', tokenFlag)
|
|
314
|
+
if (noCollections) pullExtra.push('--no-collections')
|
|
315
|
+
|
|
316
|
+
if (deps.skipPull) {
|
|
317
|
+
note('Skipping pull (test mode).')
|
|
318
|
+
} else if (deps.runPull) {
|
|
319
|
+
await deps.runPull(siteDir, pm, pullExtra)
|
|
320
|
+
} else {
|
|
321
|
+
info('Pulling content …')
|
|
322
|
+
const r = spawnSync(pm, pullExecArgv(pm, pullExtra), { cwd: siteDir, stdio: 'inherit' })
|
|
323
|
+
if (r.status !== 0) {
|
|
324
|
+
error(`Content pull failed. Fix the issue, then run \`uniweb pull\` from ${siteDir}.`)
|
|
325
|
+
return { exitCode: 1 }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
log('')
|
|
330
|
+
success(`Cloned site into ${colors.bright}${isNewWorkspace && !inPlace ? workspaceName : siteDir}${colors.reset}`)
|
|
331
|
+
if (isNewWorkspace && !inPlace) {
|
|
332
|
+
log(`\nNext: ${colors.cyan}cd ${workspaceName} && uniweb dev${colors.reset}`)
|
|
333
|
+
} else {
|
|
334
|
+
log(`\nNext: ${colors.cyan}uniweb dev${colors.reset}`)
|
|
335
|
+
}
|
|
336
|
+
return { exitCode: 0 }
|
|
337
|
+
}
|