@refrakt-md/editor 0.8.3 → 0.8.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.
Files changed (31) hide show
  1. package/app/dist/assets/{index-DwfxgjnU.js → index-3XGwrbJi.js} +1 -1
  2. package/app/dist/assets/{index-CUmEjEeR.js → index-4QIVBXA2.js} +1 -1
  3. package/app/dist/assets/{index-CeV-Af4N.js → index-64pKPA7X.js} +1 -1
  4. package/app/dist/assets/{index-BGy7ixjW.js → index-87jnOwXd.js} +1 -1
  5. package/app/dist/assets/{index-COFbngzR.js → index-BC2x3b2t.js} +1 -1
  6. package/app/dist/assets/{index-3MvwKRVQ.js → index-Bb8l840O.js} +1 -1
  7. package/app/dist/assets/{index-CQDCT-XT.js → index-BcejoAP1.js} +1 -1
  8. package/app/dist/assets/{index-ChbH55h5.js → index-BfRxJzFS.js} +1 -1
  9. package/app/dist/assets/{index-BEGy_i8o.js → index-Bx44CkAs.js} +1 -1
  10. package/app/dist/assets/{index-DezxtfNV.js → index-CARyuClI.js} +1 -1
  11. package/app/dist/assets/{index-BBljOYQu.js → index-CEUDrca1.js} +1 -1
  12. package/app/dist/assets/{index-CKfKYVw7.js → index-CNd1XgyZ.js} +1 -1
  13. package/app/dist/assets/{index-DrI4IfXE.js → index-CO6-R3qL.js} +1 -1
  14. package/app/dist/assets/{index-B7e694w6.js → index-Chzl2Gom.js} +1 -1
  15. package/app/dist/assets/{index-BjlNcvOf.js → index-Cj0n5HXq.js} +1 -1
  16. package/app/dist/assets/{index-ogrpJNou.js → index-DdNJVnBL.js} +4 -4
  17. package/app/dist/assets/{index-BaLgiiKk.js → index-DiK4QBVK.js} +1 -1
  18. package/app/dist/assets/{index-D9-aYc3I.js → index-SvR5c3Vk.js} +1 -1
  19. package/app/dist/assets/{index-CPEo_rvd.js → index-YNSVyj5l.js} +1 -1
  20. package/app/dist/index.html +1 -1
  21. package/app/src/lib/editor/attribute-completion.ts +159 -0
  22. package/app/src/lib/editor/codemirror-theme.ts +115 -0
  23. package/app/src/lib/editor/content-model-resolver.ts +196 -0
  24. package/app/src/lib/editor/inline-markdown.ts +237 -0
  25. package/app/src/lib/editor/markdoc-highlight.ts +74 -0
  26. package/app/src/lib/editor/rune-palette.ts +77 -0
  27. package/app/src/lib/editor/section-mapper.ts +476 -0
  28. package/app/src/lib/state/editor.svelte.ts +151 -0
  29. package/app/src/lib/utils/frontmatter.ts +43 -0
  30. package/app/src/lib/utils/layout-parser.ts +197 -0
  31. package/package.json +10 -8
