uniweb 0.33.1 → 0.34.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 +8 -8
- package/partials/agents.md +73 -38
- package/src/backend/site-sync.js +22 -22
- package/src/commands/build.js +17 -17
- package/src/commands/clone.js +7 -7
- package/src/commands/content.js +2 -3
- package/src/commands/doctor.js +34 -10
- package/src/commands/i18n.js +53 -39
- package/src/commands/publish.js +28 -6
- package/src/commands/pull.js +26 -25
- package/src/commands/push.js +24 -14
- package/src/commands/validate.js +1 -1
- package/src/framework-index.json +7 -7
- package/src/utils/flag-guard.js +2 -2
- package/src/utils/git.js +8 -2
- package/src/utils/records-guard.js +80 -0
- package/src/utils/schemaless-report.js +4 -4
- package/src/utils/site-data-upload.js +2 -2
- package/templates/site/site.yml.hbs +24 -6
package/src/commands/i18n.js
CHANGED
|
@@ -242,13 +242,13 @@ async function loadSiteConfig(siteRoot) {
|
|
|
242
242
|
async function runExtract(siteRoot, config, args) {
|
|
243
243
|
const verbose = args.includes('--verbose') || args.includes('-v')
|
|
244
244
|
const dryRun = args.includes('--dry-run')
|
|
245
|
-
const
|
|
246
|
-
args.includes('--
|
|
247
|
-
const
|
|
245
|
+
const recordsOnly =
|
|
246
|
+
args.includes('--records-only') || args.includes('--records')
|
|
247
|
+
const noRecords = args.includes('--no-records')
|
|
248
248
|
// --with-collections is now a no-op (collections are included by default)
|
|
249
249
|
|
|
250
|
-
// Extract page content (unless --
|
|
251
|
-
if (!
|
|
250
|
+
// Extract page content (unless --records-only)
|
|
251
|
+
if (!recordsOnly) {
|
|
252
252
|
log(
|
|
253
253
|
`\n${colors.cyan}Extracting translatable content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
|
|
254
254
|
)
|
|
@@ -309,43 +309,43 @@ async function runExtract(siteRoot, config, args) {
|
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
// Extract collection content (by default, skip with --no-
|
|
313
|
-
if (!
|
|
312
|
+
// Extract collection content (by default, skip with --no-records)
|
|
313
|
+
if (!noRecords) {
|
|
314
314
|
log(
|
|
315
|
-
`\n${colors.cyan}Extracting
|
|
315
|
+
`\n${colors.cyan}Extracting record content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
|
|
316
316
|
)
|
|
317
317
|
|
|
318
318
|
// Check if collections exist
|
|
319
319
|
const dataDir = join(siteRoot, 'public', DATA_DIR)
|
|
320
320
|
if (!existsSync(dataDir)) {
|
|
321
|
-
if (
|
|
322
|
-
error('No
|
|
321
|
+
if (recordsOnly) {
|
|
322
|
+
error('No records found. Add entities under entities/ and list them in records.yml.')
|
|
323
323
|
process.exit(1)
|
|
324
324
|
}
|
|
325
|
-
log(`${colors.dim}No
|
|
325
|
+
log(`${colors.dim}No records found in public/data/.${colors.reset}`)
|
|
326
326
|
return
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
try {
|
|
330
|
-
const {
|
|
330
|
+
const { extractRecordManifest, formatSyncReport } =
|
|
331
331
|
await import('@uniweb/build/i18n')
|
|
332
332
|
|
|
333
|
-
const
|
|
333
|
+
const recordManifestPath = join(
|
|
334
334
|
siteRoot,
|
|
335
335
|
config.localesDir,
|
|
336
|
-
'
|
|
336
|
+
'records',
|
|
337
337
|
'manifest.json'
|
|
338
338
|
)
|
|
339
|
-
const isUpdate = existsSync(
|
|
339
|
+
const isUpdate = existsSync(recordManifestPath)
|
|
340
340
|
|
|
341
|
-
const { manifest, report } = await
|
|
341
|
+
const { manifest, report } = await extractRecordManifest(siteRoot, {
|
|
342
342
|
localesDir: config.localesDir,
|
|
343
343
|
dryRun
|
|
344
344
|
})
|
|
345
345
|
|
|
346
346
|
const unitCount = Object.keys(manifest.units).length
|
|
347
347
|
if (unitCount > 0) {
|
|
348
|
-
success(`Extracted ${unitCount} translatable strings from
|
|
348
|
+
success(`Extracted ${unitCount} translatable strings from records`)
|
|
349
349
|
|
|
350
350
|
if (report && isUpdate) {
|
|
351
351
|
log('')
|
|
@@ -356,18 +356,18 @@ async function runExtract(siteRoot, config, args) {
|
|
|
356
356
|
log(`\n${colors.dim}Dry run — no files were modified.${colors.reset}`)
|
|
357
357
|
} else {
|
|
358
358
|
log(
|
|
359
|
-
`\nManifest written to: ${colors.dim}${config.localesDir}/
|
|
359
|
+
`\nManifest written to: ${colors.dim}${config.localesDir}/records/manifest.json${colors.reset}`
|
|
360
360
|
)
|
|
361
361
|
}
|
|
362
362
|
} else {
|
|
363
363
|
log(
|
|
364
|
-
`${colors.dim}No translatable content found in
|
|
364
|
+
`${colors.dim}No translatable content found in records.${colors.reset}`
|
|
365
365
|
)
|
|
366
366
|
}
|
|
367
367
|
} catch (err) {
|
|
368
|
-
error(`
|
|
368
|
+
error(`Record extraction failed: ${err.message}`)
|
|
369
369
|
if (verbose) console.error(err)
|
|
370
|
-
if (
|
|
370
|
+
if (recordsOnly) process.exit(1)
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
}
|
|
@@ -652,7 +652,7 @@ async function runStatusFreeform(siteRoot, config, locale, options = {}) {
|
|
|
652
652
|
const allPaths = [
|
|
653
653
|
...discovered.pages,
|
|
654
654
|
...discovered.pageIds,
|
|
655
|
-
...discovered.
|
|
655
|
+
...discovered.records
|
|
656
656
|
]
|
|
657
657
|
|
|
658
658
|
// Check staleness
|
|
@@ -988,11 +988,11 @@ async function runAudit(siteRoot, config, args) {
|
|
|
988
988
|
* Usage:
|
|
989
989
|
* uniweb i18n init-freeform es pages/about hero
|
|
990
990
|
* uniweb i18n init-freeform es page-ids/installation intro
|
|
991
|
-
* uniweb i18n init-freeform es
|
|
991
|
+
* uniweb i18n init-freeform es entities/article getting-started
|
|
992
992
|
*/
|
|
993
993
|
async function runInitFreeform(siteRoot, config, args) {
|
|
994
994
|
const locale = args[0]
|
|
995
|
-
const pathType = args[1] // pages/about, page-ids/installation,
|
|
995
|
+
const pathType = args[1] // pages/about, page-ids/installation, entities/article
|
|
996
996
|
const sectionId = args[2] // hero, intro, getting-started
|
|
997
997
|
|
|
998
998
|
if (!locale || !pathType || !sectionId) {
|
|
@@ -1001,7 +1001,7 @@ async function runInitFreeform(siteRoot, config, args) {
|
|
|
1001
1001
|
log(' uniweb i18n init-freeform es pages/about hero')
|
|
1002
1002
|
log(' uniweb i18n init-freeform es page-ids/installation intro')
|
|
1003
1003
|
log(
|
|
1004
|
-
` uniweb i18n init-freeform es
|
|
1004
|
+
` uniweb i18n init-freeform es entities/article getting-started${colors.reset}`
|
|
1005
1005
|
)
|
|
1006
1006
|
process.exit(1)
|
|
1007
1007
|
}
|
|
@@ -1057,15 +1057,29 @@ async function runInitFreeform(siteRoot, config, args) {
|
|
|
1057
1057
|
if (sourceContent) break
|
|
1058
1058
|
}
|
|
1059
1059
|
}
|
|
1060
|
-
} else if (pathType.startsWith('
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
)
|
|
1060
|
+
} else if (pathType.startsWith('entities/')) {
|
|
1061
|
+
// ⛔ ADDRESSED BY THE RECORD, matching where the loader reads. The freeform
|
|
1062
|
+
// tree is `entities/<schema dirs>/<slug>.md`; this used to take
|
|
1063
|
+
// `collections/<query>` and write a path nothing read — the loader moved
|
|
1064
|
+
// and this did not.
|
|
1065
|
+
//
|
|
1066
|
+
// The record's CONTENT still lives in a query's materialization, so the
|
|
1067
|
+
// query that covers this schema is resolved rather than named.
|
|
1068
|
+
const poolDirs = pathType.replace('entities/', '')
|
|
1069
|
+
const { resolveQueriesConfig, poolDirsForSchema } = await import('@uniweb/build/uwx')
|
|
1070
|
+
let queryName = null
|
|
1071
|
+
try {
|
|
1072
|
+
const { declarations } = await resolveQueriesConfig(siteRoot)
|
|
1073
|
+
for (const [name, decl] of Object.entries(declarations || {})) {
|
|
1074
|
+
const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
|
|
1075
|
+
if (dirs && dirs.join('/') === poolDirs) { queryName = name; break }
|
|
1076
|
+
}
|
|
1077
|
+
} catch {
|
|
1078
|
+
/* no resolvable config — fall through to the not-found message below */
|
|
1079
|
+
}
|
|
1080
|
+
const dataPath = queryName
|
|
1081
|
+
? join(siteRoot, 'public', 'data', `${queryName}.json`)
|
|
1082
|
+
: join(siteRoot, 'public', 'data', '__none__.json')
|
|
1069
1083
|
|
|
1070
1084
|
if (existsSync(dataPath)) {
|
|
1071
1085
|
const dataRaw = await readFile(dataPath, 'utf-8')
|
|
@@ -1607,8 +1621,8 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1607
1621
|
--freeform (status/prune) Include free-form translation status
|
|
1608
1622
|
--json (status) Output as JSON for translation tools
|
|
1609
1623
|
--by-page (status --missing) Group missing strings by page
|
|
1610
|
-
--
|
|
1611
|
-
--no-
|
|
1624
|
+
--records-only (extract/status/audit) Process only records
|
|
1625
|
+
--no-records (extract/status/audit) Skip records (pages only)
|
|
1612
1626
|
--all-stale (update-hash) Update all stale translations at once
|
|
1613
1627
|
|
|
1614
1628
|
${colors.bright}Configuration:${colors.reset}
|
|
@@ -1638,14 +1652,14 @@ ${colors.bright}File Structure:${colors.reset}
|
|
|
1638
1652
|
.manifest.json Staleness tracking
|
|
1639
1653
|
pages/about/hero.md Translated content for /about page, hero section
|
|
1640
1654
|
page-ids/install/intro.md Translated content by page ID
|
|
1641
|
-
|
|
1655
|
+
entities/article/getting-started.md
|
|
1642
1656
|
|
|
1643
1657
|
${colors.bright}Examples:${colors.reset}
|
|
1644
1658
|
${colors.dim}# Hash-based workflow${colors.reset}
|
|
1645
1659
|
uniweb i18n extract # Extract all translatable strings
|
|
1646
1660
|
uniweb i18n extract --dry-run # Preview without writing
|
|
1647
1661
|
uniweb i18n extract --verbose # Show extracted strings
|
|
1648
|
-
uniweb i18n extract --no-
|
|
1662
|
+
uniweb i18n extract --no-records # Pages only (skip records)
|
|
1649
1663
|
uniweb i18n generate es fr # Create starter files for Spanish and French
|
|
1650
1664
|
uniweb i18n generate --empty # Create files with empty values (for translators)
|
|
1651
1665
|
uniweb i18n generate --force # Overwrite existing locale files
|
|
@@ -1658,7 +1672,7 @@ ${colors.bright}Examples:${colors.reset}
|
|
|
1658
1672
|
${colors.dim}# Free-form workflow (complete section replacement)${colors.reset}
|
|
1659
1673
|
uniweb i18n init-freeform es pages/about hero
|
|
1660
1674
|
uniweb i18n init-freeform es page-ids/installation intro
|
|
1661
|
-
uniweb i18n init-freeform es
|
|
1675
|
+
uniweb i18n init-freeform es entities/article getting-started
|
|
1662
1676
|
uniweb i18n status --freeform # Show free-form translation status
|
|
1663
1677
|
uniweb i18n update-hash es --all-stale # Update hashes after review
|
|
1664
1678
|
uniweb i18n move pages/docs/setup pages/getting-started
|
package/src/commands/publish.js
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
readSiteIdentity
|
|
71
71
|
} from '../utils/site-identity.js'
|
|
72
72
|
import { isNonInteractive, confirm } from '../utils/interactive.js'
|
|
73
|
+
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
73
74
|
import { headProvenance } from '../utils/git.js'
|
|
74
75
|
import {
|
|
75
76
|
makeModelResolver,
|
|
@@ -93,7 +94,7 @@ import {
|
|
|
93
94
|
readPaymentRefusal,
|
|
94
95
|
reportPaymentRefusal
|
|
95
96
|
} from '../backend/payment-handoff.js'
|
|
96
|
-
import {
|
|
97
|
+
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
97
98
|
import { uploadSiteData } from '../utils/site-data-upload.js'
|
|
98
99
|
|
|
99
100
|
const c = {
|
|
@@ -113,7 +114,20 @@ const say = {
|
|
|
113
114
|
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
// Origin-relative serve path → clickable absolute URL
|
|
117
|
+
// Origin-relative serve path → clickable absolute URL.
|
|
118
|
+
//
|
|
119
|
+
// ⭐ THE TWO SHAPES ARE A CONTRACT, NOT AN INCONSISTENCY — ratified 2026-08-29 and
|
|
120
|
+
// documented in the backend's `wire-layer.md` rather than merely observed. A publish
|
|
121
|
+
// returns an ABSOLUTE url when Cloudflare hosts the site (another origin entirely)
|
|
122
|
+
// and an ORIGIN-RELATIVE path when the backend serves it itself, where its own
|
|
123
|
+
// external origin is not reliably self-reportable from behind an ALB.
|
|
124
|
+
//
|
|
125
|
+
// ⇒ So this branch is implementing the contract, not defending against drift. I
|
|
126
|
+
// reported the two shapes as a violation of "finished values only" in collab
|
|
127
|
+
// framework-backend-812b; the backend checked, found the adjacent ruling that
|
|
128
|
+
// explains the relative arm, and ratified both. Do not "fix" it by demanding one
|
|
129
|
+
// shape — the caller's own origin is the missing half on the relative arm, and we
|
|
130
|
+
// are the caller.
|
|
117
131
|
function absolutizeServeUrl(origin, url) {
|
|
118
132
|
if (!url || typeof url !== 'string') return null
|
|
119
133
|
if (/^https?:\/\//.test(url)) return url
|
|
@@ -436,6 +450,14 @@ export async function publish(args = []) {
|
|
|
436
450
|
// Non-local @std/registry Model schemas resolve through the backend (same as push).
|
|
437
451
|
const resolveModel = makeModelResolver({ client, offline: false })
|
|
438
452
|
|
|
453
|
+
// ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
|
|
454
|
+
// destructive — a placeholder file, created meaning to fill it in — so the count
|
|
455
|
+
// is reported and confirmed before anything is sent.
|
|
456
|
+
{
|
|
457
|
+
const guard = await guardEmptyRecords({ siteDir, args, warn: say.warn, note: say.dim })
|
|
458
|
+
if (!guard.ok) return { exitCode: 1 }
|
|
459
|
+
}
|
|
460
|
+
|
|
439
461
|
// 3. Partition collections by schema presence (a first emit reads `schemaless`
|
|
440
462
|
// — collections with no data schema, delivered statically via the ball).
|
|
441
463
|
let probe
|
|
@@ -456,7 +478,7 @@ export async function publish(args = []) {
|
|
|
456
478
|
// A product decision the author is usually making unknowingly — say it at warn
|
|
457
479
|
// level, not dim among everything else. See the helper for what the old
|
|
458
480
|
// message got wrong.
|
|
459
|
-
|
|
481
|
+
reportSchemalessQueries(probe.schemaless, say)
|
|
460
482
|
const localAssets = probe.localAssets || []
|
|
461
483
|
|
|
462
484
|
// 3a. A clone with no `$uuid` is bound to no backend site, so every cached map
|
|
@@ -579,7 +601,7 @@ export async function publish(args = []) {
|
|
|
579
601
|
// `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
|
|
580
602
|
// makes an absent key non-breaking by construction.
|
|
581
603
|
if (ball) {
|
|
582
|
-
say.info('Uploading
|
|
604
|
+
say.info('Uploading schema-less record data…')
|
|
583
605
|
try {
|
|
584
606
|
const r = await uploadSiteData({
|
|
585
607
|
apiBase: client.origin,
|
|
@@ -595,9 +617,9 @@ export async function publish(args = []) {
|
|
|
595
617
|
for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
|
|
596
618
|
return { exitCode: 1 }
|
|
597
619
|
}
|
|
598
|
-
say.dim(`
|
|
620
|
+
say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
|
|
599
621
|
} catch (err) {
|
|
600
|
-
say.err(`
|
|
622
|
+
say.err(`Record data upload failed: ${err.message}`)
|
|
601
623
|
return { exitCode: 1 }
|
|
602
624
|
}
|
|
603
625
|
}
|
package/src/commands/pull.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - content lane → `siteContentDocumentToProject` (site.yml/theme.yml/head.html,
|
|
12
12
|
* pages/**, layout/**), and
|
|
13
|
-
* - folder lane → `
|
|
13
|
+
* - folder lane → `recordsToProject` (the folder + record files).
|
|
14
14
|
*
|
|
15
15
|
* Pull is a CHECKOUT, not a merge — the "git-pull-like" it used to claim here was
|
|
16
16
|
* misleading. It reconciles the working tree to the backend: section bodies are
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
*
|
|
37
37
|
* Usage:
|
|
38
38
|
* uniweb pull GET both lanes, project to files, prune orphans
|
|
39
|
-
* uniweb pull --no-
|
|
39
|
+
* uniweb pull --no-records Pull pages only; skip the folder (records) lane
|
|
40
40
|
* uniweb pull --no-delete Project, but keep files with no backend item
|
|
41
41
|
* uniweb pull --merge Three-way merge local changes with the backend's
|
|
42
42
|
* uniweb pull --force Pull over uncommitted local changes (discards them)
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
* A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
|
|
53
53
|
* clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
|
|
54
54
|
* `entities/<uuid>.json`); `readPullDocuments` reads the entity files out of it, with
|
|
55
|
-
* a tolerant JSON fallback (`extractDocument` / `
|
|
55
|
+
* a tolerant JSON fallback (`extractDocument` / `splitRecordsPull`). Verified live
|
|
56
56
|
* against the playground backend, 2026-06-17.
|
|
57
57
|
*/
|
|
58
58
|
|
|
@@ -71,12 +71,11 @@ import yaml from 'js-yaml'
|
|
|
71
71
|
import { downloadMissingAssets } from '../backend/asset-download.js'
|
|
72
72
|
import {
|
|
73
73
|
siteContentDocumentToProject,
|
|
74
|
-
|
|
75
|
-
resolveCollectionsConfig,
|
|
74
|
+
recordsToProject,
|
|
76
75
|
readZip,
|
|
77
76
|
computeUnitHashes,
|
|
78
77
|
collectUnitUuids,
|
|
79
|
-
|
|
78
|
+
collectQueryUuids
|
|
80
79
|
} from '@uniweb/build/uwx'
|
|
81
80
|
import {
|
|
82
81
|
readWritten,
|
|
@@ -86,7 +85,7 @@ import {
|
|
|
86
85
|
import {
|
|
87
86
|
makeModelResolver,
|
|
88
87
|
rebankSyncHashes,
|
|
89
|
-
|
|
88
|
+
writeQueryUuids,
|
|
90
89
|
mergeBaseVersions,
|
|
91
90
|
mergeItemBaseVersions,
|
|
92
91
|
writeUnitBases,
|
|
@@ -203,7 +202,7 @@ export function extractDocument(payload) {
|
|
|
203
202
|
// Split a collections pull (the folder + the entities it references) into the
|
|
204
203
|
// folder document and the record documents. Tolerant of an array, an
|
|
205
204
|
// `{ entities }` / `{ documents }` list, or an explicit `{ folder, records }`.
|
|
206
|
-
export function
|
|
205
|
+
export function splitRecordsPull(payload) {
|
|
207
206
|
if (payload?.folder)
|
|
208
207
|
return { folderDoc: payload.folder, recordDocs: payload.records || [] }
|
|
209
208
|
const list = Array.isArray(payload)
|
|
@@ -240,7 +239,7 @@ export function readPullDocuments(buf) {
|
|
|
240
239
|
}
|
|
241
240
|
return docs
|
|
242
241
|
}
|
|
243
|
-
// JSON fallback — flatten any envelope
|
|
242
|
+
// JSON fallback — flatten any envelope splitRecordsPull understands into a
|
|
244
243
|
// flat `$`-document list (a raw doc, a list, `{entities}`/`{documents}`, or
|
|
245
244
|
// `{folder, records}`).
|
|
246
245
|
let payload
|
|
@@ -519,8 +518,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
519
518
|
const dryRun = args.includes('--dry-run')
|
|
520
519
|
const tokenFlag = flagValue(args, '--token')
|
|
521
520
|
const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
|
|
522
|
-
const
|
|
523
|
-
args.includes('--no-
|
|
521
|
+
const noRecords =
|
|
522
|
+
args.includes('--no-records') || args.includes('--content-only')
|
|
524
523
|
const force = args.includes('--force')
|
|
525
524
|
const mergeMode = args.includes('--merge')
|
|
526
525
|
|
|
@@ -598,7 +597,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
598
597
|
info(
|
|
599
598
|
`Dry run — would pull content from ${colors.dim}${client.origin}${colors.reset}`
|
|
600
599
|
)
|
|
601
|
-
if (!
|
|
600
|
+
if (!noRecords) info(`Dry run — would also pull records`)
|
|
602
601
|
return { exitCode: 0 }
|
|
603
602
|
}
|
|
604
603
|
|
|
@@ -691,8 +690,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
691
690
|
writeItemUuids(siteDir, collectUnitUuids(siteDoc))
|
|
692
691
|
// The collections section's identity has no file to live in either — same
|
|
693
692
|
// reason, same remedy, keyed by name. A pull is the other route by which a
|
|
694
|
-
// copy can recover it (see
|
|
695
|
-
|
|
693
|
+
// copy can recover it (see readQueryUuids).
|
|
694
|
+
writeQueryUuids(siteDir, collectQueryUuids(siteDoc))
|
|
696
695
|
// Bring the media down BEFORE projecting: a newly-landed asset gains a map
|
|
697
696
|
// entry, and the projection reads that map to put authored paths back. Run
|
|
698
697
|
// after, and this pull's new assets would project as URLs and only restore
|
|
@@ -777,13 +776,13 @@ export async function pull(args = [], deps = {}) {
|
|
|
777
776
|
// Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
|
|
778
777
|
// (the backend resolves the site's `@uniweb/folder` from it; the framework never
|
|
779
778
|
// holds a folder uuid). Models are resolved by name (async) up front, so
|
|
780
|
-
//
|
|
781
|
-
if (!
|
|
782
|
-
const folder = await getDocs('
|
|
779
|
+
// recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
|
|
780
|
+
if (!noRecords) {
|
|
781
|
+
const folder = await getDocs('records', () =>
|
|
783
782
|
client.pullFolder(siteContentUuid, { etag: etagFolder })
|
|
784
783
|
)
|
|
785
784
|
if (folder && !folder.notModified && folder.docs?.length) {
|
|
786
|
-
const { folderDoc, recordDocs } =
|
|
785
|
+
const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
|
|
787
786
|
const resolveModel = makeModelResolver({ client })
|
|
788
787
|
const declByModel = new Map()
|
|
789
788
|
for (const model of [
|
|
@@ -795,18 +794,20 @@ export async function pull(args = [], deps = {}) {
|
|
|
795
794
|
note(`! could not resolve model ${model}: ${err.message}`)
|
|
796
795
|
}
|
|
797
796
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
797
|
+
// ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
|
|
798
|
+
// `$model` names the pool folder — not by any query that happens to select
|
|
799
|
+
// it. `recordsToProject` reads `site.yml::$org` itself, so a `@/x`
|
|
800
|
+
// model the producer resolved to `@org/x` is placed back where the author
|
|
801
|
+
// wrote it.
|
|
802
|
+
const report = recordsToProject({
|
|
802
803
|
folderDoc,
|
|
803
804
|
recordDocs,
|
|
804
805
|
siteRoot: siteDir,
|
|
805
806
|
opts: {
|
|
806
|
-
resolveDeclaration: (name) => declByModel.get(name) || null
|
|
807
|
-
collectionsConfig
|
|
807
|
+
resolveDeclaration: (name) => declByModel.get(name) || null
|
|
808
808
|
}
|
|
809
809
|
})
|
|
810
|
+
if (report.records === 'updated') info('Wrote records.yml')
|
|
810
811
|
records += report.placed.length + report.updated.length
|
|
811
812
|
for (const s of report.skipped)
|
|
812
813
|
note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
|
|
@@ -840,7 +841,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
840
841
|
siteDir,
|
|
841
842
|
[
|
|
842
843
|
...wrote,
|
|
843
|
-
...['site.yml', 'theme.yml', 'head.html', '
|
|
844
|
+
...['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml'].map((f) =>
|
|
844
845
|
join(siteDir, f)
|
|
845
846
|
)
|
|
846
847
|
],
|
package/src/commands/push.js
CHANGED
|
@@ -69,7 +69,7 @@ import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
|
|
|
69
69
|
import { BackendClient } from '../backend/client.js'
|
|
70
70
|
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
71
71
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
72
|
-
import {
|
|
72
|
+
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
73
73
|
import { readOrgFlag } from '../utils/args.js'
|
|
74
74
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
75
75
|
import {
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
readSiteIdentity
|
|
78
78
|
} from '../utils/site-identity.js'
|
|
79
79
|
import { confirm } from '../utils/interactive.js'
|
|
80
|
+
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
80
81
|
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
81
82
|
import {
|
|
82
83
|
makeModelResolver,
|
|
@@ -85,7 +86,7 @@ import {
|
|
|
85
86
|
readItemBaseVersions,
|
|
86
87
|
readItemUuids,
|
|
87
88
|
readFolderItemUuids,
|
|
88
|
-
|
|
89
|
+
readQueryUuids,
|
|
89
90
|
ensureItemUuids,
|
|
90
91
|
ensureSiteExists,
|
|
91
92
|
clearRemoteSyncStateIfUnbound,
|
|
@@ -369,6 +370,15 @@ export async function push(args = [], deps = {}) {
|
|
|
369
370
|
// It also has to be this emit that carries `assetRewrite` below: the push cache
|
|
370
371
|
// stores hashes of the REWRITTEN content, so the emit compared against it must
|
|
371
372
|
// rewrite too, or every entity reads as changed forever.
|
|
373
|
+
// ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
|
|
374
|
+
// destructive — a placeholder file, created meaning to fill it in — so the count
|
|
375
|
+
// is reported and confirmed before anything is sent. The format stays honest;
|
|
376
|
+
// the asking happens here.
|
|
377
|
+
if (!dryRun) {
|
|
378
|
+
const guard = await guardEmptyRecords({ siteDir, args, warn, note })
|
|
379
|
+
if (!guard.ok) return { exitCode: 1 }
|
|
380
|
+
}
|
|
381
|
+
|
|
372
382
|
let assetRewrite = null
|
|
373
383
|
let assetIds = null
|
|
374
384
|
if (!output && !dryRun) {
|
|
@@ -475,9 +485,9 @@ export async function push(args = [], deps = {}) {
|
|
|
475
485
|
pkg = await emitSyncPackages(siteDir, {
|
|
476
486
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
477
487
|
folderItemUuids: readFolderItemUuids(siteDir),
|
|
478
|
-
// Identity for the `
|
|
488
|
+
// Identity for the `queries` section — see readQueryUuids. Keyed by
|
|
479
489
|
// name, because a declaration has no file for a path-keyed map to hold.
|
|
480
|
-
|
|
490
|
+
queryUuids: readQueryUuids(siteDir),
|
|
481
491
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
482
492
|
...(asOrg ? { org: asOrg } : {}),
|
|
483
493
|
...(foundationDir ? { foundationDir } : {}),
|
|
@@ -509,14 +519,14 @@ export async function push(args = [], deps = {}) {
|
|
|
509
519
|
error(`Could not build the sync package: ${err.message}`)
|
|
510
520
|
return { exitCode: 2 }
|
|
511
521
|
}
|
|
512
|
-
const { siteContent,
|
|
522
|
+
const { siteContent, records, siteContentUuid, warnings, skipped } = pkg
|
|
513
523
|
log('')
|
|
514
524
|
for (const w of warnings) note(`! ${w}`)
|
|
515
525
|
// Warn level, not dim: this is the author choosing entities vs static files.
|
|
516
|
-
|
|
526
|
+
reportSchemalessQueries(pkg.schemaless, { warn, dim: note })
|
|
517
527
|
|
|
518
528
|
const totalEntities =
|
|
519
|
-
(siteContent?.entityCount || 0) + (
|
|
529
|
+
(siteContent?.entityCount || 0) + (records?.entityCount || 0)
|
|
520
530
|
|
|
521
531
|
// Nothing changed since the last push — the backend is already up to date.
|
|
522
532
|
if (totalEntities === 0) {
|
|
@@ -529,10 +539,10 @@ export async function push(args = [], deps = {}) {
|
|
|
529
539
|
info(
|
|
530
540
|
`${colors.bright}site-content${colors.reset} → ${siteContent.models.join(', ')}`
|
|
531
541
|
)
|
|
532
|
-
if (
|
|
533
|
-
const n =
|
|
542
|
+
if (records) {
|
|
543
|
+
const n = records.entityCount
|
|
534
544
|
info(
|
|
535
|
-
`${colors.bright}
|
|
545
|
+
`${colors.bright}records${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${records.models.join(', ')}`
|
|
536
546
|
)
|
|
537
547
|
}
|
|
538
548
|
if (skipped) note(`${skipped} unchanged, skipped`)
|
|
@@ -542,11 +552,11 @@ export async function push(args = [], deps = {}) {
|
|
|
542
552
|
const base = output.replace(/\.uwx$/, '')
|
|
543
553
|
if (siteContent)
|
|
544
554
|
writeFileSync(resolve(`${base}.site-content.uwx`), siteContent.buffer)
|
|
545
|
-
if (
|
|
546
|
-
writeFileSync(resolve(`${base}.
|
|
555
|
+
if (records)
|
|
556
|
+
writeFileSync(resolve(`${base}.records.uwx`), records.buffer)
|
|
547
557
|
const lanes = [
|
|
548
558
|
siteContent && 'site-content',
|
|
549
|
-
|
|
559
|
+
records && 'records'
|
|
550
560
|
].filter(Boolean)
|
|
551
561
|
success(`Wrote ${lanes.join(' + ')} .uwx — not submitted`)
|
|
552
562
|
return { exitCode: 0 }
|
|
@@ -558,7 +568,7 @@ export async function push(args = [], deps = {}) {
|
|
|
558
568
|
`Dry run — would ${verb} content at ${colors.dim}${client.origin}${colors.reset}`
|
|
559
569
|
)
|
|
560
570
|
}
|
|
561
|
-
if (
|
|
571
|
+
if (records) {
|
|
562
572
|
info(
|
|
563
573
|
`Dry run — would push the folder at ${colors.dim}${client.origin}${colors.reset}`
|
|
564
574
|
)
|
package/src/commands/validate.js
CHANGED
|
@@ -269,7 +269,7 @@ export async function validate(args = []) {
|
|
|
269
269
|
return { exitCode: 0 }
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
// The data pipeline (collectSiteContent /
|
|
272
|
+
// The data pipeline (collectSiteContent / processQueries) prints progress
|
|
273
273
|
// via console.log. Route that to stderr while the engine runs so stdout stays
|
|
274
274
|
// clean — pure JSON for `--json`, just the report otherwise. `log` captured
|
|
275
275
|
// the original stdout writer at module load, so our own output is unaffected.
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-29T19:59:22.061Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.30.0",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"deps": []
|
|
28
28
|
},
|
|
29
29
|
"@uniweb/core": {
|
|
30
|
-
"version": "0.
|
|
30
|
+
"version": "0.14.0",
|
|
31
31
|
"path": "framework/core",
|
|
32
32
|
"deps": [
|
|
33
33
|
"@uniweb/semantic-parser",
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
"deps": []
|
|
41
41
|
},
|
|
42
42
|
"@uniweb/icons": {
|
|
43
|
-
"version": "0.4.
|
|
43
|
+
"version": "0.4.5",
|
|
44
44
|
"path": "framework/icons",
|
|
45
45
|
"deps": [
|
|
46
46
|
"@uniweb/core"
|
|
47
47
|
]
|
|
48
48
|
},
|
|
49
49
|
"@uniweb/kit": {
|
|
50
|
-
"version": "0.
|
|
50
|
+
"version": "0.15.0",
|
|
51
51
|
"path": "framework/kit",
|
|
52
52
|
"deps": [
|
|
53
53
|
"@uniweb/core",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"deps": []
|
|
67
67
|
},
|
|
68
68
|
"@uniweb/projections": {
|
|
69
|
-
"version": "0.5.
|
|
69
|
+
"version": "0.5.2",
|
|
70
70
|
"path": "framework/projections",
|
|
71
71
|
"deps": [
|
|
72
72
|
"@uniweb/content-writer",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
]
|
|
75
75
|
},
|
|
76
76
|
"@uniweb/runtime": {
|
|
77
|
-
"version": "0.13.
|
|
77
|
+
"version": "0.13.2",
|
|
78
78
|
"path": "framework/runtime",
|
|
79
79
|
"deps": [
|
|
80
80
|
"@uniweb/core",
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -77,12 +77,12 @@ const VERBS = {
|
|
|
77
77
|
pull: [
|
|
78
78
|
'--backend', '--content-only', '--dry-run', '--force', '--merge',
|
|
79
79
|
'--no-assets',
|
|
80
|
-
'--no-
|
|
80
|
+
'--no-records', '--no-delete', '--no-prune', '--registry', '--token',
|
|
81
81
|
// via backend/site-sync.js (the owner resolver) and utils/conformance.js
|
|
82
82
|
'--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
|
|
83
83
|
],
|
|
84
84
|
clone: [
|
|
85
|
-
'--backend', '--content-only', '--no-assets', '--no-
|
|
85
|
+
'--backend', '--content-only', '--no-assets', '--no-records', '--path',
|
|
86
86
|
'--project', '--registry', '--token', '--org', '--as-org'
|
|
87
87
|
],
|
|
88
88
|
register: [
|
package/src/utils/git.js
CHANGED
|
@@ -32,11 +32,17 @@ import yaml from 'js-yaml'
|
|
|
32
32
|
* rather than assuming the defaults.
|
|
33
33
|
*/
|
|
34
34
|
export function siteContentRoots(siteDir) {
|
|
35
|
+
// ⚠️ `queries.yml` IS AT THE SITE ROOT, and that is why it must be named here.
|
|
36
|
+
// Its predecessor lived at `collections/collections.yml`, so the `collections`
|
|
37
|
+
// root added below already covered it and the bare `'collections.yml'` entry
|
|
38
|
+
// that used to sit in this list resolved to a path no site ever had. A
|
|
39
|
+
// root-level file has no directory entry standing in for it.
|
|
35
40
|
const roots = new Set([
|
|
36
41
|
'site.yml',
|
|
37
42
|
'theme.yml',
|
|
38
43
|
'head.html',
|
|
39
|
-
'
|
|
44
|
+
'queries.yml',
|
|
45
|
+
'records.yml',
|
|
40
46
|
'locales'
|
|
41
47
|
])
|
|
42
48
|
let paths = {}
|
|
@@ -48,7 +54,7 @@ export function siteContentRoots(siteDir) {
|
|
|
48
54
|
}
|
|
49
55
|
roots.add(paths.pages || 'pages')
|
|
50
56
|
roots.add(paths.layout || 'layout')
|
|
51
|
-
roots.add(paths.
|
|
57
|
+
roots.add(paths.entities || 'entities')
|
|
52
58
|
return [...roots]
|
|
53
59
|
}
|
|
54
60
|
|