@uniweb/build 0.55.1 → 0.57.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 +7 -7
- package/src/content/index.js +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/entity-pool.js +136 -42
- package/src/site/index.js +2 -2
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +28 -149
- package/src/uwx/backfill.js +31 -4
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +29 -17
- package/src/uwx/records-project.js +131 -78
- package/src/uwx/records.js +324 -203
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +34 -15
- package/src/uwx/site.js +80 -90
- package/src/uwx/sync-package.js +23 -15
- package/src/uwx/sync-store.js +351 -0
- package/src/validate-data.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,15 +57,15 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/schemas": "^0.3.3",
|
|
61
60
|
"@uniweb/content-reader": "^1.2.5",
|
|
61
|
+
"@uniweb/schemas": "^0.3.3",
|
|
62
62
|
"@uniweb/content-writer": "^0.3.4",
|
|
63
|
-
"@uniweb/projections": "^0.7.3",
|
|
64
63
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
65
|
-
"@uniweb/theming": "^0.1.15"
|
|
64
|
+
"@uniweb/theming": "^0.1.15",
|
|
65
|
+
"@uniweb/projections": "^0.7.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.27.
|
|
68
|
+
"@uniweb/runtime": "^0.27.3"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/core": "^0.29.7",
|
|
78
|
+
"@uniweb/runtime": "^0.27.3"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/content/index.js
CHANGED
|
@@ -21,6 +21,9 @@ export {
|
|
|
21
21
|
writeQueryFiles,
|
|
22
22
|
getQueryLastModified,
|
|
23
23
|
} from '../site/query-processor.js'
|
|
24
|
+
// Where a site's records live — `paths.records`, else `records/` — so a caller
|
|
25
|
+
// holding its own config (a document's, not a `site.yml`) asks the one resolver.
|
|
26
|
+
export { resolveRecordsDir } from '../site/entity-pool.js'
|
|
24
27
|
export {
|
|
25
28
|
parseFetchConfig,
|
|
26
29
|
executeFetch,
|
package/src/i18n/data-strings.js
CHANGED
|
@@ -31,7 +31,7 @@ export const HEURISTIC_SKIP_FIELDS = new Set([
|
|
|
31
31
|
'pmid', 'bibtex', 'pdf', 'code', 'data', 'slides', 'video',
|
|
32
32
|
'repository', 'caseStudy', 'website', 'avatar', 'image',
|
|
33
33
|
'thumbnail', 'currency', 'order', 'hidden', 'current',
|
|
34
|
-
'featured', 'published', 'allDay', 'remote', 'hybrid',
|
|
34
|
+
'featured', 'published', 'draft', 'allDay', 'remote', 'hybrid',
|
|
35
35
|
'noindex', 'corresponding', 'required', 'virtual',
|
|
36
36
|
'lastModified', 'date', 'updated', 'posted', 'submitted',
|
|
37
37
|
'accepted', 'startDate', 'endDate', 'deadline',
|
package/src/i18n/freeform.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* locales/freeform/{locale}/
|
|
11
11
|
* pages/{pageRoute}/{stableId}.md - By route
|
|
12
12
|
* page-ids/{pageId}/{stableId}.md - By page ID (stable)
|
|
13
|
-
*
|
|
13
|
+
* records/{schema dirs}/{slug}.md - Records
|
|
14
14
|
*
|
|
15
15
|
* Resolution order for sections:
|
|
16
16
|
* 1. page-ids/{pageId}/{stableId}.md (if page has id:)
|
|
@@ -22,7 +22,7 @@ import { readFile, readdir, stat } from 'fs/promises'
|
|
|
22
22
|
import { existsSync } from 'node:fs'
|
|
23
23
|
import { join, relative, dirname, sep } from 'node:path'
|
|
24
24
|
import yaml from 'js-yaml'
|
|
25
|
-
import { poolDirsForSchema,
|
|
25
|
+
import { poolDirsForSchema, schemaForPoolDirs, RECORDS_DIR } from '../site/entity-pool.js'
|
|
26
26
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
27
27
|
import { computeSourceHash } from './freeform-manifest.js'
|
|
28
28
|
|
|
@@ -101,13 +101,13 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Load a free-form translation for one
|
|
104
|
+
* Load a free-form translation for one RECORD.
|
|
105
105
|
*
|
|
106
|
-
* Path:
|
|
106
|
+
* Path: records/{schema dirs}/{slug}.md — mirroring the records directory exactly.
|
|
107
107
|
*
|
|
108
|
-
* ⛔ KEYED BY THE
|
|
108
|
+
* ⛔ KEYED BY THE RECORD, NOT BY A QUERY. It used to be
|
|
109
109
|
* `collections/{queryName}/{slug}.md`, which was fine only while a
|
|
110
|
-
* collection was also a directory of files. With `
|
|
110
|
+
* collection was also a directory of files. With `records/{schema}/` holding records,
|
|
111
111
|
* TWO queries can cover one schema — so a query-keyed path would make an author
|
|
112
112
|
* write the same translation once per query, and finding neither from the other.
|
|
113
113
|
* ⚠️ And a query is renameable where a record's model is not: keying on the query
|
|
@@ -185,7 +185,7 @@ async function discoverMarkdownFiles(dir, baseDir) {
|
|
|
185
185
|
* register a translation that has no manifest entry yet.
|
|
186
186
|
*
|
|
187
187
|
* ⭐ Each path is relative to the locale's free-form directory — `pages/about/story.md`,
|
|
188
|
-
* `page-ids/<id>/intro.md`, `
|
|
188
|
+
* `page-ids/<id>/intro.md`, `records/article/hello.md` — the form the manifest keys a
|
|
189
189
|
* translation by and `freeformPathsFor` derives. ⛔ Until 2026-09-14 each was relative
|
|
190
190
|
* to its own subdirectory (`about/story.md`), so none matched a manifest key: the build
|
|
191
191
|
* registered no new file, and a status counted every stale or orphaned file as up to date.
|
|
@@ -217,10 +217,10 @@ export async function discoverFreeformTranslations(locale, localesDir) {
|
|
|
217
217
|
result.pageIds = (await discoverMarkdownFiles(pageIdsDir, freeformDir)).sort()
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
// Discover
|
|
221
|
-
const
|
|
222
|
-
if (existsSync(
|
|
223
|
-
result.records = (await discoverMarkdownFiles(
|
|
220
|
+
// Discover record translations
|
|
221
|
+
const recordsDir = join(freeformDir, RECORDS_DIR)
|
|
222
|
+
if (existsSync(recordsDir)) {
|
|
223
|
+
result.records = (await discoverMarkdownFiles(recordsDir, freeformDir)).sort()
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
return result
|
|
@@ -246,7 +246,7 @@ export async function getFreeformFileMeta(filePath) {
|
|
|
246
246
|
* Parse a free-form translation file path to extract metadata
|
|
247
247
|
*
|
|
248
248
|
* @param {string} relativePath - Path relative to locale's freeform dir
|
|
249
|
-
* @returns {Object} { type, pageRoute?, pageId?,
|
|
249
|
+
* @returns {Object} { type, pageRoute?, pageId?, schema?, stableId, slug? }
|
|
250
250
|
*/
|
|
251
251
|
export function parseFreeformPath(relativePath) {
|
|
252
252
|
const parts = relativePath.split('/')
|
|
@@ -266,11 +266,12 @@ export function parseFreeformPath(relativePath) {
|
|
|
266
266
|
return { type: 'pageId', pageId, stableId }
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
if (parts[0] ===
|
|
270
|
-
//
|
|
269
|
+
if (parts[0] === RECORDS_DIR) {
|
|
270
|
+
// records/std/person/ada.md → { type: 'record', schema: '@std/person', slug: 'ada' }
|
|
271
|
+
// ⛔ This returned `queryName: parts[1]` until 2026-09-21 — the first schema
|
|
272
|
+
// segment (`std` above), and never a query's name. Nothing read it.
|
|
271
273
|
const slug = parts[parts.length - 1].replace('.md', '')
|
|
272
|
-
|
|
273
|
-
return { type: 'record', queryName, slug }
|
|
274
|
+
return { type: 'record', schema: schemaForPoolDirs(parts.slice(1, -1)), slug }
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
return { type: 'unknown', relativePath }
|
|
@@ -343,7 +344,7 @@ function freeformSections(siteContent) {
|
|
|
343
344
|
* - `canJudge(path)` — whether this content can say a translation at `path` is
|
|
344
345
|
* orphaned. ⛔ Only a page section's (`pages/…`, `page-ids/…`), and not one for a page
|
|
345
346
|
* whose sections the content does not carry — a prerendered site with split content
|
|
346
|
-
* rewrites `site-content.json` without them. A record's (`
|
|
347
|
+
* rewrites `site-content.json` without them. A record's (`records/…`) is read from
|
|
347
348
|
* records this content does not hold. A check that cannot see the source must never
|
|
348
349
|
* call its translation orphaned: `prune --freeform` deletes what it is told is.
|
|
349
350
|
*
|
|
@@ -413,10 +414,10 @@ export function buildFreeformPath(section, page, preferPageId = true) {
|
|
|
413
414
|
*
|
|
414
415
|
* @param {string} schema - the entity's model ref (`@/name` or `@org/name`)
|
|
415
416
|
* @param {string} slug - the entity's slug
|
|
416
|
-
* @returns {string|null} e.g. `
|
|
417
|
+
* @returns {string|null} e.g. `records/article/getting-started.md`, or null for a
|
|
417
418
|
* ref this layout cannot express
|
|
418
419
|
*/
|
|
419
420
|
export function buildFreeformRecordPath(schema, slug) {
|
|
420
421
|
const dirs = poolDirsForSchema(schema)
|
|
421
|
-
return dirs ? `${
|
|
422
|
+
return dirs ? `${RECORDS_DIR}/${dirs.join('/')}/${slug}.md` : null
|
|
422
423
|
}
|
package/src/i18n/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
buildLocalizedRecords,
|
|
22
22
|
getRecordLocales,
|
|
23
23
|
translateRecordData,
|
|
24
|
-
|
|
24
|
+
RECORD_LOCALES_DIR
|
|
25
25
|
} from './records.js'
|
|
26
26
|
import { resolveDefaultLocale } from '@uniweb/core'
|
|
27
27
|
import { generateSearchIndex } from '@uniweb/projections'
|
|
@@ -75,7 +75,7 @@ export {
|
|
|
75
75
|
buildLocalizedRecords,
|
|
76
76
|
getRecordLocales,
|
|
77
77
|
translateRecordData,
|
|
78
|
-
|
|
78
|
+
RECORD_LOCALES_DIR,
|
|
79
79
|
|
|
80
80
|
// Locale resolution
|
|
81
81
|
getAvailableLocales,
|
|
@@ -234,7 +234,7 @@ export async function extractRecordManifest(siteRoot, options = {}) {
|
|
|
234
234
|
const manifest = await extractRecordContent(siteRoot)
|
|
235
235
|
|
|
236
236
|
// Ensure collections locales directory exists
|
|
237
|
-
const recordLocalesDir = join(siteRoot, localesDir,
|
|
237
|
+
const recordLocalesDir = join(siteRoot, localesDir, RECORD_LOCALES_DIR)
|
|
238
238
|
if (!existsSync(recordLocalesDir)) {
|
|
239
239
|
await mkdir(recordLocalesDir, { recursive: true })
|
|
240
240
|
}
|
|
@@ -517,7 +517,7 @@ async function warnAboutFreeformIssues(locale, freeformDir, siteContent) {
|
|
|
517
517
|
}
|
|
518
518
|
|
|
519
519
|
// Check for orphaned translations — only those this content can judge: never a
|
|
520
|
-
// record's (`
|
|
520
|
+
// record's (`records/…`), which is read from records the site content does not hold.
|
|
521
521
|
const orphaned = await getOrphanedTranslations(freeformDir, validPaths)
|
|
522
522
|
for (const item of orphaned) {
|
|
523
523
|
if (!canJudge(item.path)) continue
|
package/src/i18n/records.js
CHANGED
|
@@ -23,7 +23,7 @@ import { loadFreeformRecord } from './freeform.js'
|
|
|
23
23
|
// is not, wherever the value came from. Moved rather than copied: two tuned
|
|
24
24
|
// denylists would drift, and drift here is silent.
|
|
25
25
|
import { resolveQueriesConfig } from '../site/queries-config.js'
|
|
26
|
-
import { poolDirsForSchema, schemaForPoolDirs,
|
|
26
|
+
import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
|
|
27
27
|
import {
|
|
28
28
|
NON_TRANSLATABLE_TYPES,
|
|
29
29
|
HEURISTIC_SKIP_FIELDS,
|
|
@@ -37,7 +37,11 @@ import {
|
|
|
37
37
|
// contexts were keyed by the QUERY, so two queries over one schema produced two
|
|
38
38
|
// entries for one record, each invisible from the other, and a renamed query
|
|
39
39
|
// orphaned every translation under it.
|
|
40
|
-
|
|
40
|
+
//
|
|
41
|
+
// ⚠️ `RECORD_LOCALES_DIR`, and not `RECORDS_DIR`, since 2026-09-21: that name is the
|
|
42
|
+
// site's own `records/` directory now (`site/entity-pool.js`), and this one is
|
|
43
|
+
// `locales/records/` — two directories, one word, so the constant says which.
|
|
44
|
+
export const RECORD_LOCALES_DIR = 'records'
|
|
41
45
|
|
|
42
46
|
// ---------------------------------------------------------------------------
|
|
43
47
|
// Schema resolution
|
|
@@ -72,7 +76,7 @@ async function resolveSchema(queryName, siteRoot) {
|
|
|
72
76
|
// an author to write into it was the one remaining place the framework
|
|
73
77
|
// contradicted its own rule that `collections/` is the only way to provide
|
|
74
78
|
// structured data. The schema describes the source, so it lives with it.
|
|
75
|
-
const companionPath = join(siteRoot,
|
|
79
|
+
const companionPath = join(resolveRecordsDir(siteRoot).abs, `${queryName}.schema.js`)
|
|
76
80
|
if (existsSync(companionPath)) {
|
|
77
81
|
try {
|
|
78
82
|
const mod = await import(pathToFileURL(companionPath).href)
|
|
@@ -657,7 +661,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
|
|
|
657
661
|
const {
|
|
658
662
|
locales = [],
|
|
659
663
|
outputDir = join(siteRoot, 'dist'),
|
|
660
|
-
recordLocalesDir = join(siteRoot, 'locales',
|
|
664
|
+
recordLocalesDir = join(siteRoot, 'locales', RECORD_LOCALES_DIR),
|
|
661
665
|
localesDir = join(siteRoot, 'locales'),
|
|
662
666
|
freeformEnabled = true
|
|
663
667
|
} = options
|
|
@@ -987,7 +991,7 @@ export async function translateRecordData(items, queryName, siteRoot, options =
|
|
|
987
991
|
* @returns {Promise<string[]>} Array of locale codes
|
|
988
992
|
*/
|
|
989
993
|
export async function getRecordLocales(localesPath) {
|
|
990
|
-
const recordLocalesDir = join(localesPath,
|
|
994
|
+
const recordLocalesDir = join(localesPath, RECORD_LOCALES_DIR)
|
|
991
995
|
if (!existsSync(recordLocalesDir)) return []
|
|
992
996
|
|
|
993
997
|
try {
|
|
@@ -24,6 +24,7 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
24
24
|
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
26
|
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
27
|
+
import { resolveRecordsDir } from './entity-pool.js'
|
|
27
28
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
28
29
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
29
30
|
import {
|
|
@@ -61,7 +62,7 @@ import {
|
|
|
61
62
|
* re-derive, so we always ship enough for the ones that do.
|
|
62
63
|
* - `data/<collection>.json` (+ per-record files for `deferred:`
|
|
63
64
|
* collections) — same shape `processQueries` produces today.
|
|
64
|
-
* - `records/<path under
|
|
65
|
+
* - `records/<path under the records directory>` — the records' co-located assets, where
|
|
65
66
|
* the compiled records point.
|
|
66
67
|
* - `assets/<media>` — processed images / video posters / PDF
|
|
67
68
|
* thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
|
|
@@ -143,7 +144,7 @@ export async function buildSiteData({
|
|
|
143
144
|
const byQuery = await processQueries(
|
|
144
145
|
resolvedSiteRoot,
|
|
145
146
|
siteContent.config.queries,
|
|
146
|
-
siteContent.config?.paths
|
|
147
|
+
resolveRecordsDir(resolvedSiteRoot, siteContent.config?.paths).rel,
|
|
147
148
|
basePath,
|
|
148
149
|
{ locale: resolveDefaultLocale(siteContent.config) ?? null }
|
|
149
150
|
)
|
|
@@ -155,7 +156,7 @@ export async function buildSiteData({
|
|
|
155
156
|
await cp(publicDataDir, distDataDir, { recursive: true })
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
// ⭐ AND THE RECORDS' CO-LOCATED ASSETS — `public/records/<path under
|
|
159
|
+
// ⭐ AND THE RECORDS' CO-LOCATED ASSETS — `public/records/<path under records/>`,
|
|
159
160
|
// where the compiled records point (`/records/…`), mirrored the way vite mirrors
|
|
160
161
|
// all of `public/` on the bundle lane. ⛔ Until 2026-09-14 only `public/data` was
|
|
161
162
|
// copied, so a record's image URL named a file this lane's `dist/` did not have.
|
package/src/site/config.js
CHANGED
|
@@ -484,7 +484,7 @@ export async function defineSiteConfig(options = {}) {
|
|
|
484
484
|
const allowed = ['..']
|
|
485
485
|
const parentDir = resolve(siteRoot, '..')
|
|
486
486
|
const paths = siteConfig.paths || {}
|
|
487
|
-
for (const key of ['pages', 'layout', '
|
|
487
|
+
for (const key of ['pages', 'layout', 'records']) {
|
|
488
488
|
if (paths[key]) {
|
|
489
489
|
const resolved = resolve(siteRoot, paths[key])
|
|
490
490
|
if (!resolved.startsWith(parentDir)) {
|
|
@@ -2727,9 +2727,10 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2727
2727
|
// The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
|
|
2728
2728
|
// it would sit beside `config.queries`, the declarations, and read as one.
|
|
2729
2729
|
const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
|
|
2730
|
-
// ⛔ `$`-prefixed keys
|
|
2731
|
-
// `$backend`, `$services`, `$secrets` —
|
|
2732
|
-
//
|
|
2730
|
+
// ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2731
|
+
// `$backend`, `$services`, `$secrets` — until they moved to `sync.json`
|
|
2732
|
+
// (2026-09-20), and a `site.yml` written before then can still carry them. This
|
|
2733
|
+
// payload is a PUBLISHED artifact that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
2733
2734
|
// or kit reads a `config.$*` key), so this removes noise for four of them and a
|
|
2734
2735
|
// real disclosure for the fifth: `$secrets` carries no values, but its entries
|
|
2735
2736
|
// name every secret the site has, and an inventory of credential names is not
|
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* deploy.yml writer
|
|
3
3
|
*
|
|
4
|
-
* Updates the
|
|
4
|
+
* Updates the deploys.<target> block of a site's deploy.yml without
|
|
5
5
|
* reformatting the rest of the file. Uses the eemeli/yaml Document API
|
|
6
6
|
* because js-yaml does not preserve comments on round-trip — the writer
|
|
7
|
-
* must not destroy a developer's comments on the targets:/
|
|
7
|
+
* must not destroy a developer's comments on the targets:/saveDeploys:
|
|
8
8
|
* regions of the file.
|
|
9
9
|
*
|
|
10
10
|
* This is the only place in @uniweb/build that depends on `yaml`. The
|
|
11
11
|
* loader (deploy-config.js) stays on js-yaml for read-only ingestion.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `saveDeploys: false` (or `--no-save`) makes this a no-op; otherwise it
|
|
14
|
+
* touches ONLY deploys.<targetName> — plus targets.<targetName> when the file has no
|
|
15
|
+
* such target yet (a publish to a backend no target names). ⚠️ It was `autoSave: off|lastDeploy|full`
|
|
16
|
+
* until 2026-09-20, and `full` was reserved and behaved as `lastDeploy` — a
|
|
17
|
+
* boolean wearing a tri-state.
|
|
17
18
|
*
|
|
18
19
|
* First-deploy path: when deploy.yml does not exist, writes a fresh
|
|
19
20
|
* file scaffolded with `default:`, a single entry under `targets:`, and
|
|
20
|
-
* `
|
|
21
|
+
* `saveDeploys: true`. This is the only code path that writes the
|
|
21
22
|
* config region.
|
|
23
|
+
*
|
|
24
|
+
* And two removals, for `uniweb forget`: `forgetDeploys` drops the records one
|
|
25
|
+
* backend wrote and keeps every target; `forgetDeployYml` deletes the file, for a
|
|
26
|
+
* copy that is to become a new project.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
29
|
+
import { readFile, writeFile, unlink } from 'node:fs/promises'
|
|
25
30
|
import { existsSync } from 'node:fs'
|
|
26
31
|
import { join } from 'node:path'
|
|
27
32
|
import { Document, parseDocument, isMap } from 'yaml'
|
|
33
|
+
import { normalizeOrigin } from '../uwx/sync-store.js'
|
|
28
34
|
|
|
29
35
|
// The header every generated deploy.yml carries. It has to answer the question
|
|
30
36
|
// a reader has while looking AT the file — who wrote this, and may I edit it —
|
|
@@ -39,7 +45,7 @@ const SCAFFOLD_HEADER = [
|
|
|
39
45
|
' deploy.yml — written by `uniweb deploy` / `uniweb publish`.',
|
|
40
46
|
'',
|
|
41
47
|
' You do not create this file. The first successful deploy does, recording the',
|
|
42
|
-
' target you picked and what happened. Later deploys rewrite only `
|
|
48
|
+
' target you picked and what happened. Later deploys rewrite only `deploys:`',
|
|
43
49
|
' and leave the rest — including your comments — alone.',
|
|
44
50
|
'',
|
|
45
51
|
' Safe to commit; no credentials live here. Host credentials come from the',
|
|
@@ -48,29 +54,34 @@ const SCAFFOLD_HEADER = [
|
|
|
48
54
|
' default: which target is used when none is named',
|
|
49
55
|
' targets: where this site ships. Edit to change the destination, or add',
|
|
50
56
|
' a target and pick it with `--target <name>`',
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'
|
|
57
|
+
' deploys: what each target\'s last deploy did. A record, not a setting.',
|
|
58
|
+
' `publish` reads one field back — a fingerprint of the service',
|
|
59
|
+
' request it last sent — so deleting it only means the next',
|
|
60
|
+
' publish re-sends that request',
|
|
61
|
+
' saveDeploys: false to stop recording deploys',
|
|
62
|
+
'',
|
|
63
|
+
' Which site this is on each backend lives in sync.json, not here.',
|
|
54
64
|
].join('\n')
|
|
55
65
|
|
|
56
66
|
/**
|
|
57
|
-
* Update or create deploy.yml with a fresh
|
|
67
|
+
* Update or create deploy.yml with a fresh deploys.<target> entry.
|
|
58
68
|
*
|
|
59
69
|
* @param {string} siteDir
|
|
60
70
|
* @param {object} opts
|
|
61
71
|
* @param {string} opts.targetName e.g. 'production'
|
|
62
|
-
* @param {object} [opts.targetConfig] { host, ...host-specific };
|
|
63
|
-
*
|
|
72
|
+
* @param {object} [opts.targetConfig] { host, ...host-specific }; the
|
|
73
|
+
* first-deploy scaffold's target, and a
|
|
74
|
+
* target to ADD when the file lacks it
|
|
64
75
|
* @param {object} opts.lastDeploy { at, url, foundation, runtime,
|
|
65
76
|
* artifactSha, ... }
|
|
66
|
-
* @param {
|
|
77
|
+
* @param {boolean} opts.saveDeploys - false makes this a no-op
|
|
67
78
|
* @returns {Promise<{ created: boolean, path: string } | null>}
|
|
68
|
-
* null when
|
|
79
|
+
* null when saveDeploys is false (no-op).
|
|
69
80
|
*/
|
|
70
81
|
export async function recordLastDeploy(siteDir, opts) {
|
|
71
|
-
const { targetName, targetConfig, lastDeploy,
|
|
82
|
+
const { targetName, targetConfig, lastDeploy, saveDeploys } = opts
|
|
72
83
|
|
|
73
|
-
if (
|
|
84
|
+
if (saveDeploys === false) return null
|
|
74
85
|
|
|
75
86
|
const path = join(siteDir, 'deploy.yml')
|
|
76
87
|
|
|
@@ -89,13 +100,29 @@ export async function recordLastDeploy(siteDir, opts) {
|
|
|
89
100
|
|
|
90
101
|
const text = await readFile(path, 'utf8')
|
|
91
102
|
const doc = parseDocument(text)
|
|
103
|
+
// Never rewrite a file we could not read — the caller reports it instead.
|
|
104
|
+
if (doc.errors.length) {
|
|
105
|
+
throw new Error(`deploy.yml did not parse: ${doc.errors[0].message.split('\n')[0]}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A target the file does not have yet — a publish to a backend no target names
|
|
109
|
+
// (resolvePublishTarget) — is added beside the others. An existing target is never
|
|
110
|
+
// modified, and `default` / `saveDeploys` are never touched.
|
|
111
|
+
if (targetConfig?.host) {
|
|
112
|
+
const targetsNode = doc.get('targets', true)
|
|
113
|
+
if (!isMap(targetsNode)) {
|
|
114
|
+
if (targetsNode === undefined) doc.set('targets', { [targetName]: targetConfig })
|
|
115
|
+
} else if (!targetsNode.has(targetName)) {
|
|
116
|
+
targetsNode.set(targetName, targetConfig)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
92
119
|
|
|
93
|
-
//
|
|
94
|
-
let
|
|
95
|
-
if (!isMap(
|
|
96
|
-
doc.set('
|
|
120
|
+
// Otherwise touch ONLY deploys.<targetName>.
|
|
121
|
+
let deploysNode = doc.get('deploys', true)
|
|
122
|
+
if (!isMap(deploysNode)) {
|
|
123
|
+
doc.set('deploys', { [targetName]: lastDeploy })
|
|
97
124
|
} else {
|
|
98
|
-
|
|
125
|
+
deploysNode.set(targetName, lastDeploy)
|
|
99
126
|
}
|
|
100
127
|
|
|
101
128
|
await writeFile(path, doc.toString(), 'utf8')
|
|
@@ -106,8 +133,8 @@ function scaffold({ targetName, targetConfig, lastDeploy }) {
|
|
|
106
133
|
const doc = new Document({
|
|
107
134
|
default: targetName,
|
|
108
135
|
targets: { [targetName]: targetConfig },
|
|
109
|
-
|
|
110
|
-
|
|
136
|
+
deploys: { [targetName]: lastDeploy },
|
|
137
|
+
saveDeploys: true,
|
|
111
138
|
})
|
|
112
139
|
doc.commentBefore = SCAFFOLD_HEADER
|
|
113
140
|
return doc
|
|
@@ -131,11 +158,11 @@ function scaffold({ targetName, targetConfig, lastDeploy }) {
|
|
|
131
158
|
*
|
|
132
159
|
* Behavior:
|
|
133
160
|
* - File missing: scaffold a fresh file with this target, set as
|
|
134
|
-
* default,
|
|
161
|
+
* default, saveDeploys: true. No deploys block (no deploy
|
|
135
162
|
* has happened yet).
|
|
136
163
|
* - File exists: merge targetConfig into targets.<targetName>
|
|
137
164
|
* (overlapping keys overwritten, other keys preserved). Never
|
|
138
|
-
* touches `default`, `
|
|
165
|
+
* touches `default`, `saveDeploys`, `deploys`, or other targets,
|
|
139
166
|
* so adding a CI workflow to a project that already deploys
|
|
140
167
|
* elsewhere doesn't change its deploy semantics.
|
|
141
168
|
*/
|
|
@@ -155,7 +182,7 @@ export async function recordTarget(siteDir, opts) {
|
|
|
155
182
|
const doc = new Document({
|
|
156
183
|
default: targetName,
|
|
157
184
|
targets: { [targetName]: targetConfig },
|
|
158
|
-
|
|
185
|
+
saveDeploys: true,
|
|
159
186
|
})
|
|
160
187
|
doc.commentBefore = SCAFFOLD_HEADER
|
|
161
188
|
await writeFile(path, doc.toString(), 'utf8')
|
|
@@ -187,3 +214,84 @@ export async function recordTarget(siteDir, opts) {
|
|
|
187
214
|
await writeFile(path, doc.toString(), 'utf8')
|
|
188
215
|
return { created: false, path, action: 'merge' }
|
|
189
216
|
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Remove the deploy records one backend wrote — `uniweb forget --backend <url>`.
|
|
220
|
+
*
|
|
221
|
+
* A record belongs to the backend it names: every publish writes
|
|
222
|
+
* `deploys.<target>.backend`. A record naming none falls back to its target's
|
|
223
|
+
* `backend:`, which only a `host: uniweb` target has — so no other host's history
|
|
224
|
+
* can match, and a record that names no backend anywhere is left alone rather than
|
|
225
|
+
* guessed at.
|
|
226
|
+
*
|
|
227
|
+
* ⛔ **`targets:` stays.** A target is a CHOICE of where to ship, not something a
|
|
228
|
+
* backend told us — the spec's own line between this file and `sync.json`. After
|
|
229
|
+
* the forget, the next publish there simply creates a new site.
|
|
230
|
+
*
|
|
231
|
+
* Why the records go at all: each names the forgotten site (`siteUuid`, `url`), and
|
|
232
|
+
* its request fingerprint describes what was sent to THAT site. Kept, they would
|
|
233
|
+
* sit beside a target that now publishes somewhere else entirely.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} siteDir
|
|
236
|
+
* @param {string} origin
|
|
237
|
+
* @returns {Promise<string[]>} the target names whose record was removed, sorted
|
|
238
|
+
* @throws when deploy.yml exists and does not parse — the caller decides whether
|
|
239
|
+
* that blocks anything; this does not rewrite a file it could not read
|
|
240
|
+
*/
|
|
241
|
+
export async function forgetDeploys(siteDir, origin) {
|
|
242
|
+
const key = normalizeOrigin(origin)
|
|
243
|
+
const path = join(siteDir, 'deploy.yml')
|
|
244
|
+
if (!key || !existsSync(path)) return []
|
|
245
|
+
|
|
246
|
+
const doc = parseDocument(await readFile(path, 'utf8'))
|
|
247
|
+
if (doc.errors.length) {
|
|
248
|
+
throw new Error(`deploy.yml did not parse: ${doc.errors[0].message.split('\n')[0]}`)
|
|
249
|
+
}
|
|
250
|
+
const deploysNode = doc.get('deploys', true)
|
|
251
|
+
if (!isMap(deploysNode)) return []
|
|
252
|
+
|
|
253
|
+
const js = doc.toJS() || {}
|
|
254
|
+
const removed = []
|
|
255
|
+
for (const [name, record] of Object.entries(js.deploys || {})) {
|
|
256
|
+
const target = js.targets?.[name]
|
|
257
|
+
const named =
|
|
258
|
+
record?.backend ?? (target?.host === 'uniweb' ? target?.backend : undefined)
|
|
259
|
+
if (normalizeOrigin(named) === key) removed.push(name)
|
|
260
|
+
}
|
|
261
|
+
if (!removed.length) return []
|
|
262
|
+
|
|
263
|
+
for (const name of removed) deploysNode.delete(name)
|
|
264
|
+
// An empty `deploys: {}` says nothing and would be the only thing left of the
|
|
265
|
+
// backend in this file.
|
|
266
|
+
if (!deploysNode.items.length) doc.delete('deploys')
|
|
267
|
+
await writeFile(path, doc.toString(), 'utf8')
|
|
268
|
+
return removed.sort()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Delete deploy.yml outright — `uniweb forget --all`, for a copy that is to become a
|
|
273
|
+
* new project.
|
|
274
|
+
*
|
|
275
|
+
* ⭐ **The targets go too, not just the records** *[Diego, 2026-09-21: "if it's for
|
|
276
|
+
* duplicating a site project and making a new site from it, … the whole deploy.yml
|
|
277
|
+
* file has to be deleted"]*. They name the ORIGINAL's destinations — its Pages
|
|
278
|
+
* project, its bucket, its domain — so a deploy from the copy would overwrite the
|
|
279
|
+
* original there. The copy's first deploy writes a fresh file.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} siteDir
|
|
282
|
+
* @returns {Promise<string[]|null>} the target names it held, or null when there
|
|
283
|
+
* was no file. A file that does not parse is removed all the same and reports `[]`.
|
|
284
|
+
*/
|
|
285
|
+
export async function forgetDeployYml(siteDir) {
|
|
286
|
+
const path = join(siteDir, 'deploy.yml')
|
|
287
|
+
if (!existsSync(path)) return null
|
|
288
|
+
let names = []
|
|
289
|
+
try {
|
|
290
|
+
const doc = parseDocument(await readFile(path, 'utf8'))
|
|
291
|
+
if (!doc.errors.length) names = Object.keys(doc.toJS()?.targets || {}).sort()
|
|
292
|
+
} catch {
|
|
293
|
+
/* unreadable — still the original's, still removed */
|
|
294
|
+
}
|
|
295
|
+
await unlink(path)
|
|
296
|
+
return names
|
|
297
|
+
}
|