@@ -0,0 +1,74 @@
1
+ import {
2
+ ViewPlugin,
3
+ Decoration,
4
+ type DecorationSet,
5
+ type EditorView,
6
+ type ViewUpdate,
7
+ } from '@codemirror/view';
8
+ import { RangeSetBuilder } from '@codemirror/state';
9
+ import type { Extension } from '@codemirror/state';
10
+
11
+ const tagDeco = Decoration.mark({ class: 'cm-markdoc-tag' });
12
+ const bracketDeco = Decoration.mark({ class: 'cm-markdoc-bracket' });
13
+ const nameDeco = Decoration.mark({ class: 'cm-markdoc-name' });
14
+
15
+ // Matches: {% tagname ... %}, {% /tagname %}, {% tagname ... /%}
16
+ const TAG_RE = /\{%\s*(\/?)(\w[\w-]*)((?:\s+[^%]*)?)(\/?)\s*%\}/g;
17
+
18
+ function buildDecorations(view: EditorView): DecorationSet {
19
+ const ranges: { from: number; to: number; deco: Decoration }[] = [];
20
+
21
+ for (const { from, to } of view.visibleRanges) {
22
+ const text = view.state.doc.sliceString(from, to);
23
+ TAG_RE.lastIndex = 0;
24
+
25
+ let match: RegExpExecArray | null;
26
+ while ((match = TAG_RE.exec(text)) !== null) {
27
+ const start = from + match.index;
28
+ const end = start + match[0].length;
29
+
30
+ ranges.push({ from: start, to: end, deco: tagDeco });
31
+ ranges.push({ from: start, to: start + 2, deco: bracketDeco });
32
+ ranges.push({ from: end - 2, to: end, deco: bracketDeco });
33
+
34
+ const slash = match[1];
35
+ const name = match[2];
36
+ const nameOffset = match[0].indexOf(slash + name, 2);
37
+ const nameStart = start + nameOffset + slash.length;
38
+ ranges.push({ from: nameStart, to: nameStart + name.length, deco: nameDeco });
39
+ }
40
+ }
41
+
42
+ ranges.sort((a, b) => a.from - b.from || a.to - b.to);
43
+
44
+ const builder = new RangeSetBuilder<Decoration>();
45
+ for (const r of ranges) {
46
+ builder.add(r.from, r.to, r.deco);
47
+ }
48
+ return builder.finish();
49
+ }
50
+
51
+ const markdocPlugin = ViewPlugin.fromClass(
52
+ class {
53
+ decorations: DecorationSet;
54
+
55
+ constructor(view: EditorView) {
56
+ this.decorations = buildDecorations(view);
57
+ }
58
+
59
+ update(update: ViewUpdate) {
60
+ if (update.docChanged || update.viewportChanged) {
61
+ this.decorations = buildDecorations(update.view);
62
+ }
63
+ }
64
+ },
65
+ { decorations: (v) => v.decorations },
66
+ );
67
+
68
+ /**
69
+ * Creates a CodeMirror extension that highlights Markdoc tag syntax.
70
+ * Adds visual decoration to `{% tagname %}` and `{% /tagname %}` blocks.
71
+ */
72
+ export function markdocHighlight(): Extension {
73
+ return markdocPlugin;
74
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ type CompletionContext,
3
+ type CompletionResult,
4
+ type Completion,
5
+ type CompletionSource,
6
+ } from '@codemirror/autocomplete';
7
+ import type { RuneInfo } from '../api/client.js';
8
+
9
+ /**
10
+ * Build a snippet string for inserting a rune.
11
+ * Self-closing runes: `{% name /%}`
12
+ * Block runes: `{% name %}\n\n{% /name %}`
13
+ */
14
+ function buildSnippet(rune: RuneInfo): string {
15
+ // Build required attribute defaults
16
+ const attrParts: string[] = [];
17
+ for (const [name, attr] of Object.entries(rune.attributes)) {
18
+ if (attr.required) {
19
+ const defaultVal = attr.values?.[0] ?? '';
20
+ attrParts.push(`${name}="${defaultVal}"`);
21
+ }
22
+ }
23
+ const attrStr = attrParts.length > 0 ? ' ' + attrParts.join(' ') : '';
24
+
25
+ if (rune.selfClosing) {
26
+ return `{% ${rune.name}${attrStr} /%}`;
27
+ }
28
+ return `{% ${rune.name}${attrStr} %}\n\n{% /${rune.name} %}`;
29
+ }
30
+
31
+ /**
32
+ * Creates a CompletionSource for the rune palette triggered by `/`.
33
+ * Accepts a getter function so runes are read at query time (not creation time).
34
+ */
35
+ export function runeCompletionSource(getRunes: () => RuneInfo[]): CompletionSource {
36
+ return (context: CompletionContext): CompletionResult | null => {
37
+ const word = context.matchBefore(/\/\w*/);
38
+ if (!word) return null;
39
+
40
+ // Only trigger at start of line or after whitespace
41
+ if (word.from > 0) {
42
+ const charBefore = context.state.doc.sliceString(word.from - 1, word.from);
43
+ if (charBefore.trim() !== '') return null;
44
+ }
45
+
46
+ const runes = getRunes();
47
+ if (runes.length === 0) return null;
48
+
49
+ const slashFrom = word.from; // position of the `/` trigger character
50
+
51
+ const completions: Completion[] = runes.map((rune) => ({
52
+ label: rune.name,
53
+ detail: rune.category,
54
+ info: rune.description,
55
+ section: rune.category,
56
+ type: 'keyword',
57
+ apply: (view, _completion, from, to) => {
58
+ const snippet = buildSnippet(rune);
59
+ // Replace from the `/` to remove the trigger character
60
+ view.dispatch({
61
+ changes: { from: slashFrom, to, insert: snippet },
62
+ selection: {
63
+ anchor: rune.selfClosing
64
+ ? slashFrom + snippet.indexOf('/%}')
65
+ : slashFrom + snippet.indexOf('%}') + 2 + 1,
66
+ },
67
+ });
68
+ },
69
+ }));
70
+
71
+ return {
72
+ from: slashFrom + 1, // filter starts after `/` so "hint" matches typed "h"
73
+ options: completions,
74
+ filter: true,
75
+ };
76
+ };
77
+ }
@@ -0,0 +1,476 @@
1
+ import { parseContentTree, type ContentNode } from './block-parser.js';
2
+ import { stripInlineMarkdown } from './inline-markdown.js';
3
+
4
+ export interface SectionMapping {
5
+ /** The data-name value from the rendered HTML */
6
+ dataName: string;
7
+ /** Plain text content (without markdown syntax) — used for matching */
8
+ text: string;
9
+ /** Original markdown source line(s) */
10
+ source: string;
11
+ /** Prefix to preserve when editing (e.g., "## " for headings, "> " for blockquotes) */
12
+ sourcePrefix: string;
13
+ /** Inline markdown content after prefix stripping, preserving formatting (links, bold, etc.) */
14
+ inlineSource: string;
15
+ }
16
+
17
+ /**
18
+ * Check whether a Shadow DOM element is an editable leaf section.
19
+ * Returns true for elements with only inline content (text, em, strong, a, code, etc.)
20
+ * and false for wrapper elements containing block-level children.
21
+ */
22
+ export function isEditableSection(el: HTMLElement): boolean {
23
+ const blockTags = new Set([
24
+ 'DIV', 'SECTION', 'ARTICLE', 'HEADER', 'FOOTER', 'NAV',
25
+ 'UL', 'OL', 'LI', 'TABLE', 'BLOCKQUOTE', 'PRE', 'FIGURE',
26
+ 'DETAILS', 'SUMMARY', 'ASIDE', 'MAIN', 'FORM',
27
+ ]);
28
+ for (const child of el.children) {
29
+ if (blockTags.has(child.tagName)) return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ /**
35
+ * Extract the plain text from a markdown source line, stripping syntax prefixes.
36
+ * Returns { text, prefix } where prefix is the markdown syntax to preserve.
37
+ */
38
+ function stripMarkdownPrefix(source: string): { text: string; prefix: string } {
39
+ const trimmed = source.trim();
40
+
41
+ // Heading: ## text
42
+ const headingMatch = trimmed.match(/^(#{1,6}\s+)(.*)/);
43
+ if (headingMatch) return { text: headingMatch[2], prefix: headingMatch[1] };
44
+
45
+ // Blockquote: > text
46
+ const quoteMatch = trimmed.match(/^(>\s*)(.*)/);
47
+ if (quoteMatch) return { text: quoteMatch[2], prefix: quoteMatch[1] };
48
+
49
+ // Plain paragraph
50
+ return { text: trimmed, prefix: '' };
51
+ }
52
+
53
+ /**
54
+ * Normalize text for comparison: collapse whitespace, trim, lowercase.
55
+ */
56
+ function normalizeText(s: string): string {
57
+ return s.replace(/\s+/g, ' ').trim().toLowerCase();
58
+ }
59
+
60
+ /**
61
+ * Find the section mapping for a given data-name and its rendered text content
62
+ * within a rune's inner content.
63
+ *
64
+ * Uses text-matching: strips markdown prefixes from each content node's source
65
+ * and compares with the rendered text to find the matching node.
66
+ */
67
+ export function findSectionMapping(
68
+ innerContent: string,
69
+ dataName: string,
70
+ renderedText: string,
71
+ ): SectionMapping | null {
72
+ const nodes = parseContentTree(innerContent);
73
+ const normalizedRendered = normalizeText(renderedText);
74
+
75
+ // Try to match each node's text against the rendered text
76
+ for (const node of nodes) {
77
+ const { text, prefix } = stripMarkdownPrefix(node.source);
78
+ const plainText = stripInlineMarkdown(text);
79
+ if (normalizeText(plainText) === normalizedRendered) {
80
+ return {
81
+ dataName,
82
+ text: plainText,
83
+ source: node.source,
84
+ sourcePrefix: prefix,
85
+ inlineSource: text,
86
+ };
87
+ }
88
+
89
+ // For multi-line paragraphs, check if the joined text matches
90
+ if (node.type === 'paragraph') {
91
+ const joined = node.source.split('\n').map(l => l.trim()).join(' ');
92
+ const joinedPlain = stripInlineMarkdown(joined);
93
+ if (normalizeText(joinedPlain) === normalizedRendered) {
94
+ return {
95
+ dataName,
96
+ text: joinedPlain,
97
+ source: node.source,
98
+ sourcePrefix: '',
99
+ inlineSource: joined,
100
+ };
101
+ }
102
+ }
103
+ }
104
+
105
+ // For list nodes, try matching within individual list items
106
+ for (const node of nodes) {
107
+ if (node.type !== 'list') continue;
108
+ const result = findListItemMapping(node.source, dataName, normalizedRendered);
109
+ if (result) return result;
110
+ }
111
+
112
+ // Fallback: try matching against child nodes of rune nodes
113
+ for (const node of nodes) {
114
+ if (node.children) {
115
+ const result = findSectionMapping(
116
+ node.innerContent ?? '',
117
+ dataName,
118
+ renderedText,
119
+ );
120
+ if (result) return result;
121
+ }
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * Split a list source into individual item sources.
129
+ * Each item starts with a list marker (-, *, +, or 1.) and may have
130
+ * indented continuation lines.
131
+ */
132
+ function splitListItems(listSource: string): string[] {
133
+ const lines = listSource.split('\n');
134
+ const items: string[] = [];
135
+ let current: string[] = [];
136
+
137
+ for (const line of lines) {
138
+ if (/^[-*+]\s|^\d+\.\s/.test(line) && current.length > 0) {
139
+ items.push(current.join('\n'));
140
+ current = [line];
141
+ } else {
142
+ current.push(line);
143
+ }
144
+ }
145
+ if (current.length > 0) items.push(current.join('\n'));
146
+ return items;
147
+ }
148
+
149
+ /**
150
+ * Try to find a section mapping within a list item's content.
151
+ * Handles bold text on the first line (definition title) and
152
+ * continuation lines (definition description).
153
+ */
154
+ function findListItemMapping(
155
+ listSource: string,
156
+ dataName: string,
157
+ normalizedRendered: string,
158
+ ): SectionMapping | null {
159
+ const items = splitListItems(listSource);
160
+
161
+ for (const itemSrc of items) {
162
+ const lines = itemSrc.split('\n');
163
+ const firstLine = lines[0];
164
+
165
+ // Match simple list item: "- text" or "1. text"
166
+ const simpleMatch = firstLine.match(/^([-*+]\s+|(\d+)\.\s+)(.*)/);
167
+ if (simpleMatch) {
168
+ const [, prefix, , itemText] = simpleMatch;
169
+ const plainText = stripInlineMarkdown(itemText);
170
+ if (normalizeText(plainText) === normalizedRendered) {
171
+ return {
172
+ dataName,
173
+ text: plainText,
174
+ source: firstLine,
175
+ sourcePrefix: prefix,
176
+ inlineSource: itemText,
177
+ };
178
+ }
179
+ }
180
+
181
+ // Match bold text on first line: "- **Title**", "- **Title** extra",
182
+ // or with content before bold: "- {% icon ... /%} **Title**"
183
+ const boldMatch = firstLine.match(/^([-*+]\s+.*?)\*\*(.+?)\*\*(.*)/);
184
+ if (boldMatch) {
185
+ const [, prefix, boldText] = boldMatch;
186
+ if (normalizeText(boldText) === normalizedRendered) {
187
+ return {
188
+ dataName,
189
+ text: boldText,
190
+ source: firstLine,
191
+ sourcePrefix: prefix,
192
+ inlineSource: `**${boldText}**${boldMatch[3]}`,
193
+ };
194
+ }
195
+ }
196
+
197
+ // Match continuation lines (description text)
198
+ if (lines.length > 1) {
199
+ const contLines = lines.slice(1)
200
+ .map(l => l.replace(/^\s+/, ''))
201
+ .filter(Boolean);
202
+ const contText = contLines.join(' ');
203
+ const contPlain = stripInlineMarkdown(contText);
204
+ if (contLines.length > 0 && normalizeText(contPlain) === normalizedRendered) {
205
+ const contSource = lines.slice(1).join('\n');
206
+ // Find indentation from first non-empty continuation line
207
+ // (lines[1] may be a blank separator line)
208
+ const firstContentLine = lines.slice(1).find(l => l.trim().length > 0);
209
+ const indentMatch = firstContentLine?.match(/^(\s+)/);
210
+ const indent = indentMatch ? indentMatch[1] : '';
211
+ // Include leading blank lines in the prefix so the replacement
212
+ // preserves the blank-line separator between title and description
213
+ const firstContentIdx = firstContentLine ? contSource.indexOf(firstContentLine) : 0;
214
+ const prefix = contSource.slice(0, firstContentIdx) + indent;
215
+ return {
216
+ dataName,
217
+ text: contPlain,
218
+ source: contSource,
219
+ sourcePrefix: prefix,
220
+ inlineSource: contText,
221
+ };
222
+ }
223
+ }
224
+ }
225
+
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Apply an inline edit to a section within the rune's inner content.
231
+ * Finds the original source and replaces it with the new inline markdown,
232
+ * preserving markdown syntax prefixes (e.g., "## " for headings).
233
+ */
234
+ export function applySectionEdit(
235
+ innerContent: string,
236
+ mapping: SectionMapping,
237
+ newInlineSource: string,
238
+ ): string {
239
+ const newSource = mapping.sourcePrefix + newInlineSource;
240
+ const idx = innerContent.indexOf(mapping.source);
241
+ if (idx === -1) return innerContent;
242
+ return innerContent.slice(0, idx) + newSource + innerContent.slice(idx + mapping.source.length);
243
+ }
244
+
245
+ // ── Action item mapping ──────────────────────────────────────────────
246
+
247
+ export interface ActionMapping {
248
+ /** The full source line (e.g., "- [Get started](/docs/getting-started)") */
249
+ source: string;
250
+ /** The list item prefix (e.g., "- ") */
251
+ listPrefix: string;
252
+ /** Link display text */
253
+ text: string;
254
+ /** Link URL */
255
+ href: string;
256
+ }
257
+
258
+ /**
259
+ * Find a markdown link within a list item in the rune's inner content,
260
+ * matching by rendered text and href.
261
+ */
262
+ export function findActionMapping(
263
+ innerContent: string,
264
+ renderedText: string,
265
+ href: string,
266
+ ): ActionMapping | null {
267
+ const normalizedRendered = normalizeText(renderedText);
268
+ const lines = innerContent.split('\n');
269
+
270
+ for (const line of lines) {
271
+ const match = line.match(/^(\s*[-*+]\s+)\[([^\]]*)\]\(([^)]*)\)\s*$/);
272
+ if (!match) continue;
273
+
274
+ const [, listPrefix, linkText, linkUrl] = match;
275
+ if (
276
+ normalizeText(linkText) === normalizedRendered ||
277
+ linkUrl === href
278
+ ) {
279
+ return { source: line, listPrefix, text: linkText, href: linkUrl };
280
+ }
281
+ }
282
+
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * Apply an action edit: replace the markdown link in the inner content.
288
+ */
289
+ export function applyActionEdit(
290
+ innerContent: string,
291
+ mapping: ActionMapping,
292
+ newText: string,
293
+ newHref: string,
294
+ ): string {
295
+ const newSource = `${mapping.listPrefix}[${newText}](${newHref})`;
296
+ const idx = innerContent.indexOf(mapping.source);
297
+ if (idx === -1) return innerContent;
298
+ return innerContent.slice(0, idx) + newSource + innerContent.slice(idx + mapping.source.length);
299
+ }
300
+
301
+ // ── Command (fenced code block) mapping ──────────────────────────────
302
+
303
+ export interface CommandMapping {
304
+ /** Full fenced code block source (including backtick delimiters) */
305
+ source: string;
306
+ /** Just the code content between the fences */
307
+ code: string;
308
+ /** Language tag (e.g., 'bash', 'sh') */
309
+ language: string;
310
+ /** Full opening line before the newline (e.g., "```js" or "````markdoc") */
311
+ opener: string;
312
+ /** Backtick delimiter sequence (e.g., "```" or "````") */
313
+ delimiter: string;
314
+ }
315
+
316
+ /**
317
+ * Find a fenced code block in the rune's inner content,
318
+ * matching by comparing the code content to the rendered text.
319
+ *
320
+ * Handles 3+ backtick delimiters and info strings after the language tag
321
+ * (e.g., `` ```yaml title="config.ts" `` or `` ````markdoc ``).
322
+ */
323
+ export function findCommandMapping(
324
+ innerContent: string,
325
+ renderedText: string,
326
+ ): CommandMapping | null {
327
+ const normalizedRendered = normalizeText(renderedText);
328
+ // Match 3+ backticks, optional language, optional info string, then
329
+ // use a backreference (\2) to match the same-length closing delimiter.
330
+ const fenceRegex = /^((`{3,})(\w*)[^\n]*\n)([\s\S]*?)\n\2\s*$/gm;
331
+ let match;
332
+
333
+ while ((match = fenceRegex.exec(innerContent)) !== null) {
334
+ const [fullMatch, opener, delimiter, language, code] = match;
335
+ if (normalizeText(code) === normalizedRendered) {
336
+ return {
337
+ source: fullMatch,
338
+ code,
339
+ language: language || '',
340
+ opener: opener.trimEnd(),
341
+ delimiter,
342
+ };
343
+ }
344
+ }
345
+
346
+ return null;
347
+ }
348
+
349
+ /**
350
+ * Apply a command edit: replace the code content within the fenced code block.
351
+ * Preserves the original opening line (including delimiter length and info string)
352
+ * and uses the same backtick delimiter for the closing fence.
353
+ */
354
+ export function applyCommandEdit(
355
+ innerContent: string,
356
+ mapping: CommandMapping,
357
+ newCode: string,
358
+ ): string {
359
+ const newSource = mapping.opener + '\n' + newCode + '\n' + mapping.delimiter;
360
+ const idx = innerContent.indexOf(mapping.source);
361
+ if (idx === -1) return innerContent;
362
+ return innerContent.slice(0, idx) + newSource + innerContent.slice(idx + mapping.source.length);
363
+ }
364
+
365
+ /**
366
+ * Apply a language change to a fenced code block.
367
+ * Reconstructs the opener line with the new language, preserving
368
+ * the backtick delimiter length and any info string after the language tag.
369
+ */
370
+ export function applyLanguageEdit(
371
+ innerContent: string,
372
+ mapping: CommandMapping,
373
+ newLanguage: string,
374
+ ): string {
375
+ const afterDelimiter = mapping.opener.slice(mapping.delimiter.length);
376
+ const infoString = afterDelimiter.replace(/^\w*/, '').trim();
377
+ const newOpener = mapping.delimiter + newLanguage + (infoString ? ' ' + infoString : '');
378
+ const newSource = newOpener + '\n' + mapping.code + '\n' + mapping.delimiter;
379
+ const idx = innerContent.indexOf(mapping.source);
380
+ if (idx === -1) return innerContent;
381
+ return innerContent.slice(0, idx) + newSource + innerContent.slice(idx + mapping.source.length);
382
+ }
383
+
384
+ // ── Image mapping ────────────────────────────────────────────────────
385
+
386
+ export interface ImageMapping {
387
+ /** Full source line (e.g., "![alt text](/path/to/image.png)") */
388
+ source: string;
389
+ /** Alt text */
390
+ alt: string;
391
+ /** Image path */
392
+ src: string;
393
+ }
394
+
395
+ /**
396
+ * Find a markdown image in the rune's inner content,
397
+ * matching by rendered src (from the <img> element's src attribute).
398
+ */
399
+ export function findImageMapping(
400
+ innerContent: string,
401
+ renderedSrc: string,
402
+ ): ImageMapping | null {
403
+ const lines = innerContent.split('\n');
404
+ for (const line of lines) {
405
+ const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]*)\)\s*$/);
406
+ if (!match) continue;
407
+ const [, alt, src] = match;
408
+ if (src === renderedSrc) {
409
+ return { source: line.trim(), alt, src };
410
+ }
411
+ }
412
+ return null;
413
+ }
414
+
415
+ /**
416
+ * Apply an image edit: replace the markdown image syntax in the inner content.
417
+ */
418
+ export function applyImageEdit(
419
+ innerContent: string,
420
+ mapping: ImageMapping,
421
+ newAlt: string,
422
+ newSrc: string,
423
+ ): string {
424
+ const newSource = `![${newAlt}](${newSrc})`;
425
+ const idx = innerContent.indexOf(mapping.source);
426
+ if (idx === -1) return innerContent;
427
+ return innerContent.slice(0, idx) + newSource + innerContent.slice(idx + mapping.source.length);
428
+ }
429
+
430
+ // ── Icon (self-closing tag) mapping ──────────────────────────────────
431
+
432
+ export interface IconMapping {
433
+ /** Full source text of the icon tag (e.g., '{% icon name="rocket" /%}') */
434
+ source: string;
435
+ /** Current icon name (e.g., "rocket" or "hint/warning") */
436
+ name: string;
437
+ }
438
+
439
+ /**
440
+ * Find a {% icon name="..." /%} tag in the rune's inner content,
441
+ * matching by the rendered icon name (from the data-icon attribute).
442
+ */
443
+ export function findIconMapping(
444
+ innerContent: string,
445
+ renderedIconName: string,
446
+ ): IconMapping | null {
447
+ const iconRegex = /\{%\s*icon\s+[^%]*name=["']([^"']+)["'][^%]*\/%\}/g;
448
+ let match;
449
+
450
+ while ((match = iconRegex.exec(innerContent)) !== null) {
451
+ const [fullMatch, name] = match;
452
+ if (name === renderedIconName) {
453
+ return { source: fullMatch, name };
454
+ }
455
+ }
456
+
457
+ return null;
458
+ }
459
+
460
+ /**
461
+ * Apply an icon edit: replace the icon name in the {% icon %} tag.
462
+ * Preserves all other attributes (size, etc.) and the tag structure.
463
+ */
464
+ export function applyIconEdit(
465
+ innerContent: string,
466
+ mapping: IconMapping,
467
+ newIconName: string,
468
+ ): string {
469
+ const newSource = mapping.source.replace(
470
+ /name=["'][^"']+["']/,
471
+ `name="${newIconName}"`,
472
+ );
473
+ const idx = innerContent.indexOf(mapping.source);
474
+ if (idx === -1) return innerContent;
475
+ return innerContent.slice(0, idx) + newSource + innerContent.slice(idx + mapping.source.length);
476
+ }