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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.13.0",
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.0"
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/content-reader": "~1.5.2",
51
- "@uniweb/semantic-parser": "~1.12.0",
52
- "@uniweb/build": "~1.5.1"
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": {
@@ -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 an older alternative rendering from `block.rawContent` (raw ProseMirror), including data blocks. Prefer `Prose` for new components.
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 Tailwind Typography's `prose` classes. One line in the foundation's `styles.css` supplies them and points them at the site's theme:
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"; /* the plugin, wired to theme.yml */
1117
+ @import "@uniweb/kit/prose-tokens.css"; /* kit's prose layer, wired to theme.yml */
1118
1118
  ```
1119
1119
 
1120
- Nothing to install — the import brings the plugin with it. **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. Use **one** `prose` container per subtree — the `--tw-prose-*` variables are inherited, so a nested one silently resets them and the outer container goes on looking correct. The section that renders the document is usually the better owner; a layout should supply column width and padding.
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 — passed internally by `<Article block={block} />` |
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
@@ -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)
@@ -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` brings Tailwind Typography with it and then
1204
- // points it at the site's theme.yml. A foundation that writes `prose` (or
1205
- // renders <Prose>/<Article>, which emit it) and does NOT import that file
1206
- // gets the class and no rules behind it: correct markup, zero styling, and
1207
- // nothing reported anywhere. Measured 2026-07-30 on a foundation missing only
1208
- // the plugin — every paragraph `margin-top: 0px`, no warning from Vite,
1209
- // Tailwind or the browser.
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
- ` Tailwind Typography supplies the rules and it is not loaded, so the ` +
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
@@ -115,7 +115,7 @@ import {
115
115
  isAncestor,
116
116
  historySyncPoint,
117
117
  madeOnto,
118
- newestBase,
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 = newestBase(repo, [...known, content?.source, folderLane?.source], {
917
+ base = baseFor(repo, {
918
918
  known,
919
- // ⛔ Each lane the backend answered holds every base to what it names (`newestBase`).
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('Settle them and `git add` each, then `git commit` to finish the merge — your uncommitted changes come back with it.')
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
  }
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-19T02:42:59.650Z",
3
+ "generatedAt": "2026-10-19T17:17:22.841Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.5.2"
6
+ "version": "1.6.0"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.5.1"
9
+ "version": "1.5.2"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
- "version": "1.5.2"
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.4.0"
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.8"
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.0"
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.1"
57
+ "version": "1.1.2"
58
58
  },
59
59
  "@uniweb/theming": {
60
60
  "version": "1.2.0"
@@ -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, { lanes = null, known = [] } = {}) {
284
- let shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
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 {