react-native-richify 1.0.3 → 1.0.5

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/lib/commonjs/components/RenderedOutput.js +168 -0
  2. package/lib/commonjs/components/RenderedOutput.js.map +1 -0
  3. package/lib/commonjs/components/RichTextInput.js +196 -52
  4. package/lib/commonjs/components/RichTextInput.js.map +1 -1
  5. package/lib/commonjs/components/Toolbar.js +41 -2
  6. package/lib/commonjs/components/Toolbar.js.map +1 -1
  7. package/lib/commonjs/constants/defaultStyles.js +81 -2
  8. package/lib/commonjs/constants/defaultStyles.js.map +1 -1
  9. package/lib/commonjs/hooks/useFormatting.js +46 -2
  10. package/lib/commonjs/hooks/useFormatting.js.map +1 -1
  11. package/lib/commonjs/hooks/useRichText.js +130 -12
  12. package/lib/commonjs/hooks/useRichText.js.map +1 -1
  13. package/lib/commonjs/index.d.js +19 -0
  14. package/lib/commonjs/index.d.js.map +1 -1
  15. package/lib/commonjs/index.js +19 -0
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/utils/formatter.js +48 -12
  18. package/lib/commonjs/utils/formatter.js.map +1 -1
  19. package/lib/commonjs/utils/parser.js +1 -1
  20. package/lib/commonjs/utils/parser.js.map +1 -1
  21. package/lib/commonjs/utils/serializer.d.js +6 -0
  22. package/lib/commonjs/utils/serializer.d.js.map +1 -0
  23. package/lib/commonjs/utils/serializer.js +259 -0
  24. package/lib/commonjs/utils/serializer.js.map +1 -0
  25. package/lib/commonjs/utils/styleMapper.js +11 -0
  26. package/lib/commonjs/utils/styleMapper.js.map +1 -1
  27. package/lib/module/components/RenderedOutput.js +163 -0
  28. package/lib/module/components/RenderedOutput.js.map +1 -0
  29. package/lib/module/components/RichTextInput.js +198 -55
  30. package/lib/module/components/RichTextInput.js.map +1 -1
  31. package/lib/module/components/Toolbar.js +41 -2
  32. package/lib/module/components/Toolbar.js.map +1 -1
  33. package/lib/module/constants/defaultStyles.js +81 -2
  34. package/lib/module/constants/defaultStyles.js.map +1 -1
  35. package/lib/module/hooks/useFormatting.js +47 -3
  36. package/lib/module/hooks/useFormatting.js.map +1 -1
  37. package/lib/module/hooks/useRichText.js +130 -12
  38. package/lib/module/hooks/useRichText.js.map +1 -1
  39. package/lib/module/index.d.js +1 -0
  40. package/lib/module/index.d.js.map +1 -1
  41. package/lib/module/index.js +1 -0
  42. package/lib/module/index.js.map +1 -1
  43. package/lib/module/utils/formatter.js +46 -12
  44. package/lib/module/utils/formatter.js.map +1 -1
  45. package/lib/module/utils/parser.js +1 -1
  46. package/lib/module/utils/parser.js.map +1 -1
  47. package/lib/module/utils/serializer.d.js +4 -0
  48. package/lib/module/utils/serializer.d.js.map +1 -0
  49. package/lib/module/utils/serializer.js +253 -0
  50. package/lib/module/utils/serializer.js.map +1 -0
  51. package/lib/module/utils/styleMapper.js +11 -0
  52. package/lib/module/utils/styleMapper.js.map +1 -1
  53. package/lib/typescript/src/components/RenderedOutput.d.ts +9 -0
  54. package/lib/typescript/src/components/RenderedOutput.d.ts.map +1 -0
  55. package/lib/typescript/src/components/RichTextInput.d.ts +2 -13
  56. package/lib/typescript/src/components/RichTextInput.d.ts.map +1 -1
  57. package/lib/typescript/src/components/Toolbar.d.ts.map +1 -1
  58. package/lib/typescript/src/constants/defaultStyles.d.ts +3 -0
  59. package/lib/typescript/src/constants/defaultStyles.d.ts.map +1 -1
  60. package/lib/typescript/src/hooks/useFormatting.d.ts +4 -1
  61. package/lib/typescript/src/hooks/useFormatting.d.ts.map +1 -1
  62. package/lib/typescript/src/hooks/useRichText.d.ts.map +1 -1
  63. package/lib/typescript/src/index.d.ts +2 -1
  64. package/lib/typescript/src/index.d.ts.map +1 -1
  65. package/lib/typescript/src/types/index.d.ts +112 -1
  66. package/lib/typescript/src/types/index.d.ts.map +1 -1
  67. package/lib/typescript/src/utils/formatter.d.ts +9 -1
  68. package/lib/typescript/src/utils/formatter.d.ts.map +1 -1
  69. package/lib/typescript/src/utils/parser.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/serializer.d.ts +14 -0
  71. package/lib/typescript/src/utils/serializer.d.ts.map +1 -0
  72. package/lib/typescript/src/utils/styleMapper.d.ts.map +1 -1
  73. package/package.json +1 -1
  74. package/src/components/RenderedOutput.tsx +231 -0
  75. package/src/components/RichTextInput.d.ts +3 -14
  76. package/src/components/RichTextInput.tsx +291 -56
  77. package/src/components/Toolbar.tsx +54 -2
  78. package/src/constants/defaultStyles.d.ts +3 -0
  79. package/src/constants/defaultStyles.ts +47 -1
  80. package/src/hooks/useFormatting.ts +89 -2
  81. package/src/hooks/useRichText.ts +193 -11
  82. package/src/index.d.ts +2 -1
  83. package/src/index.ts +10 -0
  84. package/src/types/index.d.ts +112 -1
  85. package/src/types/index.ts +123 -1
  86. package/src/utils/formatter.ts +60 -10
  87. package/src/utils/parser.ts +6 -1
  88. package/src/utils/serializer.d.ts +13 -0
  89. package/src/utils/serializer.ts +365 -0
  90. package/src/utils/styleMapper.ts +21 -0
