uniweb 0.13.1 → 0.13.3
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 +6 -1
- package/src/backend/site-sync.js +4 -2
- package/src/commands/dev.js +40 -0
- package/src/commands/doctor.js +22 -5
- package/src/commands/update.js +46 -0
- package/src/framework-index.json +5 -5
- package/src/utils/install-integrity.js +206 -0
- package/templates/site/site.yml.hbs +5 -0
- package/templates/site/vite.config.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
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/runtime": "0.8.33",
|
|
45
44
|
"@uniweb/core": "0.7.27",
|
|
46
|
-
"@uniweb/kit": "0.9.
|
|
45
|
+
"@uniweb/kit": "0.9.35",
|
|
46
|
+
"@uniweb/runtime": "0.8.34"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
+
"@uniweb/build": "0.15.3",
|
|
49
50
|
"@uniweb/content-reader": "1.1.15",
|
|
50
|
-
"@uniweb/semantic-parser": "1.1.18"
|
|
51
|
-
"@uniweb/build": "0.15.1"
|
|
51
|
+
"@uniweb/semantic-parser": "1.1.18"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -312,6 +312,8 @@ Description paragraph.
|
|
|
312
312
|
|
|
313
313
|
Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing.
|
|
314
314
|
|
|
315
|
+
**A section with no `type:` renders through the foundation's default section type — a component named `Section`, unless the foundation's `main.js` sets `defaultSection` to something else.** This is what lets a folder of plain markdown with no frontmatter at all become pages: mounted documentation, an imported wiki, anything written before it met this framework. If such content renders blank, the foundation has no `Section` — that, not the markdown, is what to fix.
|
|
316
|
+
|
|
315
317
|
**Markdown order ≠ rendering order.** The parser extracts content into a flat structure; the component decides how to arrange it visually. Write markdown in semantic order, not visual order — start with the heading, then add icons, images, and text in any order.
|
|
316
318
|
|
|
317
319
|
**Placing content *before* the first heading changes the parse:** headings after body content become items, not the section title. This is by design — it's how repeating content groups are created.
|
|
@@ -605,6 +607,8 @@ pages: [home, about, ...] # Order pages (... = rest, first = homepage); withou
|
|
|
605
607
|
|
|
606
608
|
**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.
|
|
607
609
|
|
|
610
|
+
**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.
|
|
611
|
+
|
|
608
612
|
**SEO & social cards:** set site-wide defaults in `site.yml`; any page overrides per-field in `page.yml` (page wins, site fills gaps). These render into every page's static `<head>` — Open Graph, Twitter Card, canonical, robots — so shares and crawlers see them without running JS. The social `image` is the field most worth setting once at site level.
|
|
609
613
|
|
|
610
614
|
```yaml
|
|
@@ -808,11 +812,12 @@ Names only — for signatures and props, read the package: it's on disk at `node
|
|
|
808
812
|
**Media:** `Visual` (first non-empty: inset/video/image), `Image`, `Media`, `Icon`, `Asset`
|
|
809
813
|
**Navigation:** `Link`, `useActiveRoute()`, `useWebsite()`, `useRouting()`
|
|
810
814
|
**Header/layout:** `useScrolled(threshold)`, `useMobileMenu()`, `useAppearance()`
|
|
815
|
+
**Documentation shells:** `useHeadings()` (the page's headings + the one being read, derived from content so it prerenders), `website.getBranchHierarchy({ route, for })` (the page tree for one branch). Kit ships no ready-made layout — a layout is your foundation's design; write it in `src/layouts/` and use these for the behaviour.
|
|
811
816
|
**Layout helpers:** `useGridLayout(columns, { gap })`, `useAccordion({ multiple, defaultOpen })`, `useTheme(name)`
|
|
812
817
|
**Theming data:** `useThemeData()`, `useColorContext(block)`
|
|
813
818
|
**Data fetching:** `useFetched`, `useCacheEntry`, `useEntityDetail`
|
|
814
819
|
**Utilities:** `cn()`, `SafeHtml`, `SocialIcon`, `filterSocialLinks(links)`, `getSocialPlatform(url)`, `getLocaleLabel(locale)`
|
|
815
|
-
**Other styled:** `
|
|
820
|
+
**Other styled:** `Code`, `Alert`, `Table`, `Details`, `Divider`, `Disclaimer`
|
|
816
821
|
|
|
817
822
|
Four things you won't discover by reading exports:
|
|
818
823
|
|
package/src/backend/site-sync.js
CHANGED
|
@@ -110,8 +110,10 @@ export function writeSyncCache(siteDir, hashes) {
|
|
|
110
110
|
* Offline-probe how many of a site's entities differ from the last successful push.
|
|
111
111
|
* Runs the SAME emit + send-only-changed diff `uniweb push` runs, but with an
|
|
112
112
|
* OFFLINE Model resolver — no auth, no submit, no backend round-trip. Used by
|
|
113
|
-
* `uniweb status`
|
|
114
|
-
*
|
|
113
|
+
* `uniweb status` only. (It was also `uniweb publish`'s pre-flight back when publish
|
|
114
|
+
* went live WITHOUT pushing and so had to warn about unpushed content; publish now
|
|
115
|
+
* always pushes, which makes the warning moot.) Throws if the producer can't build
|
|
116
|
+
* the sync packages (e.g. an unresolved data Model); callers report it.
|
|
115
117
|
*
|
|
116
118
|
* @param {string} siteDir
|
|
117
119
|
* @returns {Promise<{ changed: number, unchanged: number, warnings: string[] }>}
|
package/src/commands/dev.js
CHANGED
|
@@ -40,6 +40,8 @@ import { readWorkspaceConfig } from '../utils/config.js'
|
|
|
40
40
|
import { discoverSites } from '../utils/discover.js'
|
|
41
41
|
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
42
42
|
import { readFlagValue } from '../utils/args.js'
|
|
43
|
+
import { checkSiteInstall, readDeclaredFoundation } from '../utils/install-integrity.js'
|
|
44
|
+
import { discoverFoundations } from '../utils/discover.js'
|
|
43
45
|
|
|
44
46
|
const RED = '\x1b[31m'
|
|
45
47
|
const YELLOW = '\x1b[33m'
|
|
@@ -95,6 +97,13 @@ export async function dev(args = []) {
|
|
|
95
97
|
console.error('')
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
// A `file:` foundation dependency can be satisfied by a link or by a copy,
|
|
101
|
+
// and a copy is a snapshot: the dev server serves it while builds read the
|
|
102
|
+
// workspace source, so the two disagree and only the browser shows it. The
|
|
103
|
+
// check is a few stats and package.json reads, so it runs every time rather
|
|
104
|
+
// than waiting for someone to suspect it.
|
|
105
|
+
await warnOnStaleInstall(rootDir, site)
|
|
106
|
+
|
|
98
107
|
const pm = detectPackageManager()
|
|
99
108
|
const command = filterCmd(pm, site.name, 'dev')
|
|
100
109
|
const [bin, ...rest] = command.split(' ')
|
|
@@ -110,3 +119,34 @@ export async function dev(args = []) {
|
|
|
110
119
|
process.exit(1)
|
|
111
120
|
})
|
|
112
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Say so if the site is about to be served from an install that no longer
|
|
125
|
+
* matches the workspace. Warns rather than exits — the server may still be
|
|
126
|
+
* useful, and the author is the one who knows whether it is.
|
|
127
|
+
*/
|
|
128
|
+
async function warnOnStaleInstall(rootDir, site) {
|
|
129
|
+
try {
|
|
130
|
+
const sitePath = join(rootDir, site.path)
|
|
131
|
+
const foundationName = readDeclaredFoundation(sitePath)
|
|
132
|
+
if (!foundationName) return
|
|
133
|
+
|
|
134
|
+
const foundations = await discoverFoundations(rootDir)
|
|
135
|
+
const foundation = foundations
|
|
136
|
+
.map(f => ({ ...f, path: join(rootDir, f.path) }))
|
|
137
|
+
.find(f => f.name === foundationName)
|
|
138
|
+
if (!foundation) return
|
|
139
|
+
|
|
140
|
+
const findings = checkSiteInstall({ name: site.name, path: sitePath }, foundation, rootDir)
|
|
141
|
+
if (!findings.length) return
|
|
142
|
+
|
|
143
|
+
console.error(`${YELLOW}⚠${RESET} Dev will not build against your workspace foundation:`)
|
|
144
|
+
for (const finding of findings) {
|
|
145
|
+
console.error(` ${finding.message}`)
|
|
146
|
+
}
|
|
147
|
+
console.error(` ${findings[0].remedy}. \`uniweb doctor\` explains in full.`)
|
|
148
|
+
console.error('')
|
|
149
|
+
} catch {
|
|
150
|
+
// A diagnostic must never be the reason dev fails to start.
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@ import { readAgentsVersion } from '../utils/agents-stamp.js'
|
|
|
13
13
|
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
14
14
|
import { surveyWorkspaceDeps } from '../utils/dep-survey.js'
|
|
15
15
|
import { discoverFoundations, discoverSites } from '../utils/discover.js'
|
|
16
|
+
import { checkSiteInstall } from '../utils/install-integrity.js'
|
|
16
17
|
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -409,8 +410,24 @@ export async function doctor(args = []) {
|
|
|
409
410
|
success(`Dependency: ${depValue} (npm package)`)
|
|
410
411
|
}
|
|
411
412
|
|
|
413
|
+
// Is what is installed what is declared? A `file:` dependency can be
|
|
414
|
+
// satisfied by a link or by a copy, and only the link stays true. The copy
|
|
415
|
+
// is invisible to a build — which reads the workspace source directly — so
|
|
416
|
+
// it surfaces as a dev server serving stale code, or nothing at all.
|
|
417
|
+
for (const finding of checkSiteInstall(
|
|
418
|
+
{ name: siteName, path: sitePath },
|
|
419
|
+
matchingFoundation,
|
|
420
|
+
workspaceDir
|
|
421
|
+
)) {
|
|
422
|
+
issues.push({ id: finding.id, type: finding.severity, site: siteName, message: finding.message })
|
|
423
|
+
error(`[${finding.id}] ${finding.message}`)
|
|
424
|
+
log(`${colors.dim}${finding.detail}${colors.reset}`)
|
|
425
|
+
log('')
|
|
426
|
+
log(` ${colors.dim}To fix: ${finding.remedy}${colors.reset}`)
|
|
427
|
+
}
|
|
428
|
+
|
|
412
429
|
// Check if foundation is built
|
|
413
|
-
const foundationDist = join(matchingFoundation.path, 'dist', '
|
|
430
|
+
const foundationDist = join(matchingFoundation.path, 'dist', 'entry.js')
|
|
414
431
|
if (!existsSync(foundationDist)) {
|
|
415
432
|
issues.push({
|
|
416
433
|
type: 'warn',
|
|
@@ -420,7 +437,7 @@ export async function doctor(args = []) {
|
|
|
420
437
|
warn(`Foundation not built yet`)
|
|
421
438
|
log(` ${colors.dim}Run: uniweb build${colors.reset}`)
|
|
422
439
|
} else {
|
|
423
|
-
success(`Foundation built: dist/
|
|
440
|
+
success(`Foundation built: dist/entry.js exists`)
|
|
424
441
|
}
|
|
425
442
|
}
|
|
426
443
|
|
|
@@ -463,7 +480,7 @@ export async function doctor(args = []) {
|
|
|
463
480
|
}
|
|
464
481
|
|
|
465
482
|
// Check if built
|
|
466
|
-
const extensionDist = join(ext.path, 'dist', '
|
|
483
|
+
const extensionDist = join(ext.path, 'dist', 'entry.js')
|
|
467
484
|
if (!existsSync(extensionDist)) {
|
|
468
485
|
issues.push({
|
|
469
486
|
type: 'warn',
|
|
@@ -472,7 +489,7 @@ export async function doctor(args = []) {
|
|
|
472
489
|
warn(`Extension not built yet`)
|
|
473
490
|
log(` ${colors.dim}Run: uniweb build${colors.reset}`)
|
|
474
491
|
} else {
|
|
475
|
-
success(`Extension built: dist/
|
|
492
|
+
success(`Extension built: dist/entry.js exists`)
|
|
476
493
|
}
|
|
477
494
|
}
|
|
478
495
|
|
|
@@ -513,7 +530,7 @@ export async function doctor(args = []) {
|
|
|
513
530
|
|
|
514
531
|
// Check if a matching extension exists and is built
|
|
515
532
|
const matchingExt = extensions.find(e => e.folderName === extName || e.name === extName)
|
|
516
|
-
if (matchingExt && !existsSync(join(matchingExt.path, 'dist', '
|
|
533
|
+
if (matchingExt && !existsSync(join(matchingExt.path, 'dist', 'entry.js'))) {
|
|
517
534
|
issues.push({
|
|
518
535
|
type: 'warn',
|
|
519
536
|
site: site.name,
|
package/src/commands/update.js
CHANGED
|
@@ -49,6 +49,7 @@ import { isNonInteractive } from '../utils/interactive.js'
|
|
|
49
49
|
import { detectWorkspacePm, installCmd, detectGlobalCliPm, globalCliUpdateCmd } from '../utils/pm.js'
|
|
50
50
|
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
51
51
|
import { surveyWorkspaceDeps, compareSemver } from '../utils/dep-survey.js'
|
|
52
|
+
import { checkWorkspaceInstall, readDeclaredFoundation } from '../utils/install-integrity.js'
|
|
52
53
|
|
|
53
54
|
const colors = {
|
|
54
55
|
reset: '\x1b[0m',
|
|
@@ -422,12 +423,57 @@ export async function update(args = []) {
|
|
|
422
423
|
})
|
|
423
424
|
}
|
|
424
425
|
|
|
426
|
+
// ── Verify the install actually took ─────────────────────────────
|
|
427
|
+
// An install that was interrupted, or that satisfied a `file:` foundation
|
|
428
|
+
// with a copy instead of a link, leaves a tree that no longer matches what
|
|
429
|
+
// was just written. Builds keep working — they read the workspace source —
|
|
430
|
+
// so the mismatch only shows up later, as a dev server serving stale code,
|
|
431
|
+
// by which point this command is nobody's first suspect. It is the most
|
|
432
|
+
// likely command to produce that state, so it is the right one to check.
|
|
433
|
+
if (!dryRun && installRan) {
|
|
434
|
+
await reportInstallIntegrity(workspaceDir)
|
|
435
|
+
}
|
|
436
|
+
|
|
425
437
|
// ── Closing summary ──────────────────────────────────────────────
|
|
426
438
|
if (!dryRun && (depsEdited || agentsResult === 'created' || agentsResult === 'updated')) {
|
|
427
439
|
printSummary({ editedPaths, depsEdited, installRan, installPm, agentsResult, cliVersion })
|
|
428
440
|
}
|
|
429
441
|
}
|
|
430
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Report any drift between the workspace and the tree that was just installed.
|
|
445
|
+
*/
|
|
446
|
+
async function reportInstallIntegrity(workspaceDir) {
|
|
447
|
+
try {
|
|
448
|
+
// Imported here, not at the top: discover.js reaches @uniweb/build, an
|
|
449
|
+
// optional peer that must stay off the CLI's startup path so `npx uniweb
|
|
450
|
+
// create` works in an empty directory. update.js is loaded at startup.
|
|
451
|
+
const { discoverSites, discoverFoundations } = await import('../utils/discover.js')
|
|
452
|
+
|
|
453
|
+
const sites = (await discoverSites(workspaceDir)).map((s) => ({
|
|
454
|
+
name: s.name,
|
|
455
|
+
path: join(workspaceDir, s.path),
|
|
456
|
+
foundation: readDeclaredFoundation(join(workspaceDir, s.path)),
|
|
457
|
+
}))
|
|
458
|
+
const foundations = (await discoverFoundations(workspaceDir)).map((f) => ({
|
|
459
|
+
name: f.name,
|
|
460
|
+
path: join(workspaceDir, f.path),
|
|
461
|
+
}))
|
|
462
|
+
|
|
463
|
+
const findings = checkWorkspaceInstall(sites, foundations, workspaceDir)
|
|
464
|
+
if (!findings.length) return
|
|
465
|
+
|
|
466
|
+
log('')
|
|
467
|
+
warn('The install finished, but dev will not resolve what is declared:')
|
|
468
|
+
for (const finding of findings) {
|
|
469
|
+
log(` ${finding.message}`)
|
|
470
|
+
}
|
|
471
|
+
log(` ${colors.dim}${findings[0].remedy}. \`uniweb doctor\` explains in full.${colors.reset}`)
|
|
472
|
+
} catch {
|
|
473
|
+
// A post-flight check must never turn a successful update into a failure.
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
431
477
|
/**
|
|
432
478
|
* Step 2 — regenerate AGENTS.md from the CLI's bundled partial, stamped
|
|
433
479
|
* with the CLI version. Guarded: won't run if the deps were edited but
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-25T16:40:40.342Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.15.
|
|
6
|
+
"version": "0.15.3",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"deps": []
|
|
44
44
|
},
|
|
45
45
|
"@uniweb/kit": {
|
|
46
|
-
"version": "0.9.
|
|
46
|
+
"version": "0.9.35",
|
|
47
47
|
"path": "framework/kit",
|
|
48
48
|
"deps": [
|
|
49
49
|
"@uniweb/core",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"deps": []
|
|
62
62
|
},
|
|
63
63
|
"@uniweb/runtime": {
|
|
64
|
-
"version": "0.8.
|
|
64
|
+
"version": "0.8.34",
|
|
65
65
|
"path": "framework/runtime",
|
|
66
66
|
"deps": [
|
|
67
67
|
"@uniweb/core",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
101
101
|
"@uniweb/unipress": {
|
|
102
|
-
"version": "0.5.
|
|
102
|
+
"version": "0.5.2",
|
|
103
103
|
"path": "framework/unipress",
|
|
104
104
|
"deps": [
|
|
105
105
|
"@uniweb/build",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local development: is the bundler reading the foundation source you are editing?
|
|
3
|
+
*
|
|
4
|
+
* SCOPE — this is a check about CLI scaffolding, and only about one modality.
|
|
5
|
+
* A site's `package.json`, its `file:` dependency on the foundation, and its
|
|
6
|
+
* `node_modules` are not part of the site as an artifact; they are the project
|
|
7
|
+
* shape the CLI hangs tooling on (see the three-ingredient model,
|
|
8
|
+
* kb/framework/architecture/site-foundation-runtime-model.md, Part 1). Treating
|
|
9
|
+
* that scaffolding as part of the site is where most of the confusion in this
|
|
10
|
+
* area comes from, so: nothing here says anything about what a foundation *is*,
|
|
11
|
+
* how it is distributed, or how it reaches a host.
|
|
12
|
+
*
|
|
13
|
+
* The one modality it covers is bundled dev mode against a workspace-local
|
|
14
|
+
* foundation — `uniweb dev` with `foundation:` naming a sibling package. There,
|
|
15
|
+
* the site imports `#foundation`, which `build/src/site/config.js` aliases to
|
|
16
|
+
* the foundation's PACKAGE NAME, so Vite resolves it through node_modules. A
|
|
17
|
+
* linked site (registry ref or URL) never takes that path, and neither does the
|
|
18
|
+
* desktop app, unipress, or the edge — they load a built `dist/entry.js`, or
|
|
19
|
+
* read a content folder directly. The check stays silent for all of them
|
|
20
|
+
* because there is no node_modules entry to look at.
|
|
21
|
+
*
|
|
22
|
+
* What goes wrong in that one modality: a `file:` dependency can be satisfied
|
|
23
|
+
* by a link to the workspace source or by a materialized copy, and a copy is a
|
|
24
|
+
* snapshot carrying its own nested node_modules — so it keeps resolving
|
|
25
|
+
* whatever `@uniweb/*` versions were current when it was taken.
|
|
26
|
+
*
|
|
27
|
+
* It hides well. `uniweb build` resolves the foundation by PATH and reads the
|
|
28
|
+
* workspace source, so the build is green and the tests pass while the dev
|
|
29
|
+
* server serves code from weeks ago. Cheap enough (a few lstats and JSON reads)
|
|
30
|
+
* to run on every `uniweb dev` rather than waiting for someone to suspect it.
|
|
31
|
+
*
|
|
32
|
+
* Reports; never prints. Callers present findings in their own voice.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
|
|
36
|
+
import { join, relative, resolve } from 'node:path'
|
|
37
|
+
import yaml from 'js-yaml'
|
|
38
|
+
import { stripVersionRange } from './dep-survey.js'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The foundation a site declares, or null. Small enough to live beside the one
|
|
42
|
+
* check that needs it rather than becoming another shared module.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} sitePath
|
|
45
|
+
* @returns {string|null}
|
|
46
|
+
*/
|
|
47
|
+
export function readDeclaredFoundation(sitePath) {
|
|
48
|
+
for (const name of ['site.yml', 'site.yaml']) {
|
|
49
|
+
const path = join(sitePath, name)
|
|
50
|
+
if (!existsSync(path)) continue
|
|
51
|
+
try {
|
|
52
|
+
return yaml.load(readFileSync(path, 'utf8'))?.foundation ?? null
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJson(path) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where a package name resolves from a starting directory, walking up through
|
|
70
|
+
* node_modules the way Node does. Returns the directory, or null.
|
|
71
|
+
*/
|
|
72
|
+
function resolvePackageDir(fromDir, packageName) {
|
|
73
|
+
let dir = resolve(fromDir)
|
|
74
|
+
|
|
75
|
+
while (true) {
|
|
76
|
+
const candidate = join(dir, 'node_modules', packageName)
|
|
77
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
78
|
+
|
|
79
|
+
const parent = resolve(dir, '..')
|
|
80
|
+
if (parent === dir) return null
|
|
81
|
+
dir = parent
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check one site against the foundation it declares.
|
|
87
|
+
*
|
|
88
|
+
* @param {Object} site - { name, path }
|
|
89
|
+
* @param {Object} foundation - { name, path } — the workspace source
|
|
90
|
+
* @param {string} workspaceDir - For readable paths in findings
|
|
91
|
+
* @returns {Array<Object>} findings, each { id, severity, site, message, detail, remedy }
|
|
92
|
+
*/
|
|
93
|
+
export function checkSiteInstall(site, foundation, workspaceDir) {
|
|
94
|
+
const findings = []
|
|
95
|
+
const rel = (p) => relative(workspaceDir, p) || '.'
|
|
96
|
+
|
|
97
|
+
const linkPath = join(site.path, 'node_modules', foundation.name)
|
|
98
|
+
|
|
99
|
+
// Nothing installed at all — not this check's business to complain about,
|
|
100
|
+
// and every other check will already be shouting.
|
|
101
|
+
if (!existsSync(linkPath)) {
|
|
102
|
+
return findings
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A symlink points at one source of truth; a real directory is a snapshot.
|
|
106
|
+
const stat = lstatSync(linkPath)
|
|
107
|
+
const isLink = stat.isSymbolicLink()
|
|
108
|
+
|
|
109
|
+
if (!isLink) {
|
|
110
|
+
findings.push({
|
|
111
|
+
id: 'dev-foundation-source-stale',
|
|
112
|
+
severity: 'error',
|
|
113
|
+
site: site.name,
|
|
114
|
+
message: `In dev, this site builds against a copy of "${foundation.name}", not your workspace source.`,
|
|
115
|
+
detail:
|
|
116
|
+
` ${rel(linkPath)} is a directory rather than a link, so it is a snapshot of\n` +
|
|
117
|
+
` ${rel(foundation.path)} taken when it was installed. Edits you make will not\n` +
|
|
118
|
+
` reach the dev server. \`uniweb build\` resolves the foundation by path and reads\n` +
|
|
119
|
+
` your source, so builds stay correct — which is why this is easy to miss.`,
|
|
120
|
+
remedy: 'Reinstall so it links to the workspace source (e.g. pnpm install)',
|
|
121
|
+
})
|
|
122
|
+
} else {
|
|
123
|
+
// Linked, but possibly at the wrong source.
|
|
124
|
+
const target = realpathSync(linkPath)
|
|
125
|
+
const expected = realpathSync(foundation.path)
|
|
126
|
+
if (target !== expected) {
|
|
127
|
+
findings.push({
|
|
128
|
+
id: 'dev-foundation-source-elsewhere',
|
|
129
|
+
severity: 'error',
|
|
130
|
+
site: site.name,
|
|
131
|
+
message: `In dev, this site resolves "${foundation.name}" somewhere other than your workspace source.`,
|
|
132
|
+
detail: ` resolves to: ${rel(target)}\n workspace source: ${rel(expected)}`,
|
|
133
|
+
remedy: 'Reinstall, and check the site\'s package.json file: reference',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// What the site actually reaches, versus what the foundation asks for. These
|
|
139
|
+
// agree through a link and drift through a copy — the drift is what runs.
|
|
140
|
+
const foundationPkg = readJson(join(foundation.path, 'package.json'))
|
|
141
|
+
const declared = { ...foundationPkg?.dependencies, ...foundationPkg?.peerDependencies }
|
|
142
|
+
|
|
143
|
+
for (const [name, spec] of Object.entries(declared)) {
|
|
144
|
+
if (!name.startsWith('@uniweb/')) continue
|
|
145
|
+
// A range says "anything compatible" — only an exact pin is a promise.
|
|
146
|
+
if (stripVersionRange(spec) !== spec) continue
|
|
147
|
+
|
|
148
|
+
const reachedDir = resolvePackageDir(linkPath, name)
|
|
149
|
+
if (!reachedDir) continue
|
|
150
|
+
|
|
151
|
+
const reached = readJson(join(reachedDir, 'package.json'))?.version
|
|
152
|
+
if (reached && reached !== spec) {
|
|
153
|
+
findings.push({
|
|
154
|
+
id: 'dev-foundation-dep-skew',
|
|
155
|
+
severity: 'error',
|
|
156
|
+
site: site.name,
|
|
157
|
+
message: `In dev, "${foundation.name}" compiles against ${name} ${reached}, but declares ${spec}.`,
|
|
158
|
+
detail:
|
|
159
|
+
` declared in ${rel(join(foundation.path, 'package.json'))}: ${spec}\n` +
|
|
160
|
+
` resolved from ${rel(linkPath)}: ${reached}\n` +
|
|
161
|
+
` ${name} is bundled into the foundation, so the dev server runs the version it\n` +
|
|
162
|
+
` reaches here. A build reads the workspace tree and may compile a different one.`,
|
|
163
|
+
remedy: 'Reinstall so the resolved tree matches what is declared',
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return findings
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check every site in the workspace.
|
|
173
|
+
*
|
|
174
|
+
* @param {Array<Object>} sites - [{ name, path, foundation }]
|
|
175
|
+
* @param {Array<Object>} foundations - [{ name, path }]
|
|
176
|
+
* @param {string} workspaceDir
|
|
177
|
+
* @returns {Array<Object>} findings
|
|
178
|
+
*/
|
|
179
|
+
export function checkWorkspaceInstall(sites, foundations, workspaceDir) {
|
|
180
|
+
const findings = []
|
|
181
|
+
|
|
182
|
+
for (const site of sites) {
|
|
183
|
+
const foundation = foundations.find((f) => f.name === site.foundation)
|
|
184
|
+
// An unresolvable foundation name is a different check's finding.
|
|
185
|
+
if (!foundation) continue
|
|
186
|
+
findings.push(...checkSiteInstall(site, foundation, workspaceDir))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return findings
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One-line summary for callers that only want to know whether to speak up.
|
|
194
|
+
* @param {Array<Object>} findings
|
|
195
|
+
* @returns {string|null}
|
|
196
|
+
*/
|
|
197
|
+
export function summarizeInstallFindings(findings) {
|
|
198
|
+
if (!findings.length) return null
|
|
199
|
+
|
|
200
|
+
const sites = [...new Set(findings.map((f) => f.site))]
|
|
201
|
+
const what = findings.some((f) => f.id === 'foundation-dep-skew')
|
|
202
|
+
? 'the installed packages do not match what is declared'
|
|
203
|
+
: 'the foundation is not linked to its workspace source'
|
|
204
|
+
|
|
205
|
+
return `Install is out of date in ${sites.join(', ')} — ${what}. Run your package manager's install, or \`uniweb doctor\` for detail.`
|
|
206
|
+
}
|
|
@@ -26,6 +26,11 @@ index: home
|
|
|
26
26
|
# pages/blog: ../../blog-content # Mount blog content at /blog route
|
|
27
27
|
# layout: ./custom-layout # Custom layout directory
|
|
28
28
|
# collections: ./data # Collections directory
|
|
29
|
+
#
|
|
30
|
+
# To give a mounted route a layout or a title, add a local folder for it
|
|
31
|
+
# (pages/docs/) holding a folder.yml — folder.yml, not page.yml, because the
|
|
32
|
+
# mounted directory is a folder of pages. Whatever that stub leaves unset comes
|
|
33
|
+
# from the mounted directory's own folder.yml, so it need only say what differs.
|
|
29
34
|
|
|
30
35
|
# ─── Data Sources ─────────────────────────────────────────────────────────────
|
|
31
36
|
# Define collections (local markdown folders) or fetch remote/local JSON data.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import { defineSiteConfig } from '@uniweb/build/site'
|
|
2
2
|
|
|
3
|
+
// Options passed here REPLACE the framework's, key by key — except
|
|
4
|
+
// optimizeDeps.include / .exclude, which are added to rather than replaced.
|
|
5
|
+
// So `build: { sourcemap: true }` overrides the framework's whole build block;
|
|
6
|
+
// check what you are replacing before you pass one.
|
|
3
7
|
export default defineSiteConfig()
|