@agent-native/core 0.149.4 → 0.149.6

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.
Files changed (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +5 -0
  3. package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +5 -0
  4. package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +5 -0
  5. package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +5 -0
  6. package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +5 -0
  7. package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +5 -0
  8. package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +5 -0
  9. package/corpus/templates/content/actions/_document-discovery-query.ts +127 -0
  10. package/corpus/templates/content/actions/list-documents.ts +189 -198
  11. package/corpus/templates/content/actions/search-documents.ts +95 -24
  12. package/corpus/templates/content/app/hooks/use-documents.ts +84 -6
  13. package/corpus/templates/content/shared/api.ts +10 -0
  14. package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +5 -0
  15. package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +5 -0
  16. package/corpus/templates/factory/.agents/skills/frontend-design/SKILL.md +5 -0
  17. package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +5 -0
  18. package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +5 -0
  19. package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +5 -0
  20. package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +5 -0
  21. package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +5 -0
  22. package/corpus/templates/slides/.agents/skills/slide-editing/SKILL.md +23 -4
  23. package/corpus/templates/slides/actions/get-deck.ts +25 -9
  24. package/corpus/templates/slides/actions/patch-deck.ts +51 -0
  25. package/corpus/templates/slides/actions/update-slide.ts +144 -14
  26. package/corpus/templates/slides/app/lib/create-deck-generation.ts +12 -4
  27. package/corpus/templates/slides/changelog/2026-08-11-pdf-requests-to-restyle-and-preserve-a-source-deck-now-impor.md +6 -0
  28. package/corpus/templates/slides/changelog/2026-08-11-slide-edits-preserve-their-layout-without-introducing-hidden.md +6 -0
  29. package/corpus/templates/slides/changelog/2026-08-11-slides-reliably-imports-uploaded-pdfs-and-presentations.md +6 -0
  30. package/corpus/templates/slides/changelog/2026-08-11-slides-support-atomic-code-style-html-patches-without-regene.md +6 -0
  31. package/corpus/templates/slides/package.json +1 -0
  32. package/corpus/templates/slides/server/lib/slide-content-patch.ts +355 -0
  33. package/corpus/templates/slides/server/lib/uploaded-reference-storage.ts +28 -9
  34. package/corpus/templates/slides/server/plugins/agent-chat.ts +12 -3
  35. package/corpus/templates/tasks/.agents/skills/frontend-design/SKILL.md +5 -0
  36. package/dist/cli/create.js +49 -0
  37. package/dist/client/chat-first/index.d.ts +1 -1
  38. package/dist/client/chat-first/index.js +1 -1
  39. package/dist/client/chat-first/primary-nav.d.ts +3 -1
  40. package/dist/client/chat-first/primary-nav.js +8 -2
  41. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  42. package/dist/notifications/routes.d.ts +3 -3
  43. package/dist/observability/routes.d.ts +3 -3
  44. package/dist/private-blob/registry.js +67 -4
  45. package/dist/resources/handlers.d.ts +1 -1
  46. package/dist/server/realtime-token.d.ts +1 -1
  47. package/dist/server/transcribe-voice.d.ts +1 -1
  48. package/dist/templates/chat/.agents/skills/frontend-design/SKILL.md +5 -0
  49. package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +5 -0
  50. package/dist/templates/default/pnpm-workspace.yaml +44 -0
  51. package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +5 -0
  52. package/dist/templates/workspace-root/pnpm-workspace.yaml +44 -0
  53. package/package.json +1 -1
  54. package/src/templates/chat/.agents/skills/frontend-design/SKILL.md +5 -0
  55. package/src/templates/default/.agents/skills/frontend-design/SKILL.md +5 -0
  56. package/src/templates/default/pnpm-workspace.yaml +44 -0
  57. package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +5 -0
  58. package/src/templates/workspace-root/pnpm-workspace.yaml +44 -0
@@ -16,6 +16,11 @@ import { normalizeSlidePadding } from "../app/lib/normalize-slide-padding.js";
16
16
  import { getDb, schema } from "../server/db/index.js"; // ensure registerShareableResource runs
17
17
  import { notifyClients } from "../server/handlers/decks.js";
18
18
  import { createDeckVersionSnapshot } from "../server/lib/deck-versions.js";
19
+ import {
20
+ applySlideContentEdits,
21
+ formatSlideHtml,
22
+ type SlideContentEdit,
23
+ } from "../server/lib/slide-content-patch.js";
19
24
  import {
20
25
  assertSourceSlidePreserved,
21
26
  sourceImportForDeck,
@@ -92,8 +97,7 @@ function storedCreativeContext(value: unknown): {
92
97
 
93
98
  export default defineAction({
94
99
  description:
95
- "Surgically edit a slide's content using search-replace or full replacement. " +
96
- "Syncs live to open editors. Prefer this over full deck rewrites. Source-imported slides preserve their original images and factual copy by default.",
100
+ "Atomically patch a slide's HTML like a code editor: send several exact edits against the current source, optionally format it with Prettier, and sync the result live to open editors. Prefer edits over fullContent so unrelated markup is not regenerated. Use baseContentHash from get-deck to reject stale patches. Content edits clear existing click-reveal metadata; use patch-deck with the complete animations list when the edit intentionally changes both content and reveals. Source-imported slides preserve their original images and factual copy by default.",
97
101
  schema: z.object({
98
102
  deckId: z.string().describe("Deck ID"),
99
103
  slideId: z.string().describe("Slide ID"),
@@ -109,6 +113,64 @@ export default defineAction({
109
113
  .string()
110
114
  .optional()
111
115
  .describe("Full HTML to replace entire slide content"),
116
+ edits: z
117
+ .array(
118
+ z.union([
119
+ z.object({
120
+ op: z.literal("replace").optional(),
121
+ find: z.string(),
122
+ replace: z.string(),
123
+ all: z.boolean().optional(),
124
+ occurrence: z.number().int().positive().optional(),
125
+ expectedMatches: z.number().int().nonnegative().optional(),
126
+ required: z.boolean().optional(),
127
+ }),
128
+ z.object({
129
+ op: z.enum(["insert-before", "insert-after"]),
130
+ marker: z.string(),
131
+ content: z.string(),
132
+ occurrence: z.number().int().positive().optional(),
133
+ expectedMatches: z.number().int().nonnegative().optional(),
134
+ required: z.boolean().optional(),
135
+ }),
136
+ z.object({
137
+ op: z.literal("replace-between"),
138
+ start: z.string(),
139
+ end: z.string(),
140
+ content: z.string(),
141
+ includeDelimiters: z.boolean().optional(),
142
+ expectedMatches: z.number().int().nonnegative().optional(),
143
+ required: z.boolean().optional(),
144
+ }),
145
+ z.object({
146
+ op: z.literal("regex-replace"),
147
+ pattern: z.string(),
148
+ replace: z.string(),
149
+ flags: z.string().optional(),
150
+ all: z.boolean().optional(),
151
+ expectedMatches: z.number().int().nonnegative().optional(),
152
+ required: z.boolean().optional(),
153
+ }),
154
+ ]),
155
+ )
156
+ .min(1)
157
+ .optional()
158
+ .describe(
159
+ "Ordered atomic edits against the current HTML. Each edit must match unless required=false. Use expectedMatches to make ambiguity explicit.",
160
+ ),
161
+ baseContentHash: z
162
+ .string()
163
+ .optional()
164
+ .describe(
165
+ "Hash returned by get-deck for the exact slide source being patched. The edit is rejected if the source changed since it was read.",
166
+ ),
167
+ format: z
168
+ .boolean()
169
+ .optional()
170
+ .default(false)
171
+ .describe(
172
+ "Format the resulting HTML with Prettier before persisting it.",
173
+ ),
112
174
  preserveSource: z
113
175
  .boolean()
114
176
  .optional()
@@ -142,13 +204,24 @@ export default defineAction({
142
204
  find,
143
205
  replace,
144
206
  fullContent,
207
+ edits,
208
+ baseContentHash,
209
+ format,
145
210
  preserveSource,
146
211
  contextPackId,
147
212
  contextModeOverride,
148
213
  reuseLabels,
149
214
  } = args;
150
- if (!find && !fullContent) {
151
- throw new Error("Either --find or --fullContent is required");
215
+ if (!edits && find === undefined && fullContent === undefined) {
216
+ throw new Error("One of --edits, --find, or --fullContent is required");
217
+ }
218
+ if (find !== undefined && find.length === 0) {
219
+ throw new Error("find must not be empty for legacy search/replace");
220
+ }
221
+ if (edits && (find !== undefined || fullContent !== undefined)) {
222
+ throw new Error(
223
+ "Use --edits instead of combining it with --find or --fullContent",
224
+ );
152
225
  }
153
226
  await assertAccess("deck", deckId, "editor");
154
227
 
@@ -203,6 +276,15 @@ export default defineAction({
203
276
  throw new Error(`Slide ${slideId} not found in deck ${deckId}`);
204
277
  }
205
278
 
279
+ if (
280
+ baseContentHash !== undefined &&
281
+ hashSlideContent(String(slide.content ?? "")) !== baseContentHash
282
+ ) {
283
+ throw new Error(
284
+ "Slide content changed since it was read. Call get-deck with this slideId again and rebase the patch.",
285
+ );
286
+ }
287
+
206
288
  // ─── Apply the edit to the slide content in decks.data ────────────────
207
289
  //
208
290
  // The agent edits the canonical slide HTML stored in `decks.data` (SQL is
@@ -215,8 +297,9 @@ export default defineAction({
215
297
  // structure renders and merges with concurrent typing via the Yjs CRDT.
216
298
  let applied = false;
217
299
  let notFound = false;
300
+ const previousContent = String(slide.content ?? "");
218
301
 
219
- if (fullContent) {
302
+ if (fullContent !== undefined) {
220
303
  const nextContent = normalizeSlidePadding(fullContent);
221
304
  assertSourceSlidePreserved({
222
305
  metadata: sourceImportForDeck(deck.sourceImport),
@@ -225,16 +308,35 @@ export default defineAction({
225
308
  preserveSource,
226
309
  });
227
310
  slide.content = nextContent;
228
- applied = true;
229
- } else if (find) {
230
- const idx = (slide.content as string).indexOf(find);
311
+ applied = nextContent !== previousContent;
312
+ } else if (edits) {
313
+ const sourceContent = format
314
+ ? await formatSlideHtml(previousContent)
315
+ : previousContent;
316
+ const patched = await applySlideContentEdits(
317
+ sourceContent,
318
+ edits as SlideContentEdit[],
319
+ format,
320
+ );
321
+ const nextContent = normalizeSlidePadding(patched.content);
322
+ assertSourceSlidePreserved({
323
+ metadata: sourceImportForDeck(deck.sourceImport),
324
+ slideId,
325
+ nextContent,
326
+ preserveSource,
327
+ });
328
+ slide.content = nextContent;
329
+ applied = patched.changed;
330
+ if (!applied) slide.content = previousContent;
331
+ } else if (find !== undefined) {
332
+ const idx = previousContent.indexOf(find);
231
333
  if (idx === -1) {
232
334
  notFound = true;
233
335
  } else {
234
336
  const nextContent =
235
- slide.content.slice(0, idx) +
337
+ previousContent.slice(0, idx) +
236
338
  (replace ?? "") +
237
- slide.content.slice(idx + find.length);
339
+ previousContent.slice(idx + find.length);
238
340
  assertSourceSlidePreserved({
239
341
  metadata: sourceImportForDeck(deck.sourceImport),
240
342
  slideId,
@@ -242,10 +344,19 @@ export default defineAction({
242
344
  preserveSource,
243
345
  });
244
346
  slide.content = nextContent;
245
- applied = true;
347
+ applied = nextContent !== previousContent;
246
348
  }
247
349
  }
248
350
 
351
+ // Animation targets are paths into the persisted HTML. A content edit
352
+ // can keep every path valid while changing which visual element lives at
353
+ // that path, so preserving the old list would reveal the wrong content.
354
+ // patch-deck is the explicit escape hatch when content and animations
355
+ // are intentionally revised together.
356
+ if (applied && Array.isArray(slide.animations)) {
357
+ delete slide.animations;
358
+ }
359
+
249
360
  // ─── Persist to SQL ───────────────────────────────────────────────────
250
361
  //
251
362
  // The fresh `updatedAt` (on both the deck JSON and the row) is the signal
@@ -352,23 +463,41 @@ export default defineAction({
352
463
  notFound,
353
464
  slide,
354
465
  slideIndex,
466
+ contentHash: hashSlideContent(String(slide.content ?? "")),
355
467
  contextMode,
356
468
  contextPackId: validated.contextPackId,
357
469
  reuseLabels: slideReuseLabels,
358
470
  };
359
471
  }
360
472
 
361
- return { applied, notFound, slide, slideIndex };
473
+ return {
474
+ applied,
475
+ notFound,
476
+ slide,
477
+ slideIndex,
478
+ contentHash: hashSlideContent(String(slide.content ?? "")),
479
+ };
362
480
  });
363
481
 
364
482
  if (rmw.notFound) {
365
483
  return {
366
484
  ok: false,
367
- message: `Text not found in slide: "${find!.slice(0, 60)}". Use get-deck with compact=false to see the current slide HTML.`,
485
+ message: `Text not found in slide: "${find!.slice(0, 60)}". Use get-deck with this slideId to see the current slide HTML.`,
368
486
  };
369
487
  }
370
488
 
371
489
  const { applied } = rmw;
490
+ if (!applied) {
491
+ return {
492
+ ok: true,
493
+ deckId,
494
+ slideId,
495
+ applied: false,
496
+ contentHash: rmw.contentHash,
497
+ deepLink: deckDeepLink(deckId),
498
+ };
499
+ }
500
+
372
501
  // Start the freshness window after the SQL write and before notifying the
373
502
  // editor. This keeps a fast render from being rejected as stale.
374
503
  const fitSince = Date.now();
@@ -390,7 +519,7 @@ export default defineAction({
390
519
  notifyClients(deckId, { slideId, actor: "agent" });
391
520
 
392
521
  console.log(
393
- `update-slide: deck=${deckId} slide=${slideId} ${find ? `find="${find.slice(0, 40)}"` : "fullContent"} applied=${applied}`,
522
+ `update-slide: deck=${deckId} slide=${slideId} ${edits ? `edits=${edits.length}` : find !== undefined ? `find="${find.slice(0, 40)}"` : "fullContent"} applied=${applied}`,
394
523
  );
395
524
 
396
525
  // Wait briefly for the editor to re-render and measure. If the patched
@@ -408,6 +537,7 @@ export default defineAction({
408
537
  deckId,
409
538
  slideId,
410
539
  applied,
540
+ contentHash: rmw.contentHash,
411
541
  deepLink: deckDeepLink(deckId),
412
542
  ...(rmw.contextMode
413
543
  ? {
@@ -108,11 +108,19 @@ export function isSourceImprovementRequest(
108
108
  /\bmake\b[\s\S]{0,40}\b(better|modern|professional|polished|prett\w*)\b/.test(
109
109
  normalized,
110
110
  );
111
+ const asksToPreserveSource =
112
+ /(?:\b(turn|convert|transform)\b[\s\S]{0,50}\b(into|to)\b[\s\S]{0,30}\b(deck|presentation|slides?)\b|\b(create|build|make|generate)\b[\s\S]{0,80}\b(deck|presentation|slides?)\b[\s\S]{0,80}\bfrom\b)/.test(
113
+ normalized,
114
+ ) &&
115
+ /\b(copy|slide[- ]for[- ]slide|preserv\w*|same order|before\s*\/?\s*after|placeholder\w*|out of order)\b/.test(
116
+ normalized,
117
+ );
111
118
  // A source deck attachment is the object being improved even when the
112
- // prompt uses an implicit phrase such as "make this prettier". Requiring a
113
- // source noun here silently falls back to reference-only generation and can
114
- // discard the uploaded deck's slide IDs and content.
115
- return asksToImprove;
119
+ // prompt uses an implicit phrase such as "make this prettier" or asks to
120
+ // copy and restyle it. Requiring a source noun here silently falls back to
121
+ // reference-only generation and can discard the uploaded deck's slide IDs
122
+ // and content.
123
+ return asksToImprove || asksToPreserveSource;
116
124
  }
117
125
 
118
126
  function describeUploadedFilesForAgent(
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-11
4
+ ---
5
+
6
+ PDF requests to restyle and preserve a source deck now import the full deck before editing
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-11
4
+ ---
5
+
6
+ Slide edits preserve their layout without introducing hidden duplicate elements or stale click reveals
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-11
4
+ ---
5
+
6
+ Slides reliably imports uploaded PDFs and presentations
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-11
4
+ ---
5
+
6
+ Slides support atomic code-style HTML patches without regenerating whole slides
@@ -57,6 +57,7 @@
57
57
  "pdf-parse": "^2.4.5",
58
58
  "pdfjs-dist": "5.4.296",
59
59
  "pptxgenjs": "4.0.1",
60
+ "prettier": "^3.9.6",
60
61
  "react-markdown": "^10.1.0",
61
62
  "rehype-raw": "^7.0.0",
62
63
  "y-protocols": "catalog:",
@@ -0,0 +1,355 @@
1
+ export type SlideContentEdit =
2
+ | {
3
+ op?: "replace";
4
+ find: string;
5
+ replace: string;
6
+ all?: boolean;
7
+ occurrence?: number;
8
+ expectedMatches?: number;
9
+ required?: boolean;
10
+ }
11
+ | {
12
+ op: "insert-before" | "insert-after";
13
+ marker: string;
14
+ content: string;
15
+ occurrence?: number;
16
+ expectedMatches?: number;
17
+ required?: boolean;
18
+ }
19
+ | {
20
+ op: "replace-between";
21
+ start: string;
22
+ end: string;
23
+ content: string;
24
+ includeDelimiters?: boolean;
25
+ expectedMatches?: number;
26
+ required?: boolean;
27
+ }
28
+ | {
29
+ op: "regex-replace";
30
+ pattern: string;
31
+ replace: string;
32
+ flags?: string;
33
+ all?: boolean;
34
+ expectedMatches?: number;
35
+ required?: boolean;
36
+ };
37
+
38
+ export class SlideContentEditError extends Error {
39
+ readonly code = "slide_content_edit_failed";
40
+
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "SlideContentEditError";
44
+ }
45
+ }
46
+
47
+ export interface SlideContentPatchResult {
48
+ content: string;
49
+ applied: string[];
50
+ formatted: boolean;
51
+ changed: boolean;
52
+ }
53
+
54
+ /**
55
+ * Applies every edit to an in-memory string before the caller persists it.
56
+ * A failed edit throws, so callers never write a partially applied patch list.
57
+ */
58
+ export async function applySlideContentEdits(
59
+ currentContent: string,
60
+ edits: readonly SlideContentEdit[],
61
+ format = false,
62
+ ): Promise<SlideContentPatchResult> {
63
+ try {
64
+ let content = currentContent;
65
+ const applied: string[] = [];
66
+
67
+ for (const edit of edits) {
68
+ const result = applyEdit(content, edit);
69
+ content = result.content;
70
+ applied.push(result.summary);
71
+ }
72
+
73
+ const changed = content !== currentContent;
74
+
75
+ if (format) {
76
+ content = await formatSlideHtml(content);
77
+ }
78
+
79
+ return { content, applied, formatted: format, changed };
80
+ } catch (error) {
81
+ if (error instanceof SlideContentEditError) throw error;
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ throw new SlideContentEditError(message);
84
+ }
85
+ }
86
+
87
+ export async function formatSlideHtml(content: string): Promise<string> {
88
+ try {
89
+ const prettier = await import("prettier");
90
+ return await prettier.format(content, {
91
+ parser: "html",
92
+ htmlWhitespaceSensitivity: "ignore",
93
+ });
94
+ } catch (error) {
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ if (
97
+ message.includes("Cannot find package 'prettier'") ||
98
+ message.includes('Cannot find package "prettier"') ||
99
+ message.includes("Cannot find module 'prettier'") ||
100
+ message.includes('Cannot find module "prettier"')
101
+ ) {
102
+ throw new SlideContentEditError(
103
+ "HTML formatting is unavailable because Prettier is not installed",
104
+ );
105
+ }
106
+ throw new SlideContentEditError(`Unable to format slide HTML: ${message}`);
107
+ }
108
+ }
109
+
110
+ function applyEdit(
111
+ content: string,
112
+ edit: SlideContentEdit,
113
+ ): { content: string; summary: string } {
114
+ switch (edit.op ?? "replace") {
115
+ case "replace":
116
+ return applyLiteralReplace(
117
+ content,
118
+ edit as Extract<SlideContentEdit, { op?: "replace" }>,
119
+ );
120
+ case "insert-before":
121
+ case "insert-after":
122
+ return applyInsert(
123
+ content,
124
+ edit as Extract<
125
+ SlideContentEdit,
126
+ { op: "insert-before" | "insert-after" }
127
+ >,
128
+ );
129
+ case "replace-between":
130
+ return applyReplaceBetween(
131
+ content,
132
+ edit as Extract<SlideContentEdit, { op: "replace-between" }>,
133
+ );
134
+ case "regex-replace":
135
+ return applyRegexReplace(
136
+ content,
137
+ edit as Extract<SlideContentEdit, { op: "regex-replace" }>,
138
+ );
139
+ default:
140
+ throw new SlideContentEditError(
141
+ `Unsupported slide content edit operation: ${String(edit.op)}`,
142
+ );
143
+ }
144
+ }
145
+
146
+ function applyLiteralReplace(
147
+ content: string,
148
+ edit: Extract<SlideContentEdit, { op?: "replace" }>,
149
+ ): { content: string; summary: string } {
150
+ const matches = countOccurrences(content, edit.find);
151
+ assertMatchCount("replace", matches, edit.expectedMatches, edit.required);
152
+ if (matches === 0) return { content, summary: "replace:0" };
153
+
154
+ if (edit.occurrence !== undefined) {
155
+ return {
156
+ content: replaceNth(content, edit.find, edit.replace, edit.occurrence),
157
+ summary: `replace:nth:${edit.occurrence}`,
158
+ };
159
+ }
160
+
161
+ if (edit.all) {
162
+ return {
163
+ content: content.split(edit.find).join(edit.replace),
164
+ summary: `replace:all:${matches}`,
165
+ };
166
+ }
167
+
168
+ return {
169
+ content: content.replace(edit.find, edit.replace),
170
+ summary: "replace:first",
171
+ };
172
+ }
173
+
174
+ function applyInsert(
175
+ content: string,
176
+ edit: Extract<SlideContentEdit, { op: "insert-before" | "insert-after" }>,
177
+ ): { content: string; summary: string } {
178
+ const matches = countOccurrences(content, edit.marker);
179
+ assertMatchCount(edit.op, matches, edit.expectedMatches, edit.required);
180
+ if (matches === 0) return { content, summary: `${edit.op}:0` };
181
+
182
+ const occurrence = edit.occurrence ?? 1;
183
+ const index = nthIndexOf(content, edit.marker, occurrence);
184
+ if (index < 0) {
185
+ throw new SlideContentEditError(
186
+ `${edit.op} could not find occurrence ${occurrence}`,
187
+ );
188
+ }
189
+ const insertAt =
190
+ edit.op === "insert-before" ? index : index + edit.marker.length;
191
+ return {
192
+ content:
193
+ content.slice(0, insertAt) + edit.content + content.slice(insertAt),
194
+ summary: `${edit.op}:${occurrence}`,
195
+ };
196
+ }
197
+
198
+ function applyReplaceBetween(
199
+ content: string,
200
+ edit: Extract<SlideContentEdit, { op: "replace-between" }>,
201
+ ): { content: string; summary: string } {
202
+ const ranges = findBetweenRanges(content, edit.start, edit.end);
203
+ assertMatchCount(
204
+ "replace-between",
205
+ ranges.length,
206
+ edit.expectedMatches,
207
+ edit.required,
208
+ );
209
+ if (!ranges.length) return { content, summary: "replace-between:0" };
210
+ if (ranges.length > 1 && edit.expectedMatches === undefined) {
211
+ throw new SlideContentEditError(
212
+ `replace-between matched ${ranges.length} ranges; pass expectedMatches to confirm`,
213
+ );
214
+ }
215
+
216
+ let next = content;
217
+ for (const range of ranges.slice().reverse()) {
218
+ const start = edit.includeDelimiters ? range.start : range.innerStart;
219
+ const end = edit.includeDelimiters ? range.end : range.innerEnd;
220
+ next = next.slice(0, start) + edit.content + next.slice(end);
221
+ }
222
+ return { content: next, summary: `replace-between:${ranges.length}` };
223
+ }
224
+
225
+ function applyRegexReplace(
226
+ content: string,
227
+ edit: Extract<SlideContentEdit, { op: "regex-replace" }>,
228
+ ): { content: string; summary: string } {
229
+ const flags = normalizeRegexFlags(edit.flags, edit.all);
230
+ const regex = new RegExp(edit.pattern, flags);
231
+ const countRegex = new RegExp(edit.pattern, ensureGlobal(flags));
232
+ const matches = Array.from(content.matchAll(countRegex)).length;
233
+ assertMatchCount(
234
+ "regex-replace",
235
+ matches,
236
+ edit.expectedMatches,
237
+ edit.required,
238
+ );
239
+ if (matches === 0) return { content, summary: "regex-replace:0" };
240
+ return {
241
+ content: content.replace(regex, edit.replace),
242
+ summary: `regex-replace:${edit.all ? "all" : "first"}:${matches}`,
243
+ };
244
+ }
245
+
246
+ function assertMatchCount(
247
+ op: string,
248
+ actual: number,
249
+ expected: number | undefined,
250
+ required: boolean | undefined,
251
+ ): void {
252
+ if (expected !== undefined && actual !== expected) {
253
+ throw new SlideContentEditError(
254
+ `${op} expected ${expected} match(es), found ${actual}`,
255
+ );
256
+ }
257
+ if (expected === undefined && required !== false && actual === 0) {
258
+ throw new SlideContentEditError(`${op} found no matches`);
259
+ }
260
+ }
261
+
262
+ function countOccurrences(content: string, needle: string): number {
263
+ if (!needle) {
264
+ throw new SlideContentEditError("Patch find/marker text cannot be empty");
265
+ }
266
+ let count = 0;
267
+ let index = 0;
268
+ while (true) {
269
+ index = content.indexOf(needle, index);
270
+ if (index < 0) return count;
271
+ count += 1;
272
+ index += needle.length;
273
+ }
274
+ }
275
+
276
+ function nthIndexOf(
277
+ content: string,
278
+ needle: string,
279
+ occurrence: number,
280
+ ): number {
281
+ if (!Number.isInteger(occurrence) || occurrence < 1) {
282
+ throw new SlideContentEditError("occurrence must be a positive integer");
283
+ }
284
+ let index = -1;
285
+ let from = 0;
286
+ for (let i = 0; i < occurrence; i += 1) {
287
+ index = content.indexOf(needle, from);
288
+ if (index < 0) return -1;
289
+ from = index + needle.length;
290
+ }
291
+ return index;
292
+ }
293
+
294
+ function replaceNth(
295
+ content: string,
296
+ find: string,
297
+ replace: string,
298
+ occurrence: number,
299
+ ): string {
300
+ const index = nthIndexOf(content, find, occurrence);
301
+ if (index < 0) {
302
+ throw new SlideContentEditError(
303
+ `replace could not find occurrence ${occurrence}`,
304
+ );
305
+ }
306
+ return content.slice(0, index) + replace + content.slice(index + find.length);
307
+ }
308
+
309
+ function findBetweenRanges(
310
+ content: string,
311
+ startMarker: string,
312
+ endMarker: string,
313
+ ): Array<{ start: number; innerStart: number; innerEnd: number; end: number }> {
314
+ if (!startMarker || !endMarker) {
315
+ throw new SlideContentEditError(
316
+ "replace-between requires non-empty start and end markers",
317
+ );
318
+ }
319
+ const ranges: Array<{
320
+ start: number;
321
+ innerStart: number;
322
+ innerEnd: number;
323
+ end: number;
324
+ }> = [];
325
+ let cursor = 0;
326
+ while (cursor < content.length) {
327
+ const start = content.indexOf(startMarker, cursor);
328
+ if (start < 0) break;
329
+ const innerStart = start + startMarker.length;
330
+ const innerEnd = content.indexOf(endMarker, innerStart);
331
+ if (innerEnd < 0) {
332
+ throw new SlideContentEditError(
333
+ "replace-between found a start marker without an end",
334
+ );
335
+ }
336
+ const end = innerEnd + endMarker.length;
337
+ ranges.push({ start, innerStart, innerEnd, end });
338
+ cursor = end;
339
+ }
340
+ return ranges;
341
+ }
342
+
343
+ function normalizeRegexFlags(flags: string | undefined, all?: boolean): string {
344
+ const unique = new Set((flags ?? "").split("").filter(Boolean));
345
+ if (all) {
346
+ unique.add("g");
347
+ } else {
348
+ unique.delete("g");
349
+ }
350
+ return Array.from(unique).join("");
351
+ }
352
+
353
+ function ensureGlobal(flags: string): string {
354
+ return flags.includes("g") ? flags : `${flags}g`;
355
+ }