@@ -22,6 +22,21 @@ export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'none';
22
22
  */
23
23
  export type ListType = 'bullet' | 'ordered' | 'none';
24
24
 
25
+ /**
26
+ * Paragraph alignment presets.
27
+ */
28
+ export type TextAlign = 'left' | 'center' | 'right';
29
+
30
+ /**
31
+ * Serialized output formats supported by the editor.
32
+ */
33
+ export type OutputFormat = 'markdown' | 'html';
34
+
35
+ /**
36
+ * Output preview modes supported by the editor.
37
+ */
38
+ export type OutputPreviewMode = 'literal' | 'rendered';
39
+
25
40
  // ─── Style Types ─────────────────────────────────────────────────────────────
26
41
 
27
42
  /**
@@ -37,6 +52,11 @@ export interface FormatStyle {
37
52
  backgroundColor?: string;
38
53
  fontSize?: number;
39
54
  heading?: HeadingLevel;
55
+ listType?: ListType;
56
+ textAlign?: TextAlign;
57
+ link?: string;
58
+ imageSrc?: string;
59
+ imageAlt?: string;
40
60
  }
41
61
 
42
62
  /**
@@ -91,6 +111,20 @@ export interface RichTextActions {
91
111
  ) => void;
92
112
  /** Apply a heading level to the current line. */
93
113
  setHeading: (level: HeadingLevel) => void;
114
+ /** Apply a list style to the current line. */
115
+ setListType: (type: ListType) => void;
116
+ /** Apply paragraph alignment to the current line. */
117
+ setTextAlign: (align: TextAlign) => void;
118
+ /** Apply or clear a hyperlink on the current selection. */
119
+ setLink: (url?: string) => void;
120
+ /** Insert an image placeholder into the document. */
121
+ insertImage: (
122
+ source: string,
123
+ options?: {
124
+ alt?: string;
125
+ placeholder?: string;
126
+ },
127
+ ) => void;
94
128
  /** Set the text color for the current selection. */
95
129
  setColor: (color: string) => void;
96
130
  /** Set the background color for the current selection. */
