mapples 0.2.0-beta.3 → 0.2.0-beta.4

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.
@@ -16,7 +16,7 @@ import { HeadMismatchError, JpatchError } from '../api/errors.js';
16
16
  import { pullDelta, SnapshotRequired } from '../api/poll.js';
17
17
  import { fetchProjectState, } from '../api/objects.js';
18
18
  import { PRIMITIVE_MAPPING } from '../adopt/mappingTable.js';
19
- import { emitComponent, emitPage } from '../codegen/emit.js';
19
+ import { emitComponent, emitPage, withoutRouteMarker } from '../codegen/emit.js';
20
20
  import { importNeedsFor } from '../codegen/imports.js';
21
21
  import { instanceLinker } from '../codegen/instances.js';
22
22
  import { deprecateFile } from '../merge/deprecate.js';
@@ -35,8 +35,11 @@ import { mintEntityFile } from '../nav/placement.js';
35
35
  import { syncNavigation } from '../nav/routeTree.js';
36
36
  import { syncStyleFile } from '../style/theme.js';
37
37
  import { emptyReport } from '../ui/report.js';
38
+ import { sourceIdFor, } from '../types.js';
38
39
  const MAX_REBASE_RETRIES = 5;
39
40
  const MAX_OPS_PER_TX = 200;
41
+ /** Source ops carry whole files (≤256 KiB each): fewer per transaction than node ops. */
42
+ const SOURCE_OPS_PER_TX = 10;
40
43
  // buildRemoteModel needs the project root to read the stored head; carried as
41
44
  // a non-enumerable tag so BaseSnapshot's shape stays unchanged for callers.
42
45
  const ROOT_TAG = Symbol('projectRoot');
