uniweb 0.56.12 → 0.58.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 +5 -5
- package/partials/agents.md +11 -1
- package/src/commands/add.js +14 -0
- package/src/commands/build.js +9 -1
- package/src/commands/clone.js +8 -0
- package/src/commands/i18n.js +34 -8
- package/src/commands/pull.js +43 -5
- package/src/framework-index.json +5 -5
- package/src/utils/placement.js +33 -4
- package/src/utils/site-identity.js +55 -5
- package/templates/site/site.yml.hbs +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/content-writer": "^0.3.4",
|
|
45
45
|
"@uniweb/core": "^0.29.4",
|
|
46
|
-
"@uniweb/runtime": "^0.26.
|
|
47
|
-
"@uniweb/semantic-parser": "^1.4.1",
|
|
46
|
+
"@uniweb/runtime": "^0.26.5",
|
|
48
47
|
"@uniweb/kit": "^0.19.3",
|
|
49
|
-
"@uniweb/schemas": "^0.3.3"
|
|
48
|
+
"@uniweb/schemas": "^0.3.3",
|
|
49
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/build": "^0.
|
|
52
|
+
"@uniweb/build": "^0.54.0",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -694,7 +694,7 @@ end the list with `...`.**
|
|
|
694
694
|
|
|
695
695
|
**Configuration cascades: `page.yml` → `folder.yml` → `site.yml` → foundation defaults.** Each level inherits from the one above and overrides specific values, the way CSS specificity works. This is what makes bulk assignment natural — put `layout: marketing` in a `folder.yml` and every page in that folder inherits it, while one page can still override with its own `page.yml`. Reach for `folder.yml` before editing the same key into a dozen `page.yml` files.
|
|
696
696
|
|
|
697
|
-
**Route mapping:** folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root, where `index:` (or first in `pages:`) sets
|
|
697
|
+
**Route mapping:** folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root, where `index:` (or first in `pages:`) sets `/` — declared in `site.yml`, or in the pages directory's own `folder.yml` / `page.yml` (how a content repo carries its order), with `site.yml` winning.
|
|
698
698
|
|
|
699
699
|
**Content-less containers:** folders with `page.yml` but no markdown are structural groups (`hasContent: false`). Visiting one auto-redirects to the first descendant with content — this is what supports courses → modules → lessons at any depth.
|
|
700
700
|
|
|
@@ -1344,6 +1344,16 @@ Components use **semantic CSS tokens** instead of hardcoded colors. The runtime
|
|
|
1344
1344
|
|
|
1345
1345
|
**Palette shades** are also available — `text-primary-600`, `bg-neutral-100`, `border-accent-300` — 11 shades (50–950) per palette color (primary, secondary, accent, neutral). See `theme-tokens.css` for the complete mapping.
|
|
1346
1346
|
|
|
1347
|
+
> **Debugging a token that "does nothing": don't probe it in the browser.** Tailwind v4 is JIT — it emits only the classes it finds in *scanned source*. So a class you assign from JavaScript, to a `<div>` you created in the console, **has never been scanned and can never resolve**, whatever the token. A comparison against a deliberately bogus class agrees with you either way, because both are ungenerated, and classes that happen to appear in source elsewhere look like they "pass" — so the probe confirms whichever answer you started with.
|
|
1348
|
+
>
|
|
1349
|
+
> **Ask the build instead.** The one command that splits the question:
|
|
1350
|
+
>
|
|
1351
|
+
> ```bash
|
|
1352
|
+
> grep -o '\.text-link{[^}]*}' dist/assets/*.css
|
|
1353
|
+
> ```
|
|
1354
|
+
>
|
|
1355
|
+
> A rule prints → the utility exists, and something at runtime is overriding it. Nothing prints → Tailwind never generated it, and there are only two reasons: your `styles.css` doesn't `@import "@uniweb/kit/theme-tokens.css"` (or declares its own `@theme inline` that omits the token), or your `@source` globs don't cover the file the class is written in. Either way, write the class into a real source file first — a token that's only ever composed at runtime is invisible to the scanner by design.
|
|
1356
|
+
|
|
1347
1357
|
**Authors control context** via `theme: dark` in frontmatter, alternating `light` (default), `medium`, and `dark` across sections for visual rhythm. **The three presets aren't the limit** — the object form overrides any token per section:
|
|
1348
1358
|
|
|
1349
1359
|
```yaml
|
package/src/commands/add.js
CHANGED
|
@@ -355,6 +355,13 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
355
355
|
// both. Format validation runs on the derived package name below, not
|
|
356
356
|
// on the raw input — slashes in the input are intentional path syntax.
|
|
357
357
|
const placement = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
|
|
358
|
+
if (placement.outsideRoot) {
|
|
359
|
+
error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
|
|
360
|
+
log('')
|
|
361
|
+
log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
|
|
362
|
+
log(`A package outside it cannot be a workspace member.`)
|
|
363
|
+
process.exit(1)
|
|
364
|
+
}
|
|
358
365
|
const { relativePath } = placement
|
|
359
366
|
let { packageName } = placement
|
|
360
367
|
const fullPath = join(rootDir, relativePath)
|
|
@@ -470,6 +477,13 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
|
|
|
470
477
|
|
|
471
478
|
// Resolve placement first (path + package name); see notes in addFoundation.
|
|
472
479
|
const placement = resolvePlacement(rootDir, name, opts, SITE_KIND)
|
|
480
|
+
if (placement.outsideRoot) {
|
|
481
|
+
error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
|
|
482
|
+
log('')
|
|
483
|
+
log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
|
|
484
|
+
log(`A package outside it cannot be a workspace member.`)
|
|
485
|
+
process.exit(1)
|
|
486
|
+
}
|
|
473
487
|
const { relativePath } = placement
|
|
474
488
|
let siteName = placement.packageName
|
|
475
489
|
const fullPath = join(rootDir, relativePath)
|
package/src/commands/build.js
CHANGED
|
@@ -414,10 +414,18 @@ async function loadI18nConfig(projectDir, siteConfig = null) {
|
|
|
414
414
|
}
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
// ⛔ The default locale is the root tree, not `dist/<default>/`. An explicit
|
|
418
|
+
// `languages: [en, fr]` resolves verbatim — default included — so the DECLARED
|
|
419
|
+
// form (the one the contract tells authors to use) minted a duplicate copy of
|
|
420
|
+
// every page, while the wildcard form did not. Dropping it here keeps content,
|
|
421
|
+
// per-locale HTML and translated records agreeing on one set.
|
|
422
|
+
const defaultLocale = resolveDefaultLocale(config)
|
|
423
|
+
locales = locales.filter((l) => l !== defaultLocale)
|
|
424
|
+
|
|
417
425
|
if (locales.length === 0) return null
|
|
418
426
|
|
|
419
427
|
return {
|
|
420
|
-
defaultLocale
|
|
428
|
+
defaultLocale,
|
|
421
429
|
locales,
|
|
422
430
|
localesDir
|
|
423
431
|
}
|
package/src/commands/clone.js
CHANGED
|
@@ -271,6 +271,14 @@ export async function clone(args = [], deps = {}) {
|
|
|
271
271
|
{ path: pathFlag, project: projectFlag },
|
|
272
272
|
SITE_KIND
|
|
273
273
|
)
|
|
274
|
+
if (placement.outsideRoot) {
|
|
275
|
+
error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
|
|
276
|
+
note(
|
|
277
|
+
`The workspace root is ${existingRoot}. A package outside it cannot be a ` +
|
|
278
|
+
`workspace member — the directory and the manifests would disagree.`
|
|
279
|
+
)
|
|
280
|
+
return { exitCode: 1 }
|
|
281
|
+
}
|
|
274
282
|
siteDir = join(existingRoot, placement.relativePath)
|
|
275
283
|
sitePkgName = placement.packageName
|
|
276
284
|
workspaceName = sitePkgName
|
package/src/commands/i18n.js
CHANGED
|
@@ -372,6 +372,26 @@ async function runExtract(siteRoot, config, args) {
|
|
|
372
372
|
}
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
+
/**
|
|
376
|
+
* The starter value a translator is handed for one unit.
|
|
377
|
+
*
|
|
378
|
+
* ⭐ `unit.markup` when the element carries inline markdown, `unit.source` otherwise.
|
|
379
|
+
* `source` is the flattened plain text — it is the KEY, and it is what this function
|
|
380
|
+
* used to seed. That meant a paragraph's links, bold and emphasis were already gone
|
|
381
|
+
* from the file a translator opened, and a translator cannot restore what they were
|
|
382
|
+
* never shown: a 100%-covered locale rendered with zero inline links and no check
|
|
383
|
+
* reported it (coverage counts keys, not fidelity). `merge` has always parsed a
|
|
384
|
+
* translation value as inline markdown, so the value channel was lossless the whole
|
|
385
|
+
* time — only the prompt was lossy. Measured 2026-09-18, @uniweb/build 0.44.4.
|
|
386
|
+
*
|
|
387
|
+
* ⚠️ This fills MISSING keys only. A locale file already seeded from the flat source
|
|
388
|
+
* is not repaired by re-running; use `uniweb i18n generate <locale> --force` against
|
|
389
|
+
* a freshly extracted manifest to get the markup-bearing source back.
|
|
390
|
+
*/
|
|
391
|
+
function starterValue(unit) {
|
|
392
|
+
return unit.markup || unit.source
|
|
393
|
+
}
|
|
394
|
+
|
|
375
395
|
/**
|
|
376
396
|
* Generate command - generate starter translation files from manifest
|
|
377
397
|
*
|
|
@@ -451,7 +471,7 @@ async function runInit(siteRoot, config, args) {
|
|
|
451
471
|
|
|
452
472
|
for (const [hash, unit] of Object.entries(units)) {
|
|
453
473
|
if (!existingKeys.has(hash)) {
|
|
454
|
-
existing[hash] = useEmpty ? '' : unit
|
|
474
|
+
existing[hash] = useEmpty ? '' : starterValue(unit)
|
|
455
475
|
added++
|
|
456
476
|
}
|
|
457
477
|
}
|
|
@@ -470,7 +490,7 @@ async function runInit(siteRoot, config, args) {
|
|
|
470
490
|
const localeData = {}
|
|
471
491
|
|
|
472
492
|
for (const [hash, unit] of Object.entries(units)) {
|
|
473
|
-
localeData[hash] = useEmpty ? '' : unit
|
|
493
|
+
localeData[hash] = useEmpty ? '' : starterValue(unit)
|
|
474
494
|
}
|
|
475
495
|
|
|
476
496
|
await writeFile(localePath, JSON.stringify(localeData, null, 2) + '\n')
|
|
@@ -962,17 +982,23 @@ async function runAudit(siteRoot, config, args) {
|
|
|
962
982
|
}
|
|
963
983
|
}
|
|
964
984
|
|
|
965
|
-
// Report
|
|
966
|
-
|
|
967
|
-
|
|
985
|
+
// ⭐ Report translations that will render as flat prose.
|
|
986
|
+
// Coverage cannot see this class — it counts keys, not fidelity — so a site
|
|
987
|
+
// whose every link has been flattened reports 100%. That is exactly how a
|
|
988
|
+
// fully translated documentation site shipped with zero internal links.
|
|
989
|
+
const losesMarkupTotal = results.reduce(
|
|
990
|
+
(sum, r) => sum + (r.losesMarkup?.length || 0),
|
|
968
991
|
0
|
|
969
992
|
)
|
|
970
|
-
if (
|
|
993
|
+
if (losesMarkupTotal > 0) {
|
|
994
|
+
log(
|
|
995
|
+
`\n${colors.yellow}${losesMarkupTotal} translation(s) drop the inline markup their source carries.`
|
|
996
|
+
)
|
|
971
997
|
log(
|
|
972
|
-
|
|
998
|
+
`Links, bold and emphasis will be missing from these — a translation value is`
|
|
973
999
|
)
|
|
974
1000
|
log(
|
|
975
|
-
`
|
|
1001
|
+
`inline markdown, so copy the [text](href) and ** ** across from the source.${colors.reset}`
|
|
976
1002
|
)
|
|
977
1003
|
if (!verbose) {
|
|
978
1004
|
log(
|
package/src/commands/pull.js
CHANGED
|
@@ -156,14 +156,39 @@ function shouldFetchAssets(args, siteDir) {
|
|
|
156
156
|
return true
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// Read a top-level `$uuid:` scalar from a YAML file
|
|
159
|
+
// Read a top-level `$uuid:` scalar from a YAML file.
|
|
160
|
+
//
|
|
161
|
+
// ⛔ THREE STATES, AND COLLAPSING THEM TO `null` IS A LIE TO THE USER.
|
|
162
|
+
// This returned `null` on any throw, so a site.yml that EXISTS and carries a
|
|
163
|
+
// `$uuid:` line but does not PARSE was reported as "this project has no $uuid
|
|
164
|
+
// yet. Run `uniweb push` first." — advice that is wrong, and actively harmful
|
|
165
|
+
// on a cloned site, since `push` would send an empty tree at the uuid the file
|
|
166
|
+
// already holds.
|
|
167
|
+
//
|
|
168
|
+
// It is also invisible: the user greps the file, sees `$uuid:`, and disbelieves
|
|
169
|
+
// the CLI. The clone suite's own comment records an earlier round of exactly
|
|
170
|
+
// this ("an unquoted `@…` scalar matched a regex here while the file did not
|
|
171
|
+
// parse, and `pull` then found no `$uuid`") — that CAUSE was fixed by quoting
|
|
172
|
+
// the ref; the SYMPTOM stayed reachable by any other parse error.
|
|
173
|
+
// Re-surfaced 2026-09-18 by the backend lane, whose clone wrote a `$uuid:` and
|
|
174
|
+
// whose pull then said there was none.
|
|
175
|
+
//
|
|
176
|
+
// ⇒ Name the state. A missing file and a `$uuid`-less file are "no uuid"; a
|
|
177
|
+
// file that will not parse is an error with the parser's own message.
|
|
160
178
|
function readYamlUuid(filePath) {
|
|
179
|
+
let text
|
|
161
180
|
try {
|
|
162
|
-
|
|
163
|
-
return typeof obj?.$uuid === 'string' ? obj.$uuid : null
|
|
181
|
+
text = readFileSync(filePath, 'utf8')
|
|
164
182
|
} catch {
|
|
165
|
-
return null
|
|
183
|
+
return { uuid: null } // no site.yml — genuinely nothing to pull
|
|
184
|
+
}
|
|
185
|
+
let obj
|
|
186
|
+
try {
|
|
187
|
+
obj = yaml.load(text)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return { uuid: null, unparsed: err.message.split('\n')[0] }
|
|
166
190
|
}
|
|
191
|
+
return { uuid: typeof obj?.$uuid === 'string' ? obj.$uuid : null }
|
|
167
192
|
}
|
|
168
193
|
|
|
169
194
|
// Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
|
|
@@ -583,7 +608,20 @@ export async function pull(args = [], deps = {}) {
|
|
|
583
608
|
|
|
584
609
|
// One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
|
|
585
610
|
// by it — the backend resolves the site's `@uniweb/folder` from this uuid.
|
|
586
|
-
const
|
|
611
|
+
const siteYmlPath = join(siteDir, 'site.yml')
|
|
612
|
+
const identity = readYamlUuid(siteYmlPath)
|
|
613
|
+
|
|
614
|
+
// A file that will not parse is an error, not an absence — see readYamlUuid.
|
|
615
|
+
if (identity.unparsed) {
|
|
616
|
+
error(`site.yml did not parse: ${identity.unparsed}`)
|
|
617
|
+
note(siteYmlPath)
|
|
618
|
+
note(
|
|
619
|
+
'Nothing was read, so the $uuid in it (if any) was not seen. Fix the YAML and re-run.'
|
|
620
|
+
)
|
|
621
|
+
return { exitCode: 1 }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const siteContentUuid = identity.uuid
|
|
587
625
|
|
|
588
626
|
if (!siteContentUuid) {
|
|
589
627
|
info(
|
package/src/framework-index.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-19T18:06:40.616Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.4.1",
|
|
7
7
|
"path": "framework/api",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/core"
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.54.0",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
]
|
|
84
84
|
},
|
|
85
85
|
"@uniweb/runtime": {
|
|
86
|
-
"version": "0.26.
|
|
86
|
+
"version": "0.26.5",
|
|
87
87
|
"path": "framework/runtime",
|
|
88
88
|
"deps": [
|
|
89
89
|
"@uniweb/core",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"deps": []
|
|
127
127
|
},
|
|
128
128
|
"@uniweb/unipress": {
|
|
129
|
-
"version": "0.9.
|
|
129
|
+
"version": "0.9.24",
|
|
130
130
|
"path": "framework/unipress",
|
|
131
131
|
"deps": [
|
|
132
132
|
"@uniweb/build",
|
package/src/utils/placement.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Package placement resolution — shared by `add` and `clone`.
|
|
3
5
|
*
|
|
@@ -7,6 +9,31 @@
|
|
|
7
9
|
* (the same reason `utils/workspace.js` loads the classifier lazily).
|
|
8
10
|
*/
|
|
9
11
|
|
|
12
|
+
// `--path` is documented as a folder INSIDE the workspace, and every consumer of
|
|
13
|
+
// the result treats it that way: the caller does `join(rootDir, relativePath)`
|
|
14
|
+
// while the workspace manifests get `relativePath` verbatim. An ABSOLUTE path
|
|
15
|
+
// silently satisfies neither — `join('/ws', '/tmp/x')` is `/ws/tmp/x`, so the
|
|
16
|
+
// directory is created in one place while `package.json::workspaces` and
|
|
17
|
+
// `pnpm-workspace.yaml` record `/tmp/x`. The two disagree, and the manifests are
|
|
18
|
+
// committed files carrying a path that exists on exactly one machine.
|
|
19
|
+
//
|
|
20
|
+
// Reported by the backend lane 2026-09-18 from a `clone --path <abs>`: the site
|
|
21
|
+
// landed under `company-websites/tmp/claude-502/clonetest/` and both manifests
|
|
22
|
+
// gained the absolute string.
|
|
23
|
+
//
|
|
24
|
+
// So: accept an absolute path when it names a folder inside the root (people type
|
|
25
|
+
// one, and it is unambiguous), by rewriting it to the root-relative form every
|
|
26
|
+
// consumer already expects. Refuse it when it escapes — a workspace package cannot
|
|
27
|
+
// live outside its workspace, and guessing which half the user meant is how you get
|
|
28
|
+
// the split above.
|
|
29
|
+
function normalizeParent(given, rootDir) {
|
|
30
|
+
const trimmed = String(given).replace(/\/+$/, '')
|
|
31
|
+
if (!isAbsolute(trimmed)) return { relative: trimmed }
|
|
32
|
+
const rel = relative(rootDir || '', trimmed)
|
|
33
|
+
if (!rel || rel === '..' || rel.startsWith('../')) return { outsideRoot: true, given: trimmed }
|
|
34
|
+
return { relative: rel.split(sep).join('/') }
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
/** Foundation placement defaults (folder `src/`, package `src`). */
|
|
11
38
|
export const FOUNDATION_KIND = {
|
|
12
39
|
defaultDir: 'src',
|
|
@@ -59,17 +86,19 @@ export function resolvePlacement(rootDir, name, opts, kind) {
|
|
|
59
86
|
// name was given, or `<path>` itself if not (the path's last segment
|
|
60
87
|
// is then taken as the package name).
|
|
61
88
|
if (opts.path) {
|
|
62
|
-
const parent = opts.path
|
|
89
|
+
const parent = normalizeParent(opts.path, rootDir)
|
|
90
|
+
if (parent.outsideRoot) return { outsideRoot: parent.given, relativePath: null, packageName: null }
|
|
91
|
+
const dir = parent.relative
|
|
63
92
|
if (name) {
|
|
64
93
|
const last = name.split('/').filter(Boolean).pop()
|
|
65
94
|
return {
|
|
66
|
-
relativePath: `${
|
|
95
|
+
relativePath: `${dir}/${name}`.replace(/\/+/g, '/'),
|
|
67
96
|
packageName: last
|
|
68
97
|
}
|
|
69
98
|
}
|
|
70
|
-
const lastSegment =
|
|
99
|
+
const lastSegment = dir.split('/').filter(Boolean).pop() || dir
|
|
71
100
|
return {
|
|
72
|
-
relativePath:
|
|
101
|
+
relativePath: dir,
|
|
73
102
|
packageName: lastSegment
|
|
74
103
|
}
|
|
75
104
|
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* and it has to move the readers and the writers together or it makes the split worse.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
31
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
32
32
|
import { join, dirname } from 'node:path'
|
|
33
33
|
import yaml from 'js-yaml'
|
|
34
34
|
import { DEFAULT_BACKEND_ORIGIN } from './config.js'
|
|
@@ -136,10 +136,25 @@ export async function recordSiteBackend(siteDir, origin, deps = {}) {
|
|
|
136
136
|
// Lazy by necessity, not by taste — see the header.
|
|
137
137
|
mod = await loadUwx()
|
|
138
138
|
} catch {
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
139
|
+
// ⛔ NO `@uniweb/build` — and this is the COMMON case on the path that needs it
|
|
140
|
+
// most, not the rare one this branch used to assume.
|
|
141
|
+
//
|
|
142
|
+
// `clone` calls us straight after scaffolding and BEFORE `pnpm install`, so the
|
|
143
|
+
// site has no `node_modules` yet; under `npx uniweb@latest` the CLI's own tree
|
|
144
|
+
// may not carry build either (it is an optional peer). The import therefore
|
|
145
|
+
// fails on a FRESH CLONE essentially always, and returning null wrote nothing.
|
|
146
|
+
//
|
|
147
|
+
// The cost is not cosmetic: the clone then holds a `$uuid` minted by the
|
|
148
|
+
// `--backend` origin it was given, with nothing recording that origin, so the
|
|
149
|
+
// scope guard infers the DEFAULT backend and stops the very next command with
|
|
150
|
+
// "this project's stored identity belongs to https://uniweb.app". The origin was
|
|
151
|
+
// on the command line the whole time.
|
|
152
|
+
// Measured by the backend lane 2026-09-18: `0.48.5` recorded `$backend`,
|
|
153
|
+
// `0.57.0` did not, and their clone was unusable until they added the line by hand.
|
|
154
|
+
//
|
|
155
|
+
// ⇒ Fall back to a dependency-free write. `js-yaml` is the CLI's own direct
|
|
156
|
+
// dependency, so this path needs nothing installed in the project.
|
|
157
|
+
return writeSiteBackendFallback(siteDir, norm)
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
// ⚠️ PRESENT BUT TOO OLD is a different problem, and it must not look like the one
|
|
@@ -167,6 +182,41 @@ export async function recordSiteBackend(siteDir, origin, deps = {}) {
|
|
|
167
182
|
}
|
|
168
183
|
}
|
|
169
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Write `$backend` into `site.yml` without `@uniweb/build` — the fallback for a
|
|
187
|
+
* project whose dependencies are not installed yet (see `recordSiteBackend`).
|
|
188
|
+
*
|
|
189
|
+
* ⭐ Verifies the RESULT rather than trusting the edit: the file must still parse and
|
|
190
|
+
* must read back the origin we meant to write. A write that corrupts `site.yml` would
|
|
191
|
+
* be far worse than an unrecorded scope — an unparseable `site.yml` is the failure this
|
|
192
|
+
* same channel spent an evening on.
|
|
193
|
+
*
|
|
194
|
+
* @returns {string|null} the recorded origin, or null when nothing was written
|
|
195
|
+
*/
|
|
196
|
+
function writeSiteBackendFallback(siteDir, norm) {
|
|
197
|
+
const file = join(siteDir, 'site.yml')
|
|
198
|
+
let text
|
|
199
|
+
try {
|
|
200
|
+
text = readFileSync(file, 'utf8')
|
|
201
|
+
} catch {
|
|
202
|
+
return null // no site.yml — nothing to annotate, and we do not create one
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
// Quote through js-yaml rather than by hand: an origin is plain today, but the
|
|
206
|
+
// value is not ours to assume. Replace an existing key, else prepend.
|
|
207
|
+
const scalar = yaml.dump(norm, { lineWidth: -1 }).trim()
|
|
208
|
+
const next = /^\$backend:/m.test(text)
|
|
209
|
+
? text.replace(/^\$backend:.*$/m, `$backend: ${scalar}`)
|
|
210
|
+
: `$backend: ${scalar}\n` + text
|
|
211
|
+
const parsed = yaml.load(next)
|
|
212
|
+
if (!parsed || typeof parsed !== 'object' || parsed.$backend !== norm) return null
|
|
213
|
+
writeFileSync(file, next)
|
|
214
|
+
return norm
|
|
215
|
+
} catch {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
170
220
|
/**
|
|
171
221
|
* Refuse to act on a site whose stored identity belongs to a different backend.
|
|
172
222
|
*
|
|
@@ -31,6 +31,12 @@ index: home
|
|
|
31
31
|
# (pages/docs/) holding a folder.yml — folder.yml, not page.yml, because the
|
|
32
32
|
# mounted directory is a folder of pages. Whatever that stub leaves unset comes
|
|
33
33
|
# from the mounted directory's own folder.yml, so it need only say what differs.
|
|
34
|
+
#
|
|
35
|
+
# ! A `pages/<segment>:` sub-mount is BUILD-ONLY today. `uniweb export` and
|
|
36
|
+
# `uniweb deploy --host <adapter>` mount it normally, but `uniweb push` /
|
|
37
|
+
# `uniweb publish` do not read the key, so the mounted pages do not reach a
|
|
38
|
+
# backend (the CLI warns when it skips them). The whole-directory form
|
|
39
|
+
# `paths: { pages: <dir> }` IS carried by both. Static hosting: use either.
|
|
34
40
|
|
|
35
41
|
# ─── Structured Content ───────────────────────────────────────────────────────
|
|
36
42
|
# Three things, and they are separate on purpose:
|