@@ -105,6 +139,8 @@ export interface RichTextActions {
105
139
  isFormatActive: (format: FormatType) => boolean;
106
140
  /** Get the effective shared style at the current cursor/selection. */
107
141
  getSelectionStyle: () => FormatStyle;
142
+ /** Serialize the current content as markdown or HTML. */
143
+ getOutput: (format?: OutputFormat) => string;
108
144
  /** Get the full plain text content. */
109
145
  getPlainText: () => string;
110
146
  /** Export the segments as a serializable JSON array. */
@@ -135,10 +171,18 @@ export interface RichTextTheme {
135
171
  containerStyle?: ViewStyle;
136
172
  /** Style for the TextInput. */
137
173
  inputStyle?: TextStyle;
138
- /** Style for the overlay text container. */
174
+ /** Style for the legacy overlay text container. */
139
175
  overlayContainerStyle?: ViewStyle;
140
176
  /** Base text style applied to all segments before formatting. */
141
177
  baseTextStyle?: TextStyle;
178
+ /** Style for the serialized output container. */
179
+ outputContainerStyle?: ViewStyle;
180
+ /** Label style for the serialized output header. */
181
+ outputLabelStyle?: TextStyle;
182
+ /** Style for the serialized output text. */
183
+ outputTextStyle?: TextStyle;
184
+ /** Style for the rendered output preview content. */
185
+ renderedOutputStyle?: ViewStyle;
142
186
  /** Style for the toolbar container. */
143
187
  toolbarStyle?: ViewStyle;
144
188
  /** Style for toolbar buttons. */
@@ -165,6 +209,8 @@ export interface RichTextTheme {
165
209
  toolbarBackground?: string;
166
210
  /** Toolbar border color. */
167
211
  toolbarBorder?: string;
212
+ /** Default link color. */
213
+ link?: string;
168
214
  /** Cursor / caret color. */
169
215
  cursor?: ColorValue;
170
216
  };
@@ -184,6 +230,16 @@ export interface ToolbarItem {
184
230
  format?: FormatType;
185
231
  /** The heading level this button sets. */
186
232
  heading?: HeadingLevel;
233
+ /** The list type this button sets. */
234
+ listType?: ListType;
235
+ /** The alignment this button sets. */
236
+ textAlign?: TextAlign;
237
+ /** The output format this button toggles to. */
238
+ outputFormat?: OutputFormat;
239
+ /** The output preview mode this button toggles to. */
240
+ outputPreviewMode?: OutputPreviewMode;
241
+ /** Special toolbar action. */
242
+ actionType?: 'link' | 'image';
187
243
  /** Custom action handler (overrides default behavior). */
188
244
  onPress?: () => void;
189
245
  /** Whether this item is currently active. */
@@ -208,6 +264,38 @@ export interface ToolbarRenderProps {
208
264
  items: ToolbarItem[];
209
265
  state: RichTextState;
210
266
  actions: RichTextActions;
267
+ outputFormat: OutputFormat;
268
+ outputPreviewMode: OutputPreviewMode;
269
+ onOutputFormatChange: (format: OutputFormat) => void;
270
+ onOutputPreviewModeChange: (mode: OutputPreviewMode) => void;
271
+ onRequestLink?: () => void;
272
+ onRequestImage?: () => void;
273
+ }
274
+
275
+ /**
276
+ * Payload passed when the built-in link button requests a URL.
277
+ */
278
+ export interface LinkRequestPayload {
279
+ /** Selected plain text at the time of the request. */
280
+ selectedText: string;
281
+ /** Existing URL on the selection, when present. */
282
+ currentUrl?: string;
283
+ /** Apply or clear the URL on the current selection. */
284
+ applyLink: (url?: string) => void;
285
+ }
286
+
287
+ /**
288
+ * Payload passed when the built-in image button requests an image source.
289
+ */
290
+ export interface ImageRequestPayload {
291
+ /** Insert an image placeholder into the document. */
292
+ insertImage: (
293
+ source: string,
294
+ options?: {
295
+ alt?: string;
296
+ placeholder?: string;
297
+ },
298
+ ) => void;
211
299
  }
212
300
 
213
301
  // ─── Component Props ─────────────────────────────────────────────────────────
@@ -254,6 +342,18 @@ export interface ToolbarProps {
254
342
  theme?: RichTextTheme;
255
343
  /** Whether to show the toolbar. */
256
344
  visible?: boolean;
345
+ /** Currently selected serialized output format. */
346
+ outputFormat?: OutputFormat;
347
+ /** Currently selected preview mode. */
348
+ outputPreviewMode?: OutputPreviewMode;
349
+ /** Called when the output format changes from the toolbar. */
350
+ onOutputFormatChange?: (format: OutputFormat) => void;
351
+ /** Called when the preview mode changes from the toolbar. */
352
+ onOutputPreviewModeChange?: (mode: OutputPreviewMode) => void;
353
+ /** Called when the link button is pressed. */
354
+ onRequestLink?: () => void;
355
+ /** Called when the image button is pressed. */
356
+ onRequestImage?: () => void;
257
357
  /** Custom render function for the entire toolbar. */
258
358
  renderToolbar?: (props: ToolbarRenderProps) => React.ReactElement | null;
259
359
  }
@@ -282,6 +382,28 @@ export interface RichTextInputProps {
282
382
  toolbarItems?: ToolbarItem[];
283
383
  /** Theme configuration. */
284
384
  theme?: RichTextTheme;
385
+ /** Whether to show the serialized output preview below the input. */
386
+ showOutputPreview?: boolean;
387
+ /** Controlled format used for the serialized output preview. */
388
+ outputFormat?: OutputFormat;
389
+ /** Initial format used for the serialized output preview. */
390
+ defaultOutputFormat?: OutputFormat;
391
+ /** Controlled preview mode for the output panel. */
392
+ outputPreviewMode?: OutputPreviewMode;
393
+ /** Initial preview mode for the output panel. */
394
+ defaultOutputPreviewMode?: OutputPreviewMode;
395
+ /** Maximum height for the output preview panel. */
396
+ maxOutputHeight?: number;
397
+ /** Callback when the serialized output changes. */
398
+ onChangeOutput?: (output: string, format: OutputFormat) => void;
399
+ /** Callback when the output format changes. */
400
+ onChangeOutputFormat?: (format: OutputFormat) => void;
401
+ /** Callback when the output preview mode changes. */
402
+ onChangeOutputPreviewMode?: (mode: OutputPreviewMode) => void;
403
+ /** Invoked when the built-in link button needs a URL. */
404
+ onRequestLink?: (payload: LinkRequestPayload) => void;
405
+ /** Invoked when the built-in image button needs an image source. */
406
+ onRequestImage?: (payload: ImageRequestPayload) => void;
285
407
  /** Whether multiline input is enabled. */
286
408
  multiline?: boolean;
287
409
  /** Minimum height for the input area. */
@@ -3,16 +3,16 @@ import type {
3
3
  FormatType,
4
4
  FormatStyle,
5
5
  HeadingLevel,
6
+ ListType,
6
7
  SelectionRange,
8
+ TextAlign,
7
9
  } from '../types';
8
10
  import {
9
11
  createSegment,
10
12
  findPositionInSegments,
11
- splitSegment,
12
13
  mergeAdjacentSegments,
13
14
  segmentsToPlainText,
14
15
  } from '../utils/parser';
15
- import { HEADING_FONT_SIZES } from '../constants/defaultStyles';
16
16
 
17
17
  /**
18
18
  * Toggle an inline format (bold, italic, etc.) on the selected range.
@@ -69,16 +69,37 @@ export function setHeadingOnLine(
69
69
  selection: SelectionRange,
70
70
  level: HeadingLevel,
71
71
  ): StyledSegment[] {
72
- const plainText = segmentsToPlainText(segments);
73
- const { lineStart, lineEnd } = getLineRange(plainText, selection.start);
72
+ return setLineStyleOnSelection(segments, selection, {
73
+ heading: level === 'none' ? undefined : level,
74
+ listType: level === 'none' ? undefined : undefined,
75
+ });
76
+ }
74
77
 
75
- const headingStyle: Partial<FormatStyle> = {
76
- heading: level,
77
- fontSize: HEADING_FONT_SIZES[level],
78
- bold: level !== 'none' ? true : undefined,
79
- };
78
+ /**
79
+ * Apply a list type to the lines containing the cursor/selection.
80
+ */
81
+ export function setListTypeOnLine(
82
+ segments: StyledSegment[],
83
+ selection: SelectionRange,
84
+ listType: ListType,
85
+ ): StyledSegment[] {
86
+ return setLineStyleOnSelection(segments, selection, {
87
+ listType: listType === 'none' ? undefined : listType,
88
+ heading: listType === 'none' ? undefined : undefined,
89
+ });
90
+ }
80
91
 
81
- return applyStyleToRange(segments, lineStart, lineEnd, headingStyle);
92
+ /**
93
+ * Apply text alignment to the lines containing the cursor/selection.
94
+ */
95
+ export function setTextAlignOnLine(
96
+ segments: StyledSegment[],
97
+ selection: SelectionRange,
98
+ textAlign: TextAlign,
99
+ ): StyledSegment[] {
100
+ return setLineStyleOnSelection(segments, selection, {
101
+ textAlign,
102
+ });
82
103
  }
83
104
 
84
105
  /**
@@ -142,6 +163,11 @@ export function getSelectionStyle(
142
163
  result.backgroundColor = undefined;
143
164
  if (result.fontSize !== s.fontSize) result.fontSize = undefined;
144
165
  if (result.heading !== s.heading) result.heading = undefined;
166
+ if (result.listType !== s.listType) result.listType = undefined;
167
+ if (result.textAlign !== s.textAlign) result.textAlign = undefined;
168
+ if (result.link !== s.link) result.link = undefined;
169
+ if (result.imageSrc !== s.imageSrc) result.imageSrc = undefined;
170
+ if (result.imageAlt !== s.imageAlt) result.imageAlt = undefined;
145
171
  }
146
172
 
147
173
  return result;
@@ -252,6 +278,17 @@ function applyStyleToRange(
252
278
  return mergeAdjacentSegments(result);
253
279
  }
254
280
 
281
+ function setLineStyleOnSelection(
282
+ segments: StyledSegment[],
283
+ selection: SelectionRange,
284
+ styleDelta: Partial<FormatStyle>,
285
+ ): StyledSegment[] {
286
+ const plainText = segmentsToPlainText(segments);
287
+ const { lineStart, lineEnd } = getSelectionLineRange(plainText, selection);
288
+
289
+ return applyStyleToRange(segments, lineStart, lineEnd, styleDelta);
290
+ }
291
+
255
292
  /**
256
293
  * Get the line start and end positions for the line containing the given position.
257
294
  */
@@ -272,5 +309,18 @@ function getLineRange(
272
309
  return { lineStart, lineEnd };
273
310
  }
274
311
 
312
+ function getSelectionLineRange(
313
+ text: string,
314
+ selection: SelectionRange,
315
+ ): { lineStart: number; lineEnd: number } {
316
+ const normalized = normalizeSelection(selection);
317
+ const lineStart = getLineRange(text, normalized.start).lineStart;
318
+ const effectiveEnd =
319
+ normalized.end > normalized.start ? normalized.end - 1 : normalized.end;
320
+ const lineEnd = getLineRange(text, effectiveEnd).lineEnd;
321
+
322
+ return { lineStart, lineEnd };
323
+ }
324
+
275
325
  // Re-export for convenience
276
326
  export { createSegment } from '../utils/parser';
@@ -77,7 +77,12 @@ export function areStylesEqual(a: FormatStyle, b: FormatStyle): boolean {
77
77
  (a.color ?? undefined) === (b.color ?? undefined) &&
78
78
  (a.backgroundColor ?? undefined) === (b.backgroundColor ?? undefined) &&
79
79
  (a.fontSize ?? undefined) === (b.fontSize ?? undefined) &&
80
- (a.heading ?? undefined) === (b.heading ?? undefined)
80
+ (a.heading ?? undefined) === (b.heading ?? undefined) &&
81
+ (a.listType ?? undefined) === (b.listType ?? undefined) &&
82
+ (a.textAlign ?? undefined) === (b.textAlign ?? undefined) &&
83
+ (a.link ?? undefined) === (b.link ?? undefined) &&
84
+ (a.imageSrc ?? undefined) === (b.imageSrc ?? undefined) &&
85
+ (a.imageAlt ?? undefined) === (b.imageAlt ?? undefined)
81
86
  );
82
87
  }
83
88
 
@@ -0,0 +1,13 @@
1
+ import type { OutputFormat, StyledSegment } from '../types';
2
+ /**
3
+ * Serialize styled segments as Markdown or HTML.
4
+ */
5
+ export declare function serializeSegments(segments: StyledSegment[], format?: OutputFormat): string;
6
+ /**
7
+ * Convenience wrapper for Markdown output.
8
+ */
9
+ export declare function segmentsToMarkdown(segments: StyledSegment[]): string;
10
+ /**
11
+ * Convenience wrapper for HTML output.
12
+ */
13
+ export declare function segmentsToHTML(segments: StyledSegment[]): string;
@@ -0,0 +1,365 @@
1
+ import type {
2
+ FormatStyle,
3
+ HeadingLevel,
4
+ ListType,
5
+ OutputFormat,
6
+ StyledSegment,
7
+ TextAlign,
8
+ } from '../types';
9
+
10
+ type LineFragment = Pick<StyledSegment, 'text' | 'styles'>;
11
+
12
+ /**
13
+ * Serialize styled segments as Markdown or HTML.
14
+ */
15
+ export function serializeSegments(
16
+ segments: StyledSegment[],
17
+ format: OutputFormat = 'markdown',
18
+ ): string {
19
+ const lines = splitSegmentsByLine(segments);
20
+ const blocks: string[] = [];
21
+
22
+ for (let index = 0; index < lines.length; ) {
23
+ const line = lines[index];
24
+ const listType = getLineListType(line);
25
+
26
+ if (listType && listType !== 'none') {
27
+ const listLines: LineFragment[][] = [];
28
+
29
+ while (index < lines.length && getLineListType(lines[index]) === listType) {
30
+ listLines.push(lines[index]);
31
+ index++;
32
+ }
33
+
34
+ blocks.push(serializeListBlock(listLines, format, listType));
35
+ continue;
36
+ }
37
+
38
+ blocks.push(serializeBlockLine(line, format));
39
+ index++;
40
+ }
41
+
42
+ return blocks.join('\n');
43
+ }
44
+
45
+ /**
46
+ * Convenience wrapper for Markdown output.
47
+ */
48
+ export function segmentsToMarkdown(segments: StyledSegment[]): string {
49
+ return serializeSegments(segments, 'markdown');
50
+ }
51
+
52
+ /**
53
+ * Convenience wrapper for HTML output.
54
+ */
55
+ export function segmentsToHTML(segments: StyledSegment[]): string {
56
+ return serializeSegments(segments, 'html');
57
+ }
58
+
59
+ function splitSegmentsByLine(segments: StyledSegment[]): LineFragment[][] {
60
+ const lines: LineFragment[][] = [[]];
61
+
62
+ for (const segment of segments) {
63
+ const parts = segment.text.split('\n');
64
+
65
+ parts.forEach((part, index) => {
66
+ if (part.length > 0 || segment.styles.imageSrc) {
67
+ lines[lines.length - 1]?.push({
68
+ text: part,
69
+ styles: { ...segment.styles },
70
+ });
71
+ }
72
+
73
+ if (index < parts.length - 1) {
74
+ lines.push([]);
75
+ }
76
+ });
77
+ }
78
+
79
+ return lines;
80
+ }
81
+
82
+ function serializeListBlock(
83
+ lines: LineFragment[][],
84
+ format: OutputFormat,
85
+ listType: ListType,
86
+ ): string {
87
+ if (format === 'html' || lines.some((line) => !!getLineTextAlign(line))) {
88
+ const tag = listType === 'ordered' ? 'ol' : 'ul';
89
+ const items = lines.map((line) => serializeHtmlListItem(line)).join('');
90
+ return `<${tag}>${items}</${tag}>`;
91
+ }
92
+
93
+ return lines
94
+ .map((line, index) => {
95
+ const marker = listType === 'ordered' ? `${index + 1}.` : '-';
96
+ const content = serializeLineContent(line, format);
97
+ return content.length > 0 ? `${marker} ${content}` : marker;
98
+ })
99
+ .join('\n');
100
+ }
101
+
102
+ function serializeHtmlListItem(line: LineFragment[]): string {
103
+ const content = serializeLineContent(line, 'html');
104
+ const styleAttribute = buildBlockStyle(getLineTextAlign(line));
105
+ return `<li${styleAttribute ? ` style="${styleAttribute}"` : ''}>${content}</li>`;
106
+ }
107
+
108
+ function serializeBlockLine(
109
+ line: LineFragment[],
110
+ format: OutputFormat,
111
+ ): string {
112
+ const heading = getLineHeading(line);
113
+ const textAlign = getLineTextAlign(line);
114
+ const content = serializeLineContent(line, format, heading);
115
+
116
+ if (format === 'html') {
117
+ const blockTag = heading ?? 'p';
118
+ const styleAttribute = buildBlockStyle(textAlign);
119
+ return `<${blockTag}${styleAttribute ? ` style="${styleAttribute}"` : ''}>${content}</${blockTag}>`;
120
+ }
121
+
122
+ if (textAlign) {
123
+ return serializeAlignedMarkdownLine(content, heading, textAlign);
124
+ }
125
+
126
+ const headingPrefix = getHeadingPrefix(heading);
127
+ if (!headingPrefix) {
128
+ return content;
129
+ }
130
+
131
+ return content.length > 0 ? `${headingPrefix} ${content}` : headingPrefix;
132
+ }
133
+
134
+ function serializeAlignedMarkdownLine(
135
+ content: string,
136
+ heading: HeadingLevel | undefined,
137
+ textAlign: TextAlign,
138
+ ): string {
139
+ const blockTag = heading ?? 'p';
140
+ const styleAttribute = buildBlockStyle(textAlign);
141
+ return `<${blockTag} style="${styleAttribute}">${content}</${blockTag}>`;
142
+ }
143
+
144
+ function serializeLineContent(
145
+ line: LineFragment[],
146
+ format: OutputFormat,
147
+ lineHeading?: HeadingLevel,
148
+ ): string {
149
+ return line
150
+ .map((fragment) => serializeFragment(fragment, format, lineHeading))
151
+ .join('');
152
+ }
153
+
154
+ function serializeFragment(
155
+ fragment: LineFragment,
156
+ format: OutputFormat,
157
+ lineHeading?: HeadingLevel,
158
+ ): string {
159
+ if (fragment.styles.imageSrc) {
160
+ return serializeImageFragment(fragment, format);
161
+ }
162
+
163
+ const normalizedStyles: FormatStyle = {
164
+ ...fragment.styles,
165
+ heading: undefined,
166
+ listType: undefined,
167
+ textAlign: undefined,
168
+ imageSrc: undefined,
169
+ imageAlt: undefined,
170
+ bold:
171
+ lineHeading && lineHeading !== 'none' ? false : fragment.styles.bold,
172
+ };
173
+
174
+ return format === 'html'
175
+ ? serializeHtmlFragment(fragment.text, normalizedStyles)
176
+ : serializeMarkdownFragment(fragment.text, normalizedStyles);
177
+ }
178
+
179
+ function serializeImageFragment(
180
+ fragment: LineFragment,
181
+ format: OutputFormat,
182
+ ): string {
183
+ const source = fragment.styles.imageSrc ?? '';
184
+ const altText = fragment.styles.imageAlt ?? extractImageAlt(fragment.text);
185
+
186
+ if (format === 'html') {
187
+ return `<img src="${escapeHtml(source)}" alt="${escapeHtml(altText)}" />`;
188
+ }
189
+
190
+ return `![${escapeMarkdown(altText)}](${escapeMarkdownUrl(source)})`;
191
+ }
192
+
193
+ function serializeHtmlFragment(text: string, styles: FormatStyle): string {
194
+ let result = escapeHtml(text);
195
+
196
+ if (styles.code) {
197
+ result = `<code>${result}</code>`;
198
+ }
199
+
200
+ if (styles.bold) {
201
+ result = `<strong>${result}</strong>`;
202
+ }
203
+
204
+ if (styles.italic) {
205
+ result = `<em>${result}</em>`;
206
+ }
207
+
208
+ if (styles.underline) {
209
+ result = `<u>${result}</u>`;
210
+ }
211
+
212
+ if (styles.strikethrough) {
213
+ result = `<s>${result}</s>`;
214
+ }
215
+
216
+ const styleAttribute = buildInlineStyle(styles);
217
+ if (styleAttribute) {
218
+ result = `<span style="${styleAttribute}">${result}</span>`;
219
+ }
220
+
221
+ if (styles.link) {
222
+ result = `<a href="${escapeHtml(styles.link)}">${result}</a>`;
223
+ }
224
+
225
+ return result;
226
+ }
227
+
228
+ function serializeMarkdownFragment(text: string, styles: FormatStyle): string {
229
+ let result = escapeMarkdown(text);
230
+
231
+ if (styles.code) {
232
+ result = wrapInlineCode(text);
233
+ }
234
+
235
+ if (styles.bold) {
236
+ result = `**${result}**`;
237
+ }
238
+
239
+ if (styles.italic) {
240
+ result = `*${result}*`;
241
+ }
242
+
243
+ if (styles.strikethrough) {
244
+ result = `~~${result}~~`;
245
+ }
246
+
247
+ if (styles.underline) {
248
+ result = `<u>${result}</u>`;
249
+ }
250
+
251
+ const styleAttribute = buildInlineStyle(styles);
252
+ if (styleAttribute) {
253
+ result = `<span style="${styleAttribute}">${result}</span>`;
254
+ }
255
+
256
+ if (styles.link) {
257
+ result = `[${result}](${escapeMarkdownUrl(styles.link)})`;
258
+ }
259
+
260
+ return result;
261
+ }
262
+
263
+ function buildInlineStyle(styles: FormatStyle): string {
264
+ const cssRules: string[] = [];
265
+
266
+ if (styles.color) {
267
+ cssRules.push(`color: ${styles.color}`);
268
+ }
269
+
270
+ if (styles.backgroundColor) {
271
+ cssRules.push(`background-color: ${styles.backgroundColor}`);
272
+ }
273
+
274
+ if (styles.fontSize) {
275
+ cssRules.push(`font-size: ${styles.fontSize}px`);
276
+ }
277
+
278
+ return cssRules.join('; ');
279
+ }
280
+
281
+ function buildBlockStyle(textAlign?: TextAlign): string {
282
+ const cssRules: string[] = [];
283
+
284
+ if (textAlign) {
285
+ cssRules.push(`text-align: ${textAlign}`);
286
+ }
287
+
288
+ return cssRules.join('; ');
289
+ }
290
+
291
+ function getLineHeading(line: LineFragment[]): HeadingLevel | undefined {
292
+ for (const fragment of line) {
293
+ if (fragment.styles.heading && fragment.styles.heading !== 'none') {
294
+ return fragment.styles.heading;
295
+ }
296
+ }
297
+
298
+ return undefined;
299
+ }
300
+
301
+ function getLineListType(line: LineFragment[]): ListType | undefined {
302
+ for (const fragment of line) {
303
+ if (fragment.styles.listType && fragment.styles.listType !== 'none') {
304
+ return fragment.styles.listType;
305
+ }
306
+ }
307
+
308
+ return undefined;
309
+ }
310
+
311
+ function getLineTextAlign(line: LineFragment[]): TextAlign | undefined {
312
+ for (const fragment of line) {
313
+ if (fragment.styles.textAlign) {
314
+ return fragment.styles.textAlign;
315
+ }
316
+ }
317
+
318
+ return undefined;
319
+ }
320
+
321
+ function getHeadingPrefix(heading?: HeadingLevel): string | undefined {
322
+ switch (heading) {
323
+ case 'h1':
324
+ return '#';
325
+ case 'h2':
326
+ return '##';
327
+ case 'h3':
328
+ return '###';
329
+ default:
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ function extractImageAlt(text: string): string {
335
+ const normalized = text.replace(/^\[Image:\s*/i, '').replace(/^\[Image\]/i, '').replace(/\]$/, '').trim();
336
+ return normalized.length > 0 ? normalized : 'image';
337
+ }
338
+
339
+ function escapeHtml(text: string): string {
340
+ return text
341
+ .replaceAll('&', '&amp;')
342
+ .replaceAll('<', '&lt;')
343
+ .replaceAll('>', '&gt;')
344
+ .replaceAll('"', '&quot;')
345
+ .replaceAll("'", '&#39;');
346
+ }
347
+
348
+ function escapeMarkdown(text: string): string {
349
+ return text.replace(/([\\`*_~[\]])/g, '\\$1');
350
+ }
351
+
352
+ function escapeMarkdownUrl(url: string): string {
353
+ return url.replaceAll(' ', '%20').replaceAll(')', '%29');
354
+ }
355
+
356
+ function wrapInlineCode(text: string): string {
357
+ const matches = text.match(/`+/g);
358
+ const longestBacktickRun = matches?.reduce(
359
+ (max, match) => Math.max(max, match.length),
360
+ 0,
361
+ ) ?? 0;
362
+ const fence = '`'.repeat(longestBacktickRun + 1);
363
+
364
+ return `${fence}${text}${fence}`;
365
+ }