@@ -112,8 +115,11 @@ export async function runSyncEngine({ root, client }) {
112
115
  let txId = mintTxId();
113
116
  let attempt = 0;
114
117
  for (;;) {
115
- const { pushOps, mergedBase, pagesMap, componentsMap } = await mergeAndWrite(root, client, config, base, remote, report);
116
- if (pushOps.length === 0) {
118
+ const { pushOps, mergedBase, pagesMap, componentsMap, sourceUploads } = await mergeAndWrite(root, client, config, base, remote, report);
119
+ // Source text goes out AFTER the node ops (its `$sid`s must be server
120
+ // ids, which the node push mints), as its own transaction(s).
121
+ let sourceOps = [];
122
+ if (pushOps.length === 0 && sourceUploads.size === 0) {
117
123
  finalize(root, baseStore, mergedBase, pagesMap, componentsMap, remote.head, remote.seq);
118
124
  report.head = remote.head;
119
125
  return report;
@@ -124,15 +130,41 @@ export async function runSyncEngine({ root, client }) {
124
130
  }
125
131
  const progress = newProgress(remote.head, remote.seq);
126
132
  try {
127
- await pushChunked(client, pushOps, txId, progress, {
128
- rebaseInPlace: false,
129
- report,
130
- // Rewrite as each chunk lands, so files and base never hold a cid the
131
- // server has already resolved: a re-merge or a park after a later
132
- // chunk fails then sees the committed nodes by their real ids.
133
- onChunk: (idMap) => rewriteIds(root, mergedBase, [pagesMap, componentsMap], idMap, report),
134
- });
135
- report.pushedOps += pushOps.length;
133
+ if (pushOps.length > 0) {
134
+ await pushChunked(client, pushOps, txId, progress, {
135
+ rebaseInPlace: false,
136
+ report,
137
+ // Rewrite as each chunk lands, so files and base never hold a cid the
138
+ // server has already resolved: a re-merge or a park after a later
139
+ // chunk fails then sees the committed nodes by their real ids.
140
+ onChunk: (idMap) => rewriteIds(root, mergedBase, [pagesMap, componentsMap], idMap, report),
141
+ });
142
+ report.pushedOps += pushOps.length;
143
+ }
144
+ sourceOps = sourceOpsFor(mergedBase, remote, sourceUploads, progress.idMap);
145
+ try {
146
+ for (let i = 0; i < sourceOps.length; i += SOURCE_OPS_PER_TX) {
147
+ await pushChunked(client, sourceOps.slice(i, i + SOURCE_OPS_PER_TX), pushOps.length === 0 && i === 0 ? txId : mintTxId(), progress, { rebaseInPlace: false, report });
148
+ }
149
+ if (sourceOps.length > 0) {
150
+ report.pushedOps += sourceOps.length;
151
+ report.notes.push(`uploaded the source text of ${sourceOps.length} file(s) to Creator`);
152
+ }
153
+ }
154
+ catch (err) {
155
+ if (!(err instanceof JpatchError && err.code === 'UNKNOWN_ENTITY'))
156
+ throw err;
157
+ // A Creator server from before A12 rejects the entity. The node ops
158
+ // already landed; the code stays local and is offered again once the
159
+ // server stores source text — never silently.
160
+ for (const id of sourceUploads) {
161
+ const entry = mergedBase.sources.get(progress.idMap[id] ?? id);
162
+ if (entry)
163
+ entry.onServer = false;
164
+ }
165
+ sourceOps = [];
166
+ report.notes.push('this Creator server does not store source text yet — the code around your elements stays local until it does');
167
+ }
136
168
  finalize(root, baseStore, mergedBase, pagesMap, componentsMap, progress.head, progress.seq);
137
169
  report.head = progress.head;
138
170
  return report;
@@ -157,12 +189,15 @@ export async function runSyncEngine({ root, client }) {
157
189
  // Files already show the merged view; park what did not land (with
158
190
  // the committed chunks' cids resolved) and adopt the remote head so
159
191
  // the drain pushes exactly these ops next time.
192
+ // Source ops not yet computed (offline during the node push) are
193
+ // simply recomputed next time: the files still differ from the base.
194
+ const allOps = [...pushOps, ...sourceOps];
160
195
  outbox.park({
161
196
  txId: progress.committedOps > 0 ? mintTxId() : txId,
162
- ops: resolveCids(pushOps.slice(progress.committedOps), progress.idMap),
197
+ ops: resolveCids(allOps.slice(progress.committedOps), progress.idMap),
163
198
  parkedAt: new Date().toISOString(),
164
199
  });
165
- report.pushedOps = pushOps.length;
200
+ report.pushedOps = allOps.length;
166
201
  report.offline = true;
167
202
  finalize(root, baseStore, remoteAsBase(remote), readPagesMap(root), readComponentsMap(root), remote.head, remote.seq);
168
203
  return report;
@@ -182,26 +217,33 @@ async function bootstrap(root, client, config, report) {
182
217
  const componentsMap = readComponentsMap(root);
183
218
  placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, state.components, componentsMap);
184
219
  const linker = instanceLinker(componentsMap, state.components);
220
+ // A container with stored source text (A12) materializes from that text —
221
+ // hooks and handlers written in the Creator's editor included — patched to
222
+ // its current tree; the rest is generated.
185
223
  const targets = [];
186
224
  for (const page of state.pages.values()) {
187
225
  const mapping = pagesMap[page.id];
188
226
  if (!mapping)
189
227
  throw new Error(`internal: page ${page.id} was not placed`);
228
+ const tree = linker.unlink(page.root, mapping.file, warnInto(report, mapping.file));
190
229
  targets.push({
230
+ id: page.id,
191
231
  file: mapping.file,
192
- emit: () => emitPage(emitInput(page.name, linker.unlink(page.root, mapping.file, warnInto(report, mapping.file)), mapping), join(root, mapping.file)),
232
+ emit: async () => renderFromSource(state.sources.get(page.id) ?? null, tree, mapping.file, report) ??
233
+ emitPage(emitInput(page.name, tree, mapping), join(root, mapping.file)),
193
234
  });
194
235
  }
195
236
  for (const component of state.components.values()) {
196
237
  const mapping = componentsMap[component.id];
238
+ const tree = linker.unlink(component.root, mapping.file, warnInto(report, mapping.file));
197
239
  targets.push({
240
+ id: component.id,
198
241
  file: mapping.file,
199
- emit: () => emitComponent({
200
- name: component.name,
201
- root: linker.unlink(component.root, mapping.file, warnInto(report, mapping.file)),
202
- }, join(root, mapping.file)),
242
+ emit: async () => renderFromSource(state.sources.get(component.id) ?? null, tree, mapping.file, report) ??
243
+ emitComponent({ name: component.name, root: tree }, join(root, mapping.file)),
203
244
  });
204
245
  }
246
+ const baseSources = new Map();
205
247
  for (const target of targets) {
206
248
  const filePath = join(root, target.file);
207
249
  if (existsSync(filePath)) {
@@ -211,11 +253,23 @@ async function bootstrap(root, client, config, report) {
211
253
  action: 'unchanged',
212
254
  detail: 'exists — left untouched',
213
255
  });
214
- continue;
215
256
  }
216
- mkdirSync(dirname(filePath), { recursive: true });
217
- writeFileSync(filePath, await target.emit());
218
- report.files.push({ file: target.file, action: 'created' });
257
+ else {
258
+ mkdirSync(dirname(filePath), { recursive: true });
259
+ writeFileSync(filePath, await target.emit());
260
+ report.files.push({ file: target.file, action: 'created' });
261
+ }
262
+ // The text 3-way's base: the server's text where it has one, else the
263
+ // file as it stands (an untouched pre-existing file included — its own
264
+ // code then reads as a local change and uploads on the next sync).
265
+ const remoteSource = state.sources.get(target.id) ?? null;
266
+ baseSources.set(target.id, {
267
+ id: remoteSource?.id ?? sourceIdFor(target.id),
268
+ of: target.id,
269
+ lang: remoteSource?.lang ?? 'tsx',
270
+ text: remoteSource?.text ?? readFileSync(filePath, 'utf8'),
271
+ onServer: remoteSource !== null,
272
+ });
219
273
  }
220
274
  const styleResult = syncStyleFile(root, state.style, state.style, report);
221
275
  const assetsResult = await syncAssets(root, client, state.assets, report);
@@ -228,6 +282,7 @@ async function bootstrap(root, client, config, report) {
228
282
  navigation: navResult.mergedNavigation,
229
283
  style: styleResult.mergedStyle,
230
284
  assets: state.assets,
285
+ sources: baseSources,
231
286
  });
232
287
  baseStore.writeStamp(state.head);
233
288
  writePagesMap(root, pagesMap);
@@ -287,6 +342,9 @@ async function buildRemoteModel(client, base, sinceSeq, report) {
287
342
  navigation: structuredClone(base.navigation),
288
343
  style: structuredClone(base.style),
289
344
  assets: structuredClone(base.assets),
345
+ sources: new Map([...base.sources]
346
+ .filter(([, source]) => source.onServer)
347
+ .map(([id, { onServer: _onServer, ...source }]) => [id, source])),
290
348
  head: storedHead.head,
291
349
  seq: storedHead.seq,
292
350
  };
@@ -299,6 +357,7 @@ async function buildRemoteModel(client, base, sinceSeq, report) {
299
357
  navigation: state.navigation,
300
358
  style: state.style,
301
359
  assets: state.assets,
360
+ sources: state.sources,
302
361
  head: state.head,
303
362
  seq: state.seq,
304
363
  };
@@ -309,12 +368,14 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
309
368
  const pagesMap = readPagesMap(root);
310
369
  const componentsMap = readComponentsMap(root);
311
370
  const pushOps = [];
371
+ const sourceUploads = new Set();
312
372
  const mergedBase = {
313
373
  pages: new Map(),
314
374
  components: new Map(),
315
375
  navigation: remote.navigation,
316
376
  style: remote.style,
317
377
  assets: remote.assets,
378
+ sources: new Map(),
318
379
  };
319
380
  // Reset per-attempt report fields (the rebase loop re-runs this).
320
381
  report.files = [];
@@ -336,6 +397,10 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
336
397
  linker,
337
398
  emit: (entity, mapping, tree, filePath) => emitPage(emitInput(entity.name, tree, mapping), filePath),
338
399
  mergedOut: mergedBase.pages,
400
+ baseSources: base.sources,
401
+ remoteSources: remote.sources,
402
+ sourcesOut: mergedBase.sources,
403
+ sourceUploads,
339
404
  pushOps,
340
405
  report,
341
406
  });
@@ -348,6 +413,10 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
348
413
  linker,
349
414
  emit: (entity, _mapping, tree, filePath) => emitComponent({ name: entity.name, root: tree }, filePath),
350
415
  mergedOut: mergedBase.components,
416
+ baseSources: base.sources,
417
+ remoteSources: remote.sources,
418
+ sourcesOut: mergedBase.sources,
419
+ sourceUploads,
351
420
  pushOps,
352
421
  report,
353
422
  });
