uniweb 1.13.0 → 1.13.2
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/partials/agents.md +23 -5
- package/src/backend/site-sync.js +19 -1
- package/src/commands/doctor.js +83 -8
- package/src/commands/pull.js +11 -5
- package/src/framework-index.json +8 -8
- package/src/utils/sync-point.js +58 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/schemas": "~1.5.0",
|
|
45
|
-
"@uniweb/content-writer": "~1.3.0",
|
|
46
44
|
"@uniweb/core": "~1.3.0",
|
|
47
|
-
"@uniweb/semantic-parser": "~1.12.
|
|
45
|
+
"@uniweb/semantic-parser": "~1.12.1",
|
|
46
|
+
"@uniweb/content-writer": "~1.3.0",
|
|
47
|
+
"@uniweb/schemas": "~1.5.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/semantic-parser": "~1.12.
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "~1.5.2",
|
|
51
|
+
"@uniweb/semantic-parser": "~1.12.1",
|
|
52
|
+
"@uniweb/content-reader": "~1.5.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -1098,7 +1098,7 @@ import { Section, Prose } from '@uniweb/kit'
|
|
|
1098
1098
|
|
|
1099
1099
|
`Prose` renders from the parsed sequence — headings, paragraphs, images, snippets, lists — with prose typography. Tagged data blocks are **skipped**; access them via `content.data` for custom rendering. Pass `content` (needs `.sequence`), and `block` too if the content uses insets. Also works as a pure typography wrapper: `<Prose>{children}</Prose>`.
|
|
1100
1100
|
|
|
1101
|
-
`Article` is
|
|
1101
|
+
`Article` is `Prose` in a semantic `<article>` element — the same rendering, for content that is a self-contained document.
|
|
1102
1102
|
|
|
1103
1103
|
### The Kit, by use case
|
|
1104
1104
|
|
|
@@ -1111,13 +1111,13 @@ Names only — for signatures and props, read the package: it's on disk at `node
|
|
|
1111
1111
|
**Header/layout:** `useScrolled(threshold)`, `useMobileMenu()`, `useAppearance()`
|
|
1112
1112
|
**Overlays:** `Overlay` (modals, palettes, drawers, toasts — portals out of the layout, contains focus; read the note below before hand-rolling one)
|
|
1113
1113
|
**Keyboard:** `useShortcut('mod+k', fn)`, `useShortcuts({…})`, `useShortcutLabel('mod+k')` — `mod` is Cmd on Apple platforms and Ctrl elsewhere, and the label renders per-platform so a hint never shows the wrong key
|
|
1114
|
-
**Long-form prose:** `<Prose>` and the container you put `<Render>` output in both use
|
|
1114
|
+
**Long-form prose:** `<Prose>` and the container you put `<Render>` output in both use kit's `prose` classes. One line in the foundation's `styles.css` supplies the rules, colored by the section's theme:
|
|
1115
1115
|
|
|
1116
1116
|
```css
|
|
1117
|
-
@import "@uniweb/kit/prose-tokens.css"; /*
|
|
1117
|
+
@import "@uniweb/kit/prose-tokens.css"; /* kit's prose layer, wired to theme.yml */
|
|
1118
1118
|
```
|
|
1119
1119
|
|
|
1120
|
-
Nothing to install
|
|
1120
|
+
Nothing to install. **Skip the import and prose is completely unstyled** — lists lose their bullets and numbers, and text its typography — while the markup looks right. The foundation build warns about it, and `uniweb doctor` flags it. `size` picks a step (`prose-sm` … `prose-2xl`) of the site's reading size, which `theme.yml` sets under `vars:` — `prose-size`, `prose-leading`, `prose-measure`, `prose-flow`, `prose-h1` … `prose-h4`; spread `proseVars` from `@uniweb/kit/prose-vars` into `main.js`'s `vars` to declare them. A utility class or an element variant (`prose-headings:font-display`, `prose-a:no-underline`) overrides any rule. There are no palette modifiers and no `prose-invert` — the colors are already the theme's, light and dark.
|
|
1121
1121
|
|
|
1122
1122
|
**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.
|
|
1123
1123
|
**Layout helpers:** `useGridLayout(columns, { gap })`, `useAccordion({ multiple, defaultOpen })`
|
|
@@ -1860,7 +1860,7 @@ const page = website.activePage
|
|
|
1860
1860
|
| `block.childBlocks` / `block.insets` | Child sections / inline `@Component` references |
|
|
1861
1861
|
| `block.getInset(refId)` | Lookup an inset by refId |
|
|
1862
1862
|
| `block.properties` | Raw frontmatter |
|
|
1863
|
-
| `block.rawContent` | ProseMirror document —
|
|
1863
|
+
| `block.rawContent` | The section's ProseMirror document, before parsing — what a content handler reads |
|
|
1864
1864
|
| `block.themeName` | `"light"`, `"medium"`, `"dark"` |
|
|
1865
1865
|
| `block.stableId` / `block.key` | Stable ID from filename or `id:` / unique key across pages — use as React key |
|
|
1866
1866
|
| `block.path` | Page route this block belongs to |
|
|
@@ -2566,6 +2566,24 @@ await writer.remove(itemId)
|
|
|
2566
2566
|
first; a retry would succeed by overwriting a change nobody looked at. Tell the
|
|
2567
2567
|
person.
|
|
2568
2568
|
|
|
2569
|
+
### Translated fields
|
|
2570
|
+
|
|
2571
|
+
A `localized` field holds `{ en: '…', fr: '…' }`, or one plain value that is every
|
|
2572
|
+
language, and a read answers it in the page's language. ⛔ **Never write that one value
|
|
2573
|
+
back as the whole field** — it becomes the value in every language. To edit one:
|
|
2574
|
+
|
|
2575
|
+
```jsx
|
|
2576
|
+
const { entity } = useEntity({ schema: '@/profile', uuid, languages: 'all' }) // every language, as stored
|
|
2577
|
+
await writer.update(item.id, { ...item.data, bio: 'Bonjour' }, { language: 'fr' }) // one language, merged
|
|
2578
|
+
```
|
|
2579
|
+
|
|
2580
|
+
- **`update` with `{ language }`** merges each translated field you name into the languages
|
|
2581
|
+
the item holds, and keeps a translated field you leave out. On a site with more than one
|
|
2582
|
+
language, an update without it that writes a plain value over a translated field read in
|
|
2583
|
+
the page's language is refused.
|
|
2584
|
+
- **`languagesOf`, `inLanguage` and `withLanguage`** read and set one language of a value
|
|
2585
|
+
yourself, its `@` metadata kept.
|
|
2586
|
+
|
|
2569
2587
|
### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
|
|
2570
2588
|
|
|
2571
2589
|
Gate your controls on what the viewer may do — a record's own `can_edit` from a
|
package/src/backend/site-sync.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
uncommittedPaths,
|
|
24
24
|
commitRoots,
|
|
25
25
|
gitError,
|
|
26
|
+
RECORDED_TRAILER,
|
|
26
27
|
unmergedPaths,
|
|
27
28
|
mergeInProgress
|
|
28
29
|
} from '../utils/sync-point.js'
|
|
@@ -1071,6 +1072,20 @@ export function uncommittedContent(siteDir, { verb = 'push' } = {}) {
|
|
|
1071
1072
|
lines: [`Finish it (\`git commit\`), then ${verb} — or abandon it (\`git merge --abort\`) and pull again.`]
|
|
1072
1073
|
}
|
|
1073
1074
|
}
|
|
1075
|
+
// ⭐ A repository with no commit yet is still the one that will hold the site's history —
|
|
1076
|
+
// and holds none of it, so every file is listed as not committed. Said as that, not as
|
|
1077
|
+
// edits. ⛔ Not taken for a folder outside git: its first push would keep nothing to merge
|
|
1078
|
+
// from, and the pull after its first commit would read every difference as a conflict.
|
|
1079
|
+
if (!repo.head) {
|
|
1080
|
+
return {
|
|
1081
|
+
paths,
|
|
1082
|
+
headline: `Not ${done} — this repository has no commit yet, so none of the site's ${paths.length} file(s) is committed.`,
|
|
1083
|
+
lines: [
|
|
1084
|
+
`In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
|
|
1085
|
+
`Commit the site, then ${verb} again — or re-run with --commit (and -m "…") to make that first commit.`
|
|
1086
|
+
]
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1074
1089
|
return {
|
|
1075
1090
|
paths,
|
|
1076
1091
|
headline: `Not ${done} — ${paths.length} file(s) of the site are not committed:`,
|
|
@@ -1139,8 +1154,11 @@ export function commitWriteBacks(siteDir, backend, { complete = false } = {}) {
|
|
|
1139
1154
|
GIT_COMMITTER_EMAIL: `sync@${host}`
|
|
1140
1155
|
}
|
|
1141
1156
|
try {
|
|
1157
|
+
// ⭐ Marked with the backend and the site, so a pull reads the commit a lane names through
|
|
1158
|
+
// it (`recordedAfter`, utils/sync-point.js): the ids written back are what the backend holds.
|
|
1159
|
+
const site = readBackendState(siteDir, backend).site?.uuid
|
|
1142
1160
|
const made = commitRoots(repo, [...sentRoots(siteDir), SYNC_STORE_FILE], {
|
|
1143
|
-
message: `Record what ${backend} gave the site`,
|
|
1161
|
+
message: `Record what ${backend} gave the site${site ? `\n\n${RECORDED_TRAILER}: ${backend} ${site}` : ''}`,
|
|
1144
1162
|
identity
|
|
1145
1163
|
})
|
|
1146
1164
|
if (made && complete) writeSyncPoint(siteDir, backend, made.commit)
|
package/src/commands/doctor.js
CHANGED
|
@@ -190,6 +190,30 @@ export function rendersKitProse(dir) {
|
|
|
190
190
|
)
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** Does a stylesheet load Tailwind Typography itself? */
|
|
194
|
+
export function loadsTypographyPlugin(styles) {
|
|
195
|
+
return /@plugin\s+["']@tailwindcss\/typography["']/.test(String(styles || ''))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Tailwind Typography's palette modifiers and `prose-invert`, wherever a
|
|
200
|
+
* foundation's source names one in a class list — `dark:prose-invert` included.
|
|
201
|
+
* Comments are skipped, since a comment warning against one names it too.
|
|
202
|
+
*
|
|
203
|
+
* @returns {{ file: string, modifiers: string[] }[]}
|
|
204
|
+
*/
|
|
205
|
+
export function inertProseModifiers(dir) {
|
|
206
|
+
const MODIFIER =
|
|
207
|
+
/(?:^|[\s"'`:])(prose-(?:gray|slate|zinc|neutral|stone|invert|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose))(?=$|[\s"'`])/gm
|
|
208
|
+
const found = []
|
|
209
|
+
forEachSourceFile(dir, (text, file) => {
|
|
210
|
+
const code = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
211
|
+
const modifiers = [...new Set([...code.matchAll(MODIFIER)].map((m) => m[1]))]
|
|
212
|
+
if (modifiers.length) found.push({ file, modifiers })
|
|
213
|
+
})
|
|
214
|
+
return found
|
|
215
|
+
}
|
|
216
|
+
|
|
193
217
|
function sourceMatches(dir, pattern) {
|
|
194
218
|
let found = false
|
|
195
219
|
forEachSourceFile(dir, (text) => {
|
|
@@ -1200,13 +1224,14 @@ export async function doctor(args = []) {
|
|
|
1200
1224
|
|
|
1201
1225
|
// Prose without the tokens — a silent, total loss of styling.
|
|
1202
1226
|
//
|
|
1203
|
-
// `@uniweb/kit/prose-tokens.css`
|
|
1204
|
-
//
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1227
|
+
// `@uniweb/kit/prose-tokens.css` is kit's prose layer, which answers to the
|
|
1228
|
+
// site's theme.yml. A foundation that writes `prose` (or renders
|
|
1229
|
+
// <Prose>/<Article>, which emit it) and does NOT import that file gets the
|
|
1230
|
+
// class and no rules behind it: correct markup, zero styling, and nothing
|
|
1231
|
+
// reported anywhere. Measured 2026-07-30 on a foundation missing only the
|
|
1232
|
+
// rules — every paragraph `margin-top: 0px`, no warning from Vite, Tailwind or
|
|
1233
|
+
// the browser. (Then the rules were Tailwind Typography's; kit's own since
|
|
1234
|
+
// 2026-10-19.)
|
|
1210
1235
|
//
|
|
1211
1236
|
// kit closes this for anyone who imports the file; this closes it for anyone
|
|
1212
1237
|
// who does not, which is the case kit cannot reach.
|
|
@@ -1237,13 +1262,63 @@ export async function doctor(args = []) {
|
|
|
1237
1262
|
})
|
|
1238
1263
|
warn(`[${id}] ${f.name} ${reason}, with no prose styling behind it`)
|
|
1239
1264
|
log(
|
|
1240
|
-
`
|
|
1265
|
+
` kit's prose layer supplies the rules and it is not imported, so the ` +
|
|
1241
1266
|
`markup is correct and completely unstyled — silently.`
|
|
1242
1267
|
)
|
|
1243
1268
|
log(` Add to ${f.folderName}/styles.css:`)
|
|
1244
1269
|
log(` ${colors.dim}@import "@uniweb/kit/prose-tokens.css";${colors.reset}`)
|
|
1245
1270
|
}
|
|
1246
1271
|
|
|
1272
|
+
// A modifier kit's prose layer does not have — `prose-slate`, `prose-invert`
|
|
1273
|
+
// and the rest of Tailwind Typography's palettes. Since 2026-10-19 the layer
|
|
1274
|
+
// takes every color from the section's theme tokens, so a class list still
|
|
1275
|
+
// naming one styles nothing and says a design the page does not have.
|
|
1276
|
+
// Only where the foundation imports kit's layer and does not load the plugin
|
|
1277
|
+
// itself — with its own `@plugin`, the modifier still means what it says.
|
|
1278
|
+
for (const f of foundations) {
|
|
1279
|
+
const stylesPath = join(f.path, 'styles.css')
|
|
1280
|
+
if (!existsSync(stylesPath)) continue
|
|
1281
|
+
const styles = readFileSync(stylesPath, 'utf8')
|
|
1282
|
+
if (!styles.includes('@uniweb/kit/prose-tokens.css')) continue
|
|
1283
|
+
if (loadsTypographyPlugin(styles)) {
|
|
1284
|
+
// Both: the plugin's rules sit in a layer above kit's and win, so prose is
|
|
1285
|
+
// the plugin's again — its own greys, and none of the --prose-* variables.
|
|
1286
|
+
const id = 'prose-plugin-alongside'
|
|
1287
|
+
issues.push({
|
|
1288
|
+
id,
|
|
1289
|
+
type: 'warning',
|
|
1290
|
+
foundation: f.name,
|
|
1291
|
+
message: `${f.name} loads Tailwind Typography as well as kit's prose layer`
|
|
1292
|
+
})
|
|
1293
|
+
warn(`[${id}] ${f.name} loads Tailwind Typography as well as kit's prose layer`)
|
|
1294
|
+
log(
|
|
1295
|
+
` The plugin's rules sit above kit's and win: prose takes the plugin's greys, not the ` +
|
|
1296
|
+
`theme's colors, and the --prose-* variables reach nothing.`
|
|
1297
|
+
)
|
|
1298
|
+
log(` Remove the @plugin line from ${f.folderName}/styles.css to use kit's layer alone.`)
|
|
1299
|
+
continue
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const found = inertProseModifiers(f.path)
|
|
1303
|
+
if (found.length === 0) continue
|
|
1304
|
+
|
|
1305
|
+
const id = 'prose-modifier-inert'
|
|
1306
|
+
const names = [...new Set(found.flatMap((x) => x.modifiers))]
|
|
1307
|
+
issues.push({
|
|
1308
|
+
id,
|
|
1309
|
+
type: 'warning',
|
|
1310
|
+
foundation: f.name,
|
|
1311
|
+
message: `${f.name} names ${names.join(', ')}, which kit's prose layer does not have`
|
|
1312
|
+
})
|
|
1313
|
+
warn(`[${id}] ${f.name} names ${names.join(', ')} — they no longer do anything`)
|
|
1314
|
+
for (const { file, modifiers } of found.slice(0, 6)) log(` ${file}: ${modifiers.join(', ')}`)
|
|
1315
|
+
log(
|
|
1316
|
+
` Prose takes its colors from the section's theme tokens, so it already follows ` +
|
|
1317
|
+
`theme.yml and the visitor's scheme. Remove these; to change what prose looks like, ` +
|
|
1318
|
+
`set the theme's colors, or the --prose-* variables.`
|
|
1319
|
+
)
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1247
1322
|
// ⛔ SAY WHEN A CHECK DID NOT RUN. Every foundation check below reads the
|
|
1248
1323
|
// BUILT `dist/meta/schema.json` — what a consumer actually receives — and
|
|
1249
1324
|
// returns silently when there is none. In a workspace with a site, "Foundation
|
package/src/commands/pull.js
CHANGED
|
@@ -115,7 +115,7 @@ import {
|
|
|
115
115
|
isAncestor,
|
|
116
116
|
historySyncPoint,
|
|
117
117
|
madeOnto,
|
|
118
|
-
|
|
118
|
+
baseFor,
|
|
119
119
|
openStandIn,
|
|
120
120
|
mergeSyncPoint,
|
|
121
121
|
takeSyncPoint,
|
|
@@ -914,13 +914,15 @@ export async function pull(args = [], deps = {}) {
|
|
|
914
914
|
]
|
|
915
915
|
const laneRoots = siteLaneRoots(siteDir)
|
|
916
916
|
const taken = (lane) => lane && !lane.notModified && !lane.refused
|
|
917
|
-
base =
|
|
917
|
+
base = baseFor(repo, {
|
|
918
918
|
known,
|
|
919
|
-
// ⛔
|
|
919
|
+
// ⛔ What each lane the backend answered names is a base only where every lane holds it (`baseFor`).
|
|
920
920
|
lanes: [
|
|
921
921
|
...(taken(content) ? [{ source: content.source, roots: laneRoots.content }] : []),
|
|
922
922
|
...(taken(folderLane) ? [{ source: folderLane.source, roots: laneRoots.records }] : [])
|
|
923
|
-
]
|
|
923
|
+
],
|
|
924
|
+
origin: client.origin,
|
|
925
|
+
siteUuid: siteContentUuid
|
|
924
926
|
})
|
|
925
927
|
onto = force || !base ? repo.head : base
|
|
926
928
|
try {
|
|
@@ -1183,6 +1185,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
1183
1185
|
} else {
|
|
1184
1186
|
merge.conflicted = result.conflicted || []
|
|
1185
1187
|
merge.stashKept = Boolean(result.stashKept)
|
|
1188
|
+
// Whether anything was set aside for the merge: the files as they were, against HEAD.
|
|
1189
|
+
merge.setAside = files !== repo.head
|
|
1186
1190
|
merge.noBase = !force && !base
|
|
1187
1191
|
merge.changed = changedInFiles(repo, {
|
|
1188
1192
|
from: repo.head,
|
|
@@ -1286,7 +1290,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
1286
1290
|
note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
|
|
1287
1291
|
note('Once this merge is committed, later pulls merge from it.')
|
|
1288
1292
|
}
|
|
1289
|
-
note(
|
|
1293
|
+
note(
|
|
1294
|
+
`Settle them and \`git add\` each, then \`git commit\` to finish the merge${merge.setAside ? ' — your uncommitted changes come back with it' : ''}.`
|
|
1295
|
+
)
|
|
1290
1296
|
}
|
|
1291
1297
|
}
|
|
1292
1298
|
}
|
package/src/framework-index.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-19T17:17:22.841Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.6.0"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.5.
|
|
9
|
+
"version": "1.5.2"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
|
-
"version": "1.5.
|
|
12
|
+
"version": "1.5.3"
|
|
13
13
|
},
|
|
14
14
|
"@uniweb/content-writer": {
|
|
15
15
|
"version": "1.3.0"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"version": "1.0.4"
|
|
25
25
|
},
|
|
26
26
|
"@uniweb/kit": {
|
|
27
|
-
"version": "1.
|
|
27
|
+
"version": "1.5.0"
|
|
28
28
|
},
|
|
29
29
|
"@uniweb/loom": {
|
|
30
30
|
"version": "1.1.0"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"version": "1.1.4"
|
|
37
37
|
},
|
|
38
38
|
"@uniweb/runtime": {
|
|
39
|
-
"version": "1.1.
|
|
39
|
+
"version": "1.1.9"
|
|
40
40
|
},
|
|
41
41
|
"@uniweb/scene": {
|
|
42
42
|
"version": "1.0.1"
|
|
@@ -48,13 +48,13 @@
|
|
|
48
48
|
"version": "1.1.0"
|
|
49
49
|
},
|
|
50
50
|
"@uniweb/semantic-parser": {
|
|
51
|
-
"version": "1.12.
|
|
51
|
+
"version": "1.12.1"
|
|
52
52
|
},
|
|
53
53
|
"@uniweb/snapshot": {
|
|
54
54
|
"version": "1.1.0"
|
|
55
55
|
},
|
|
56
56
|
"@uniweb/templates": {
|
|
57
|
-
"version": "1.1.
|
|
57
|
+
"version": "1.1.2"
|
|
58
58
|
},
|
|
59
59
|
"@uniweb/theming": {
|
|
60
60
|
"version": "1.2.0"
|
package/src/utils/sync-point.js
CHANGED
|
@@ -53,6 +53,8 @@ import { join, dirname } from 'node:path'
|
|
|
53
53
|
|
|
54
54
|
export const SYNC_TRAILER = 'Uniweb-Sync'
|
|
55
55
|
export const SITE_TRAILER = 'Uniweb-Site'
|
|
56
|
+
/** Marks the commit a push makes of what it wrote back: `Uniweb-Recorded: <origin> <site>`. */
|
|
57
|
+
export const RECORDED_TRAILER = 'Uniweb-Recorded'
|
|
56
58
|
|
|
57
59
|
const MAX_BUFFER = 1024 * 1024 * 1024
|
|
58
60
|
|
|
@@ -280,24 +282,8 @@ export function madeOnto(repo, sha, { origin, siteUuid }) {
|
|
|
280
282
|
* reads the old text, so the change-back looks like no change at all. ⛔ Until
|
|
281
283
|
* 2026-10-17 this said an older base "costs precision at worst".
|
|
282
284
|
*/
|
|
283
|
-
export function newestBase(repo, candidates
|
|
284
|
-
|
|
285
|
-
// ⛔ ONE MERGE TAKES ONE BASE FOR EVERY LANE, and a lane's own source says what that lane
|
|
286
|
-
// holds. A commit newer than it whose content differs there reads what the lane lacks — a
|
|
287
|
-
// push of it the backend refused while it took the other lane — as the backend's removal,
|
|
288
|
-
// and the merge drops it. A lane that names no source is held to the newest of the bases
|
|
289
|
-
// this copy knows of itself; with none, only those can be the base.
|
|
290
|
-
if (lanes?.length) {
|
|
291
|
-
const own = new Set(known.filter(Boolean))
|
|
292
|
-
const fallback = newestBase(repo, known)
|
|
293
|
-
shas = shas.filter((s) =>
|
|
294
|
-
lanes.every(({ source, roots }) => {
|
|
295
|
-
const ref = source && isCommit(repo, source) && isAncestor(repo, source) ? source : fallback
|
|
296
|
-
if (!ref) return own.has(s)
|
|
297
|
-
return s === ref || isAncestor(repo, s, ref) || !differsIn(repo, ref, s, roots)
|
|
298
|
-
})
|
|
299
|
-
)
|
|
300
|
-
}
|
|
285
|
+
export function newestBase(repo, candidates) {
|
|
286
|
+
const shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
|
|
301
287
|
if (shas.length <= 1) return shas[0] || null
|
|
302
288
|
const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
|
|
303
289
|
if (newest) return newest
|
|
@@ -308,6 +294,60 @@ export function newestBase(repo, candidates, { lanes = null, known = [] } = {})
|
|
|
308
294
|
}
|
|
309
295
|
}
|
|
310
296
|
|
|
297
|
+
/**
|
|
298
|
+
* The base a pull merges from, given the bases this copy knows of itself and what the backend
|
|
299
|
+
* names for each lane it answered (`x-uniweb-source`).
|
|
300
|
+
*
|
|
301
|
+
* ⭐ THIS COPY'S OWN BASES ARE EXACT BY CONSTRUCTION — a pull's commit, the commit a push was
|
|
302
|
+
* made from once every lane landed, one a push found nothing to send from — and are never
|
|
303
|
+
* second-guessed here.
|
|
304
|
+
*
|
|
305
|
+
* ⛔ ONE MERGE TAKES ONE BASE FOR EVERY LANE, and what the backend names is exact for its own
|
|
306
|
+
* lane only. A commit one lane names that is newer than another lane's, with that lane's files
|
|
307
|
+
* different, would read what that lane lacks — a push of it the backend refused while it took the
|
|
308
|
+
* other — as the backend's removal, and the merge would drop it. So it is not a base. A lane is
|
|
309
|
+
* held to the commit it names, read through the commit its push made of what it wrote back
|
|
310
|
+
* (`recordedAfter`), and a lane that names none to the newest base this copy knows.
|
|
311
|
+
*
|
|
312
|
+
* ⛔ Until 2026-10-19 (cli 713f62d, uniweb 1.13.0) every candidate was held to that, this copy's
|
|
313
|
+
* own included, and a lane to the commit it names alone. A first push writes each new record's
|
|
314
|
+
* `$uuid` back, so the records differed from the commit the folder lane names in every commit after
|
|
315
|
+
* it, and each was dropped: a pull merged from the site's first push, measured by flows against a
|
|
316
|
+
* real backend — a reverted change came back (A18), a re-wrap and six pushed edits conflicted.
|
|
317
|
+
*/
|
|
318
|
+
export function baseFor(repo, { known = [], lanes = [], origin, siteUuid }) {
|
|
319
|
+
const held = (s) => Boolean(s) && isCommit(repo, s) && isAncestor(repo, s)
|
|
320
|
+
const fallback = newestBase(repo, known)
|
|
321
|
+
const refs = lanes.map(({ source, roots }) => ({
|
|
322
|
+
roots,
|
|
323
|
+
ref: held(source) ? recordedAfter(repo, source, { origin, siteUuid }) || source : fallback
|
|
324
|
+
}))
|
|
325
|
+
const named = lanes.flatMap(({ source }) => (held(source) ? [source, recordedAfter(repo, source, { origin, siteUuid })] : []))
|
|
326
|
+
const taken = named.filter(Boolean).filter((s) =>
|
|
327
|
+
refs.every(({ ref, roots }) => Boolean(ref) && (s === ref || isAncestor(repo, s, ref) || !differsIn(repo, ref, s, roots)))
|
|
328
|
+
)
|
|
329
|
+
return newestBase(repo, [...known, ...taken])
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The commit a push made of what it wrote back, after being made from `sha` — its first parent,
|
|
334
|
+
* marked with the push's backend and site — when HEAD's history holds it; else null.
|
|
335
|
+
*/
|
|
336
|
+
export function recordedAfter(repo, sha, { origin, siteUuid }) {
|
|
337
|
+
if (!sha || !origin || !siteUuid) return null
|
|
338
|
+
let out
|
|
339
|
+
try {
|
|
340
|
+
out = gitText(repo.top, ['log', '--fixed-strings', `--grep=${RECORDED_TRAILER}: ${origin} ${siteUuid}`, '--format=%H %P', 'HEAD'])
|
|
341
|
+
} catch {
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
for (const line of out.split('\n')) {
|
|
345
|
+
const [commit, first] = line.trim().split(' ')
|
|
346
|
+
if (commit && first && (first === sha || first.startsWith(sha))) return commit
|
|
347
|
+
}
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
|
|
311
351
|
/** Does the content under `roots` differ between two commits? Anything but "no" counts as yes. */
|
|
312
352
|
function differsIn(repo, a, b, roots) {
|
|
313
353
|
try {
|