@portel/photon 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  2. package/dist/auto-ui/beam/routes/api-config.js +29 -8
  3. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  4. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-marketplace.js +3 -0
  6. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  7. package/dist/auto-ui/beam.d.ts.map +1 -1
  8. package/dist/auto-ui/beam.js +167 -48
  9. package/dist/auto-ui/beam.js.map +1 -1
  10. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  11. package/dist/auto-ui/bridge/index.js +578 -0
  12. package/dist/auto-ui/bridge/index.js.map +1 -1
  13. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  14. package/dist/auto-ui/bridge/renderers.js +7 -3
  15. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  16. package/dist/auto-ui/bridge/types.d.ts +6 -0
  17. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  18. package/dist/auto-ui/frontend/pure-view.html +289 -0
  19. package/dist/auto-ui/photon-bridge.d.ts +11 -0
  20. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  21. package/dist/auto-ui/photon-bridge.js +75 -1
  22. package/dist/auto-ui/photon-bridge.js.map +1 -1
  23. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  24. package/dist/auto-ui/streamable-http-transport.js +29 -3
  25. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  26. package/dist/beam-form.bundle.js +5707 -0
  27. package/dist/beam-form.bundle.js.map +7 -0
  28. package/dist/beam.bundle.js +1947 -523
  29. package/dist/beam.bundle.js.map +4 -4
  30. package/dist/cli/commands/info.d.ts.map +1 -1
  31. package/dist/cli/commands/info.js +15 -2
  32. package/dist/cli/commands/info.js.map +1 -1
  33. package/dist/daemon/client.d.ts +5 -0
  34. package/dist/daemon/client.d.ts.map +1 -1
  35. package/dist/daemon/client.js +50 -0
  36. package/dist/daemon/client.js.map +1 -1
  37. package/dist/daemon/manager.d.ts +15 -0
  38. package/dist/daemon/manager.d.ts.map +1 -1
  39. package/dist/daemon/manager.js +142 -11
  40. package/dist/daemon/manager.js.map +1 -1
  41. package/dist/deploy/cloudflare.d.ts.map +1 -1
  42. package/dist/deploy/cloudflare.js +10 -2
  43. package/dist/deploy/cloudflare.js.map +1 -1
  44. package/dist/loader.d.ts.map +1 -1
  45. package/dist/loader.js +50 -3
  46. package/dist/loader.js.map +1 -1
  47. package/dist/marketplace-manager.d.ts +9 -0
  48. package/dist/marketplace-manager.d.ts.map +1 -1
  49. package/dist/marketplace-manager.js +115 -42
  50. package/dist/marketplace-manager.js.map +1 -1
  51. package/dist/meta.d.ts +51 -0
  52. package/dist/meta.d.ts.map +1 -0
  53. package/dist/meta.js +320 -0
  54. package/dist/meta.js.map +1 -0
  55. package/dist/photon-cli-runner.d.ts.map +1 -1
  56. package/dist/photon-cli-runner.js +30 -5
  57. package/dist/photon-cli-runner.js.map +1 -1
  58. package/dist/photon-doc-extractor.d.ts +1 -0
  59. package/dist/photon-doc-extractor.d.ts.map +1 -1
  60. package/dist/photon-doc-extractor.js +33 -21
  61. package/dist/photon-doc-extractor.js.map +1 -1
  62. package/dist/photons/docs/ui/docs.html +441 -0
  63. package/dist/photons/docs.photon.d.ts +237 -0
  64. package/dist/photons/docs.photon.d.ts.map +1 -0
  65. package/dist/photons/docs.photon.js +483 -0
  66. package/dist/photons/docs.photon.js.map +1 -0
  67. package/dist/photons/docs.photon.ts +536 -0
  68. package/dist/photons/slides.photon.d.ts +212 -0
  69. package/dist/photons/slides.photon.d.ts.map +1 -0
  70. package/dist/photons/slides.photon.js +355 -0
  71. package/dist/photons/slides.photon.js.map +1 -0
  72. package/dist/photons/slides.photon.ts +370 -0
  73. package/dist/photons/spreadsheet/ui/spreadsheet.html +779 -0
  74. package/dist/photons/spreadsheet.photon.d.ts +554 -0
  75. package/dist/photons/spreadsheet.photon.d.ts.map +1 -0
  76. package/dist/photons/spreadsheet.photon.js +1050 -0
  77. package/dist/photons/spreadsheet.photon.js.map +1 -0
  78. package/dist/photons/spreadsheet.photon.ts +1239 -0
  79. package/dist/server.d.ts.map +1 -1
  80. package/dist/server.js +17 -57
  81. package/dist/server.js.map +1 -1
  82. package/dist/shared/error-handler.d.ts +8 -0
  83. package/dist/shared/error-handler.d.ts.map +1 -1
  84. package/dist/shared/error-handler.js +50 -0
  85. package/dist/shared/error-handler.js.map +1 -1
  86. package/dist/shared-utils.d.ts +3 -2
  87. package/dist/shared-utils.d.ts.map +1 -1
  88. package/dist/shared-utils.js +4 -3
  89. package/dist/shared-utils.js.map +1 -1
  90. package/package.json +7 -2
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Docs — Markdown Document Editor with PDF Export
3
+ *
4
+ * A document editor backed by plain markdown files with YAML frontmatter.
5
+ * Each instance is a document: `_use('quarterly-report')` → `quarterly-report.md`.
6
+ * Pass a full path to open any file: `_use('/path/to/doc.md')`.
7
+ *
8
+ * Features page-aware preview via Paged.js, TOC generation, footnotes,
9
+ * custom containers (note/warning/tip), multi-column layouts, and PDF export.
10
+ *
11
+ * @version 1.0.0
12
+ * @runtime ^1.14.0
13
+ * @tags document, markdown, pdf, writing, authoring
14
+ * @icon 📄
15
+ * @stateful
16
+ * @ui editor ./ui/docs.html
17
+ */
18
+ import * as fs from 'fs/promises';
19
+ import { existsSync, mkdirSync } from 'fs';
20
+ import * as path from 'path';
21
+ import * as os from 'os';
22
+
23
+ const DEFAULT_DOC = `---
24
+ title: Untitled Document
25
+ author: ""
26
+ date: ${new Date().toISOString().split('T')[0]}
27
+ size: A4
28
+ margins:
29
+ top: 2.5cm
30
+ bottom: 2.5cm
31
+ left: 3cm
32
+ right: 2cm
33
+ header:
34
+ right: "{date}"
35
+ footer:
36
+ center: "Page {page} of {pages}"
37
+ toc: false
38
+ numbersections: false
39
+ theme: default
40
+ ---
41
+
42
+ # Untitled Document
43
+
44
+ Start writing here. This is a plain markdown document with superpowers.
45
+
46
+ ## Features
47
+
48
+ - **YAML frontmatter** controls page layout, headers, footers, and theme
49
+ - **Page breaks** with \`---pagebreak---\`
50
+ - **Footnotes** with \`[^1]\` syntax
51
+ - **Callout boxes** with \`::: note\`, \`::: warning\`, \`::: tip\`
52
+ - **Multi-column layouts** with \`::: columns\`
53
+ - **Table of contents** with \`[[toc]]\`
54
+ - **PDF export** that matches exactly what you see
55
+
56
+ ## Next Steps
57
+
58
+ Ask AI to help you write, restructure, or format this document.
59
+ `;
60
+
61
+ export default class Docs {
62
+ protected settings = {
63
+ /** @property Directory where document files are stored */
64
+ folder: path.join(os.homedir(), 'Documents', 'docs'),
65
+ };
66
+
67
+ declare memory: {
68
+ get<T>(key: string): Promise<T | null>;
69
+ set(key: string, value: unknown): Promise<void>;
70
+ };
71
+ declare emit: (payload: { event: string; data: unknown }) => void;
72
+ declare instanceName: string;
73
+
74
+ // ── File Resolution ─────────────────────────────────────────────────────
75
+
76
+ private get defaultFolder(): string {
77
+ return this.settings?.folder || path.join(os.homedir(), 'Documents', 'docs');
78
+ }
79
+
80
+ private get docPath(): string {
81
+ const name = this.instanceName || 'untitled';
82
+ if (path.isAbsolute(name)) return name.endsWith('.md') ? name : name + '.md';
83
+ if (name.includes('/') || name.includes('\\')) {
84
+ const resolved = path.resolve(name);
85
+ return resolved.endsWith('.md') ? resolved : resolved + '.md';
86
+ }
87
+ const dir = this.defaultFolder;
88
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
89
+ return path.join(dir, name.endsWith('.md') ? name : name + '.md');
90
+ }
91
+
92
+ async onInitialize() {
93
+ const dir = this.defaultFolder;
94
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
95
+ if (!existsSync(this.docPath)) {
96
+ await fs.writeFile(this.docPath, DEFAULT_DOC, 'utf8');
97
+ }
98
+ }
99
+
100
+ // ── Editor ──────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Open the document editor UI
104
+ * @ui editor
105
+ * @autorun
106
+ */
107
+ async main() {
108
+ const markdown = await this.readDoc();
109
+ const { frontmatter, body } = parseFrontmatter(markdown);
110
+ const outline = buildOutline(body);
111
+ return {
112
+ file: path.basename(this.docPath),
113
+ markdown,
114
+ frontmatter,
115
+ outline,
116
+ stats: computeStats(body),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Read the document markdown
122
+ * @readOnly
123
+ */
124
+ async read() {
125
+ return { file: path.basename(this.docPath), markdown: await this.readDoc() };
126
+ }
127
+
128
+ /**
129
+ * Save the full document markdown
130
+ * @param markdown Full markdown content with YAML frontmatter
131
+ * @ui editor
132
+ */
133
+ async save({ markdown }: { markdown: string }) {
134
+ await fs.writeFile(this.docPath, markdown, 'utf8');
135
+ const { frontmatter, body } = parseFrontmatter(markdown);
136
+ const result = {
137
+ file: path.basename(this.docPath),
138
+ markdown,
139
+ frontmatter,
140
+ outline: buildOutline(body),
141
+ stats: computeStats(body),
142
+ };
143
+ this.emit({ event: 'docChanged', data: result });
144
+ return result;
145
+ }
146
+
147
+ // ── Structural Editing ──────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Get the document's heading structure for navigation
151
+ * @readOnly
152
+ */
153
+ async outline() {
154
+ const body = getBody(await this.readDoc());
155
+ return { outline: buildOutline(body) };
156
+ }
157
+
158
+ /**
159
+ * Edit a specific section by heading path
160
+ * @param section Heading text or path like "Chapter 3/Introduction"
161
+ * @param markdown New content for that section (everything under the heading until next same-level heading)
162
+ * @ui editor
163
+ */
164
+ async edit({ section, markdown: newContent }: { section: string; markdown: string }) {
165
+ const doc = await this.readDoc();
166
+ const { frontmatterRaw, body } = splitDoc(doc);
167
+ const updated = replaceSection(body, section, newContent);
168
+ if (updated === null) return { error: `Section "${section}" not found` };
169
+ const full = frontmatterRaw + updated;
170
+ await fs.writeFile(this.docPath, full, 'utf8');
171
+ const { frontmatter } = parseFrontmatter(full);
172
+ const result = {
173
+ file: path.basename(this.docPath),
174
+ markdown: full,
175
+ frontmatter,
176
+ outline: buildOutline(updated),
177
+ stats: computeStats(updated),
178
+ };
179
+ this.emit({ event: 'docChanged', data: result });
180
+ return result;
181
+ }
182
+
183
+ /**
184
+ * Append content at the end of the document or after a specific section
185
+ * @param markdown Content to append
186
+ * @param after Optional heading text — inserts after that section instead of end
187
+ * @ui editor
188
+ */
189
+ async append({ markdown: content, after }: { markdown: string; after?: string }) {
190
+ const doc = await this.readDoc();
191
+ const { frontmatterRaw, body } = splitDoc(doc);
192
+ let updated: string;
193
+ if (after) {
194
+ updated = insertAfterSection(body, after, content);
195
+ } else {
196
+ updated = body.trimEnd() + '\n\n' + content + '\n';
197
+ }
198
+ const full = frontmatterRaw + updated;
199
+ await fs.writeFile(this.docPath, full, 'utf8');
200
+ const { frontmatter } = parseFrontmatter(full);
201
+ const result = {
202
+ file: path.basename(this.docPath),
203
+ markdown: full,
204
+ frontmatter,
205
+ outline: buildOutline(updated),
206
+ stats: computeStats(updated),
207
+ };
208
+ this.emit({ event: 'docChanged', data: result });
209
+ return result;
210
+ }
211
+
212
+ // ── Search & Replace ────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Find text in the document with optional fuzzy matching
216
+ * @param query Search text
217
+ * @param fuzzy Enable fuzzy matching {@default false}
218
+ * @param scope Limit search to a section heading
219
+ * @readOnly
220
+ */
221
+ async find({ query, fuzzy, scope }: { query: string; fuzzy?: boolean; scope?: string }) {
222
+ const body = getBody(await this.readDoc());
223
+ const searchIn = scope ? extractSection(body, scope) || body : body;
224
+ const lines = searchIn.split('\n');
225
+ const queryLower = query.toLowerCase();
226
+
227
+ const matches: { line: number; text: string; context: string }[] = [];
228
+ for (let i = 0; i < lines.length; i++) {
229
+ const lineLower = lines[i].toLowerCase();
230
+ const found = fuzzy ? fuzzyMatch(lineLower, queryLower) : lineLower.includes(queryLower);
231
+ if (found) {
232
+ matches.push({
233
+ line: i + 1,
234
+ text: lines[i],
235
+ context: lines.slice(Math.max(0, i - 1), i + 2).join('\n'),
236
+ });
237
+ }
238
+ }
239
+ return { query, fuzzy: !!fuzzy, scope: scope || null, matches, total: matches.length };
240
+ }
241
+
242
+ /**
243
+ * Find and replace text in the document
244
+ * @param pattern Text to find (string or regex pattern)
245
+ * @param replacement Replacement text
246
+ * @param scope Limit to a section heading
247
+ * @param all Replace all occurrences {@default true}
248
+ * @ui editor
249
+ */
250
+ async replace({
251
+ pattern,
252
+ replacement,
253
+ scope,
254
+ all,
255
+ }: {
256
+ pattern: string;
257
+ replacement: string;
258
+ scope?: string;
259
+ all?: boolean;
260
+ }) {
261
+ const doc = await this.readDoc();
262
+ const { frontmatterRaw, body } = splitDoc(doc);
263
+
264
+ let target = scope ? extractSection(body, scope) || body : body;
265
+ const replaceAll = all !== false;
266
+
267
+ let count = 0;
268
+ if (replaceAll) {
269
+ const parts = target.split(pattern);
270
+ count = parts.length - 1;
271
+ target = parts.join(replacement);
272
+ } else {
273
+ if (target.includes(pattern)) {
274
+ target = target.replace(pattern, replacement);
275
+ count = 1;
276
+ }
277
+ }
278
+
279
+ const updated = scope ? body.replace(extractSection(body, scope) || '', target) : target;
280
+ const full = frontmatterRaw + updated;
281
+ await fs.writeFile(this.docPath, full, 'utf8');
282
+ const { frontmatter } = parseFrontmatter(full);
283
+ const result = {
284
+ file: path.basename(this.docPath),
285
+ markdown: full,
286
+ frontmatter,
287
+ outline: buildOutline(updated),
288
+ stats: computeStats(updated),
289
+ replacements: count,
290
+ };
291
+ this.emit({ event: 'docChanged', data: result });
292
+ return result;
293
+ }
294
+
295
+ // ── Document Management ─────────────────────────────────────────────────
296
+
297
+ /**
298
+ * List saved documents in the docs folder
299
+ * @readOnly
300
+ */
301
+ async list() {
302
+ const dir = this.defaultFolder;
303
+ if (!existsSync(dir)) return { folder: dir, docs: [] };
304
+ const entries = await fs.readdir(dir, { withFileTypes: true });
305
+ const docs = await Promise.all(
306
+ entries
307
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
308
+ .map(async (e) => {
309
+ const stat = await fs.stat(path.join(dir, e.name));
310
+ const md = await fs.readFile(path.join(dir, e.name), 'utf8');
311
+ const { frontmatter } = parseFrontmatter(md);
312
+ const body = getBody(md);
313
+ return {
314
+ file: e.name,
315
+ title: (frontmatter as any).title || firstHeading(body) || e.name.replace(/\.md$/i, ''),
316
+ author: (frontmatter as any).author || '',
317
+ updatedAt: stat.mtime.toISOString(),
318
+ wordCount: body.split(/\s+/).filter(Boolean).length,
319
+ };
320
+ })
321
+ );
322
+ return { folder: dir, docs: docs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) };
323
+ }
324
+
325
+ /**
326
+ * Generate table of contents from the document structure
327
+ * @readOnly
328
+ */
329
+ async toc() {
330
+ const body = getBody(await this.readDoc());
331
+ const outline = buildOutline(body);
332
+ const tocMarkdown = outline.map((h) => `${' '.repeat(h.level - 1)}- ${h.text}`).join('\n');
333
+ return { outline, markdown: tocMarkdown };
334
+ }
335
+
336
+ /**
337
+ * Document statistics: word count, reading time, section breakdown
338
+ * @readOnly
339
+ */
340
+ async stats() {
341
+ const body = getBody(await this.readDoc());
342
+ return computeStats(body);
343
+ }
344
+
345
+ // ── Private ─────────────────────────────────────────────────────────────
346
+
347
+ private async readDoc(): Promise<string> {
348
+ try {
349
+ return await fs.readFile(this.docPath, 'utf8');
350
+ } catch {
351
+ return DEFAULT_DOC;
352
+ }
353
+ }
354
+ }
355
+
356
+ // ── Pure Helpers ──────────────────────────────────────────────────────────
357
+
358
+ function parseFrontmatter(markdown: string): { frontmatter: Record<string, any>; body: string } {
359
+ const match = markdown.match(/^---\n([\s\S]*?)\n---\n?/);
360
+ if (!match) return { frontmatter: {}, body: markdown };
361
+ const yamlText = match[1];
362
+ const body = markdown.slice(match[0].length);
363
+ // Simple YAML parser for flat/nested values
364
+ const frontmatter: Record<string, any> = {};
365
+ let currentKey = '';
366
+ let currentNested: Record<string, string> | null = null;
367
+ for (const line of yamlText.split('\n')) {
368
+ const nestedMatch = line.match(/^ (\w+):\s*(.+)/);
369
+ if (nestedMatch && currentKey) {
370
+ if (!currentNested) currentNested = {};
371
+ currentNested[nestedMatch[1]] = nestedMatch[2].replace(/^["']|["']$/g, '');
372
+ frontmatter[currentKey] = currentNested;
373
+ continue;
374
+ }
375
+ const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
376
+ if (kvMatch) {
377
+ if (currentNested) currentNested = null;
378
+ currentKey = kvMatch[1];
379
+ const val = kvMatch[2].replace(/^["']|["']$/g, '').trim();
380
+ if (val === 'true') frontmatter[currentKey] = true;
381
+ else if (val === 'false') frontmatter[currentKey] = false;
382
+ else if (val === '' || val === '""') frontmatter[currentKey] = '';
383
+ else if (/^\d+$/.test(val)) frontmatter[currentKey] = parseInt(val, 10);
384
+ else frontmatter[currentKey] = val;
385
+ }
386
+ }
387
+ return { frontmatter, body };
388
+ }
389
+
390
+ function splitDoc(markdown: string): { frontmatterRaw: string; body: string } {
391
+ const match = markdown.match(/^(---\n[\s\S]*?\n---\n?)/);
392
+ if (!match) return { frontmatterRaw: '', body: markdown };
393
+ return { frontmatterRaw: match[1], body: markdown.slice(match[1].length) };
394
+ }
395
+
396
+ function getBody(markdown: string): string {
397
+ return splitDoc(markdown).body;
398
+ }
399
+
400
+ interface HeadingEntry {
401
+ level: number;
402
+ text: string;
403
+ line: number;
404
+ }
405
+
406
+ function buildOutline(body: string): HeadingEntry[] {
407
+ const headings: HeadingEntry[] = [];
408
+ const lines = body.split('\n');
409
+ for (let i = 0; i < lines.length; i++) {
410
+ const match = lines[i].match(/^(#{1,6})\s+(.+)/);
411
+ if (match) {
412
+ headings.push({ level: match[1].length, text: match[2].trim(), line: i + 1 });
413
+ }
414
+ }
415
+ return headings;
416
+ }
417
+
418
+ function computeStats(body: string) {
419
+ const words = body.split(/\s+/).filter(Boolean).length;
420
+ const chars = body.length;
421
+ const paragraphs = body.split(/\n\n+/).filter((p) => p.trim().length > 0).length;
422
+ const headings = buildOutline(body);
423
+ const readingTime = Math.max(1, Math.ceil(words / 200));
424
+
425
+ // Per-section word counts
426
+ const sections = headings.map((h, i) => {
427
+ const start = body.indexOf(body.split('\n')[h.line - 1]);
428
+ const nextHeading = headings[i + 1];
429
+ const end = nextHeading ? body.indexOf(body.split('\n')[nextHeading.line - 1]) : body.length;
430
+ const sectionText = body.slice(start, end);
431
+ return {
432
+ heading: h.text,
433
+ level: h.level,
434
+ words: sectionText.split(/\s+/).filter(Boolean).length,
435
+ };
436
+ });
437
+
438
+ return { words, chars, paragraphs, headings: headings.length, readingTime, sections };
439
+ }
440
+
441
+ function firstHeading(body: string): string {
442
+ return body.match(/^#\s+(.+)$/m)?.[1]?.trim() || '';
443
+ }
444
+
445
+ function extractSection(body: string, heading: string): string | null {
446
+ const lines = body.split('\n');
447
+ let startLine = -1;
448
+ let headingLevel = 0;
449
+
450
+ for (let i = 0; i < lines.length; i++) {
451
+ const match = lines[i].match(/^(#{1,6})\s+(.+)/);
452
+ if (match && match[2].trim().toLowerCase() === heading.toLowerCase()) {
453
+ startLine = i;
454
+ headingLevel = match[1].length;
455
+ break;
456
+ }
457
+ }
458
+
459
+ if (startLine === -1) return null;
460
+
461
+ let endLine = lines.length;
462
+ for (let i = startLine + 1; i < lines.length; i++) {
463
+ const match = lines[i].match(/^(#{1,6})\s+/);
464
+ if (match && match[1].length <= headingLevel) {
465
+ endLine = i;
466
+ break;
467
+ }
468
+ }
469
+
470
+ return lines.slice(startLine, endLine).join('\n');
471
+ }
472
+
473
+ function replaceSection(body: string, heading: string, newContent: string): string | null {
474
+ const lines = body.split('\n');
475
+ let startLine = -1;
476
+ let headingLevel = 0;
477
+
478
+ for (let i = 0; i < lines.length; i++) {
479
+ const match = lines[i].match(/^(#{1,6})\s+(.+)/);
480
+ if (match && match[2].trim().toLowerCase() === heading.toLowerCase()) {
481
+ startLine = i;
482
+ headingLevel = match[1].length;
483
+ break;
484
+ }
485
+ }
486
+
487
+ if (startLine === -1) return null;
488
+
489
+ let endLine = lines.length;
490
+ for (let i = startLine + 1; i < lines.length; i++) {
491
+ const match = lines[i].match(/^(#{1,6})\s+/);
492
+ if (match && match[1].length <= headingLevel) {
493
+ endLine = i;
494
+ break;
495
+ }
496
+ }
497
+
498
+ const before = lines.slice(0, startLine);
499
+ const after = lines.slice(endLine);
500
+ return [...before, newContent, ...after].join('\n');
501
+ }
502
+
503
+ function insertAfterSection(body: string, heading: string, content: string): string {
504
+ const lines = body.split('\n');
505
+ let headingLevel = 0;
506
+ let insertAt = lines.length;
507
+
508
+ for (let i = 0; i < lines.length; i++) {
509
+ const match = lines[i].match(/^(#{1,6})\s+(.+)/);
510
+ if (match && match[2].trim().toLowerCase() === heading.toLowerCase()) {
511
+ headingLevel = match[1].length;
512
+ // Find end of this section
513
+ for (let j = i + 1; j < lines.length; j++) {
514
+ const nextMatch = lines[j].match(/^(#{1,6})\s+/);
515
+ if (nextMatch && nextMatch[1].length <= headingLevel) {
516
+ insertAt = j;
517
+ break;
518
+ }
519
+ }
520
+ if (insertAt === lines.length) insertAt = lines.length;
521
+ break;
522
+ }
523
+ }
524
+
525
+ const before = lines.slice(0, insertAt);
526
+ const after = lines.slice(insertAt);
527
+ return [...before, '', content, '', ...after].join('\n');
528
+ }
529
+
530
+ function fuzzyMatch(text: string, query: string): boolean {
531
+ let qi = 0;
532
+ for (let i = 0; i < text.length && qi < query.length; i++) {
533
+ if (text[i] === query[qi]) qi++;
534
+ }
535
+ return qi === query.length;
536
+ }