@@ -358,7 +427,7 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
358
427
  const assetsResult = await syncAssets(root, client, remote.assets, report);
359
428
  pushOps.push(...assetsResult.pushOps);
360
429
  mergedBase.assets = assetsResult.mergedAssets;
361
- return { pushOps, mergedBase, pagesMap, componentsMap };
430
+ return { pushOps, mergedBase, pagesMap, componentsMap, sourceUploads };
362
431
  }
363
432
  /**
364
433
  * The 3-way file merge for one entity kind: extract the file, merge against
@@ -366,7 +435,28 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
366
435
  * deprecate on conflict, and collect the ops that push local changes.
367
436
  */
368
437
  async function mergeFiles(args) {
369
- const { root, kind, base, remote, mapping: mappings, linker, emit, mergedOut, pushOps, report, } = args;
438
+ const { root, kind, base, remote, mapping: mappings, linker, emit, mergedOut, baseSources, remoteSources, sourcesOut, sourceUploads, pushOps, report, } = args;
439
+ /**
440
+ * The file's text after this sync becomes the next base. It goes to the
441
+ * server when a stored text exists and differs from the file (the file
442
+ * always wins: the merge above already folded the Creator's side in), or
443
+ * when the developer changed the code of a file the server has no text for.
444
+ */
445
+ const recordSource = (id, remoteSource, text, codeChanged) => {
446
+ const upload = remoteSource !== null ? remoteSource.text !== text : codeChanged;
447
+ if (upload)
448
+ sourceUploads.add(id);
449
+ sourcesOut.set(id, {
450
+ id: remoteSource?.id ?? sourceIdFor(id),
451
+ of: id,
452
+ lang: remoteSource?.lang ?? 'tsx',
453
+ text,
454
+ onServer: remoteSource !== null || upload,
455
+ });
456
+ };
457
+ /** A container's file text: its stored source patched to `tree`, else generated. */
458
+ const fileTextFor = async (entity, mapping, remoteSource, tree, filePath) => renderFromSource(remoteSource, tree, mapping.file, report) ??
459
+ (await emit(entity, mapping, tree, filePath));
370
460
  const ids = new Set([...base.keys(), ...remote.keys()]);
371
461
  for (const id of ids) {
372
462
  const baseEntity = base.get(id) ?? null;
@@ -380,12 +470,20 @@ async function mergeFiles(args) {
380
470
  }
381
471
  const filePath = join(root, mapping.file);
382
472
  const fileExists = existsSync(filePath);
473
+ const baseSource = baseSources.get(id) ?? null;
474
+ const remoteSource = remoteSources.get(id) ?? null;
383
475
  // local = fresh extract, with NON_LITERAL props refilled from base.
384
476
  let local;
385
477
  // The same tree as the file reads it (linked instances still Customs):
386
478
  // what edits to the file are computed against.
387
479
  let localFile = null;
388
480
  let fileSource = null;
481
+ /**
482
+ * The file as the developer left it, after import healing (a stripped
483
+ * import is an interrupted sync's damage, not a code edit) and before
484
+ * tagging (an element added without a `$sid` IS one).
485
+ */
486
+ let originalSource = null;
389
487
  if (fileExists) {
390
488
  fileSource = readFileSync(filePath, 'utf8');
391
489
  // Pre-extraction import healing: a file left behind by an interrupted
@@ -401,6 +499,7 @@ async function mergeFiles(args) {
401
499
  report.notes.push(`${mapping.file}: restored missing imports before extraction`);
402
500
  }
403
501
  }
502
+ originalSource = fileSource;
404
503
  // An element the developer added without a `$sid` is invisible to
405
504
  // extraction — and so to the Creator — for as long as it stays untagged.
406
505
  // Tag it the way adoption did the file: react-native primitives swapped
@@ -460,7 +559,9 @@ async function mergeFiles(args) {
460
559
  report.conflicts.push({ ...c, file: mapping.file, deprecatedTo });
461
560
  if (remoteEntity) {
462
561
  mkdirSync(dirname(filePath), { recursive: true });
463
- writeFileSync(filePath, await emit(remoteEntity, mapping, linker.unlink(remoteEntity.root, mapping.file, warnInto(report, mapping.file)), filePath));
562
+ const regenerated = await fileTextFor(remoteEntity, mapping, remoteSource, linker.unlink(remoteEntity.root, mapping.file, warnInto(report, mapping.file)), filePath);
563
+ writeFileSync(filePath, regenerated);
564
+ recordSource(id, remoteSource, regenerated, false);
464
565
  report.files.push({
465
566
  file: mapping.file,
466
567
  action: 'deprecated',
@@ -511,27 +612,76 @@ async function mergeFiles(args) {
511
612
  }
512
613
  // Write the merged view into the file — in the file's language.
513
614
  const mergedFile = linker.unlink(merged, mapping.file, warnInto(report, mapping.file));
514
- if (fileExists && fileSource !== null && localFile !== null) {
515
- const edits = treeToEdits(localFile, mergedFile);
516
- // Import healing: patch-inserted elements must be importable, and files
517
- // touched by an interrupted earlier sync may be missing imports already.
518
- const healed = upsertImports(fileSource, importNeedsFor(mergedFile));
519
- if (edits.length > 0 || healed !== fileSource) {
520
- const { source: patched, warnings } = patch(healed, edits);
521
- for (const w of warnings)
522
- report.warnings.push({ ...w, file: mapping.file });
523
- writeFileSync(filePath, patched);
524
- report.files.push({ file: mapping.file, action: 'updated' });
615
+ /** The file's text after this sync. */
616
+ let written;
617
+ /** The developer changed the file since the last sync (unknowable without a base). */
618
+ let localTextChanged = false;
619
+ if (fileExists && fileSource !== null && localFile !== null && originalSource !== null) {
620
+ // The text 3-way (A12): the code outside `$sid` elements. The
621
+ // document merge above already settled every element; here only the
622
+ // surrounding code is at stake. Remote wins when the developer left the
623
+ // file alone; both changed is a conflict, and P0 keeps the local file.
624
+ const remoteTextChanged = remoteSource !== null && baseSource !== null && remoteSource.text !== baseSource.text;
625
+ // Route markers are the CLI's own (placement rewrites them), never a code edit.
626
+ localTextChanged =
627
+ baseSource !== null &&
628
+ withoutRouteMarker(originalSource) !== withoutRouteMarker(baseSource.text);
629
+ const fromRemote = remoteTextChanged && remoteSource !== null
630
+ ? renderFromSource(remoteSource, mergedFile, mapping.file, report)
631
+ : null;
632
+ if (fromRemote !== null) {
633
+ if (localTextChanged) {
634
+ const deprecatedTo = relative(root, deprecateFile(filePath));
635
+ report.conflicts.push({
636
+ sid: mergedFile.sid,
637
+ reason: 'source-text',
638
+ detail: 'the code around the elements changed both here and in Creator',
639
+ file: mapping.file,
640
+ deprecatedTo,
641
+ });
642
+ report.files.push({
643
+ file: mapping.file,
644
+ action: 'deprecated',
645
+ detail: `code conflict — your version kept at ${deprecatedTo}; Creator's code applied`,
646
+ });
647
+ }
648
+ else {
649
+ report.files.push({
650
+ file: mapping.file,
651
+ action: 'updated',
652
+ detail: 'code edited in Creator',
653
+ });
654
+ }
655
+ mkdirSync(dirname(filePath), { recursive: true });
656
+ writeFileSync(filePath, fromRemote);
657
+ written = fromRemote;
525
658
  }
526
659
  else {
527
- report.files.push({ file: mapping.file, action: 'unchanged' });
660
+ const edits = treeToEdits(localFile, mergedFile);
661
+ // Import healing: patch-inserted elements must be importable, and files
662
+ // touched by an interrupted earlier sync may be missing imports already.
663
+ const healed = upsertImports(fileSource, importNeedsFor(mergedFile));
664
+ if (edits.length > 0 || healed !== fileSource) {
665
+ const { source: patched, warnings } = patch(healed, edits);
666
+ for (const w of warnings)
667
+ report.warnings.push({ ...w, file: mapping.file });
668
+ writeFileSync(filePath, patched);
669
+ report.files.push({ file: mapping.file, action: 'updated' });
670
+ written = patched;
671
+ }
672
+ else {
673
+ report.files.push({ file: mapping.file, action: 'unchanged' });
674
+ written = fileSource;
675
+ }
528
676
  }
529
677
  }
530
678
  else {
531
679
  mkdirSync(dirname(filePath), { recursive: true });
532
- writeFileSync(filePath, await emit(remoteEntity, mapping, mergedFile, filePath));
680
+ written = await fileTextFor(remoteEntity, mapping, remoteSource, mergedFile, filePath);
681
+ writeFileSync(filePath, written);
533
682
  report.files.push({ file: mapping.file, action: fileExists ? 'regenerated' : 'created' });
534
683
  }
684
+ recordSource(id, remoteSource, written, localTextChanged);
535
685
  // Push = local changes rebased onto remote.
536
686
  pushOps.push(...diffTree(remoteRoot, merged));
537
687
  mergedOut.set(id, { ...remoteEntity, root: merged });
@@ -544,6 +694,65 @@ function warnInto(report, file) {
544
694
  report.warnings.push({ ...w, file });
545
695
  };
546
696
  }
697
+ /**
698
+ * A container's stored source (A12) brought up to date with `tree` — the
699
+ * FILE-language tree the file must show: its `$sid` elements are re-patched
700
+ * from the document (which is the truth for them), everything around them is
701
+ * the text as stored. Null when the text holds no `$sid` root, which is
702
+ * reported and regenerated rather than silently guessed.
703
+ */
704
+ function renderFromSource(source, tree, file, report) {
705
+ if (source === null)
706
+ return null;
707
+ const extracted = extract(source.text);
708
+ if (extracted.dom === null) {
709
+ report.notes.push(`${file}: the source stored in Creator has no $sid root — regenerated from the canvas instead`);
710
+ return null;
711
+ }
712
+ fillNonLiteralProps(extracted.dom, tree, extracted.warnings);
713
+ const healed = upsertImports(source.text, importNeedsFor(tree));
714
+ const edits = treeToEdits(extracted.dom, tree);
715
+ if (edits.length === 0)
716
+ return healed;
717
+ const { source: patched, warnings } = patch(healed, edits);
718
+ for (const w of warnings)
719
+ report.warnings.push({ ...w, file });
720
+ return patched;
721
+ }
722
+ /**
723
+ * Source ops for the containers the merge marked for upload: a create where
724
+ * the server has no Source yet, an update otherwise. `idMap` resolves
725
+ * containers created by the node push that just landed.
726
+ */
727
+ function sourceOpsFor(mergedBase, remote, uploads, idMap) {
728
+ const ops = [];
729
+ for (const containerId of uploads) {
730
+ const serverId = idMap[containerId] ?? containerId;
731
+ if (serverId.startsWith('c_'))
732
+ continue; // its page never landed (offline): next sync
733
+ const source = mergedBase.sources.get(serverId);
734
+ if (!source)
735
+ throw new Error(`internal: no merged source for ${serverId}`);
736
+ const remoteSource = remote.sources.get(serverId) ?? null;
737
+ if (remoteSource === null) {
738
+ ops.push({
739
+ op: 'create',
740
+ entity: 'Source',
741
+ cid: mintCid(),
742
+ data: { of: serverId, lang: source.lang, text: source.text },
743
+ });
744
+ }
745
+ else if (remoteSource.text !== source.text) {
746
+ ops.push({
747
+ op: 'update',
748
+ entity: 'Source',
749
+ id: remoteSource.id,
750
+ patch: { text: source.text },
751
+ });
752
+ }
753
+ }
754
+ return ops;
755
+ }
547
756
  /** NON_LITERAL_PROP contract: expression props keep their last-known base value. */
548
757
  function fillNonLiteralProps(local, baseRoot, warnings) {
549
758
  const baseIndex = new Map();
@@ -623,6 +832,17 @@ function rewriteIds(root, mergedBase, fileMaps, idMap, report) {
623
832
  renameTree(page.root);
624
833
  for (const component of mergedBase.components.values())
625
834
  renameTree(component.root);
835
+ // Source text mirrors the files: same rewrite, and a container minted by
836
+ // this push re-keys its entry under the server id.
837
+ const renamedSources = new Map();
838
+ for (const [containerId, source] of mergedBase.sources) {
839
+ let text = source.text;
840
+ for (const [cid, id] of entries)
841
+ text = text.split(`"${cid}"`).join(`"${id}"`);
842
+ const of = idMap[containerId] ?? containerId;
843
+ renamedSources.set(of, { ...source, id: sourceIdFor(of), of, text });
844
+ }
845
+ mergedBase.sources = renamedSources;
626
846
  if (mergedBase.assets) {
627
847
  mergedBase.assets = resolveAssetIds(mergedBase.assets, idMap);
628
848
  writeAssetMap(root, mergedBase.assets);
@@ -637,6 +857,7 @@ function remoteAsBase(remote) {
637
857
  navigation: remote.navigation,
638
858
  style: remote.style,
639
859
  assets: remote.assets,
860
+ sources: new Map([...remote.sources].map(([id, source]) => [id, { ...source, onServer: true }])),
640
861
  };
641
862
  }
642
863
  function finalize(root, baseStore, mergedBase, pagesMap, componentsMap, head, seq) {