@flighthq/text-markup 0.1.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.
@@ -0,0 +1,139 @@
1
+ import type { MarkupTagRegistry, RichTextContent, TextFormat } from '@flighthq/types';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ createMarkupTagRegistry,
6
+ registerMarkupTag,
7
+ registerStandardMarkupTags,
8
+ resolveMarkupHexColor,
9
+ } from './markupTagRegistry';
10
+ import { parseTextMarkup } from './textMarkup';
11
+
12
+ function formatAt(content: RichTextContent, index: number): TextFormat {
13
+ for (const range of content.formatRanges) {
14
+ if (index >= range.start && index < range.end) return range.format;
15
+ }
16
+ return {};
17
+ }
18
+
19
+ describe('createMarkupTagRegistry', () => {
20
+ it('returns an empty, independent registry', () => {
21
+ const a = createMarkupTagRegistry();
22
+ const b = createMarkupTagRegistry();
23
+ expect(a.handlers.size).toBe(0);
24
+ registerMarkupTag(a, 'b', () => ({ bold: true }));
25
+ expect(a.handlers.size).toBe(1);
26
+ expect(b.handlers.size).toBe(0);
27
+ });
28
+
29
+ it('supplies the dialect for parseTextMarkup — an empty registry keeps text but no format', () => {
30
+ const registry = createMarkupTagRegistry();
31
+ const content = parseTextMarkup('<b>plain</b>', registry);
32
+ expect(content.text).toBe('plain');
33
+ expect(content.formatRanges).toEqual([]);
34
+ });
35
+ });
36
+
37
+ describe('registerMarkupTag', () => {
38
+ it('registers a custom tag whose handler contributes format', () => {
39
+ const registry = createMarkupTagRegistry();
40
+ registerMarkupTag(registry, 'acme.hot', () => ({ color: 0xff0000 }));
41
+ const content = parseTextMarkup('<acme.hot>x</acme.hot>', registry);
42
+ expect(formatAt(content, 0)).toEqual({ color: 0xff0000 });
43
+ });
44
+
45
+ it('matches tag names case-insensitively', () => {
46
+ const registry = createMarkupTagRegistry();
47
+ registerMarkupTag(registry, 'Loud', () => ({ bold: true }));
48
+ expect(formatAt(parseTextMarkup('<LOUD>x</LOUD>', registry), 0)).toEqual({ bold: true });
49
+ });
50
+
51
+ it('is last-write-wins so a later registration overrides an earlier one', () => {
52
+ const registry = createMarkupTagRegistry();
53
+ registerMarkupTag(registry, 'x', () => ({ bold: true }));
54
+ registerMarkupTag(registry, 'x', () => ({ italic: true }));
55
+ expect(formatAt(parseTextMarkup('<x>y</x>', registry), 0)).toEqual({ italic: true });
56
+ });
57
+
58
+ it('reads a handler-provided attribute map', () => {
59
+ const registry = createMarkupTagRegistry();
60
+ registerMarkupTag(registry, 'size', (attributes) => {
61
+ const value = Number.parseFloat(attributes.value ?? '');
62
+ return Number.isFinite(value) ? { size: value } : {};
63
+ });
64
+ expect(formatAt(parseTextMarkup('<size value="18">x</size>', registry), 0)).toEqual({ size: 18 });
65
+ });
66
+ });
67
+
68
+ describe('registerStandardMarkupTags', () => {
69
+ function standardRegistry(): MarkupTagRegistry {
70
+ const registry = createMarkupTagRegistry();
71
+ registerStandardMarkupTags(registry);
72
+ return registry;
73
+ }
74
+
75
+ it('registers the standard style, font, block, and break tags', () => {
76
+ const registry = standardRegistry();
77
+ for (const name of [
78
+ 'a',
79
+ 'b',
80
+ 'br',
81
+ 'em',
82
+ 'font',
83
+ 'i',
84
+ 'li',
85
+ 'p',
86
+ 's',
87
+ 'span',
88
+ 'strike',
89
+ 'strong',
90
+ 'textformat',
91
+ 'u',
92
+ ]) {
93
+ expect(registry.handlers.has(name)).toBe(true);
94
+ }
95
+ });
96
+
97
+ it('maps the bold/italic/strike aliases to the same booleans', () => {
98
+ const registry = standardRegistry();
99
+ expect(formatAt(parseTextMarkup('<strong>x</strong>', registry), 0)).toEqual({ bold: true });
100
+ expect(formatAt(parseTextMarkup('<em>x</em>', registry), 0)).toEqual({ italic: true });
101
+ expect(formatAt(parseTextMarkup('<strike>x</strike>', registry), 0)).toEqual({ strikethrough: true });
102
+ expect(formatAt(parseTextMarkup('<s>x</s>', registry), 0)).toEqual({ strikethrough: true });
103
+ });
104
+
105
+ it('parses a hex font color but leaves a named color unresolved by default', () => {
106
+ const registry = standardRegistry();
107
+ expect(formatAt(parseTextMarkup('<font color="#ff0000">x</font>', registry), 0).color).toBe(0xff0000);
108
+ // Named colors are gated behind `registerMarkupNamedColors`; the standard dialect alone is hex-only,
109
+ // so a named color resolves to no color gracefully rather than erroring.
110
+ expect(formatAt(parseTextMarkup('<font color="red">x</font>', registry), 0).color).toBeUndefined();
111
+ });
112
+
113
+ it('installs the hex-only color resolver as the registry seam', () => {
114
+ const registry = standardRegistry();
115
+ expect(registry.colorResolver).toBe(resolveMarkupHexColor);
116
+ });
117
+
118
+ it('inserts an implicit collapsing break before a block tag', () => {
119
+ const registry = standardRegistry();
120
+ const content = parseTextMarkup('a<p align="center">b</p>', registry);
121
+ expect(content.text).toBe('a\nb');
122
+ expect(formatAt(content, 2)).toEqual({ align: 'center' });
123
+ });
124
+ });
125
+
126
+ describe('resolveMarkupHexColor', () => {
127
+ it('parses #rrggbb, #rgb, and 0xRRGGBB values', () => {
128
+ expect(resolveMarkupHexColor('#ff0000')).toBe(0xff0000);
129
+ expect(resolveMarkupHexColor('#f00')).toBe(0xff0000);
130
+ expect(resolveMarkupHexColor('0x00ff00')).toBe(0x00ff00);
131
+ expect(resolveMarkupHexColor(' #0000FF ')).toBe(0x0000ff);
132
+ });
133
+
134
+ it('returns null for a named color or unparseable value — no table on this path', () => {
135
+ expect(resolveMarkupHexColor('red')).toBeNull();
136
+ expect(resolveMarkupHexColor('#zz0000')).toBeNull();
137
+ expect(resolveMarkupHexColor('')).toBeNull();
138
+ });
139
+ });
@@ -0,0 +1,248 @@
1
+ import type { RichTextContent, TextFormat } from '@flighthq/types';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createMarkupTagRegistry, registerMarkupTag } from './markupTagRegistry';
5
+ import { formatTextMarkup, parseTextMarkup } from './textMarkup';
6
+
7
+ function formatAt(content: RichTextContent, index: number): TextFormat {
8
+ for (const range of content.formatRanges) {
9
+ if (index >= range.start && index < range.end) return range.format;
10
+ }
11
+ return {};
12
+ }
13
+
14
+ describe('formatTextMarkup', () => {
15
+ it('returns an empty string for empty content', () => {
16
+ expect(formatTextMarkup({ formatRanges: [], text: '' })).toBe('');
17
+ });
18
+
19
+ it('emits plain text with no ranges and no tags', () => {
20
+ expect(formatTextMarkup({ formatRanges: [], text: 'hello' })).toBe('hello');
21
+ });
22
+
23
+ it('escapes text entities on the way out', () => {
24
+ expect(formatTextMarkup({ formatRanges: [], text: 'a < b & c > d' })).toBe('a &lt; b &amp; c &gt; d');
25
+ });
26
+
27
+ it('emits style booleans as their tags', () => {
28
+ const content: RichTextContent = {
29
+ formatRanges: [{ end: 4, format: { bold: true, italic: true, strikethrough: true, underline: true }, start: 0 }],
30
+ text: 'text',
31
+ };
32
+ expect(formatTextMarkup(content)).toBe('<b><i><u><s>text</s></u></i></b>');
33
+ });
34
+
35
+ it('emits font color as a #rrggbb attribute', () => {
36
+ const content: RichTextContent = {
37
+ formatRanges: [{ end: 3, format: { color: 0xff0000, font: 'Verdana', size: 18 }, start: 0 }],
38
+ text: 'red',
39
+ };
40
+ expect(formatTextMarkup(content)).toBe('<font color="#ff0000" size="18" face="Verdana">red</font>');
41
+ });
42
+
43
+ it('emits anchors with href and target', () => {
44
+ const content: RichTextContent = {
45
+ formatRanges: [{ end: 4, format: { target: '_blank', url: 'https://a.test' }, start: 0 }],
46
+ text: 'link',
47
+ };
48
+ expect(formatTextMarkup(content)).toBe('<a href="https://a.test" target="_blank">link</a>');
49
+ });
50
+
51
+ it('emits textformat block metrics', () => {
52
+ const content: RichTextContent = {
53
+ formatRanges: [{ end: 1, format: { blockIndent: 4, leading: 2, leftMargin: 10, tabStops: [10, 20] }, start: 0 }],
54
+ text: 'x',
55
+ };
56
+ expect(formatTextMarkup(content)).toBe(
57
+ '<textformat blockindent="4" leading="2" leftmargin="10" tabstops="10,20">x</textformat>',
58
+ );
59
+ });
60
+
61
+ it('emits bullets with a list marker type', () => {
62
+ const content: RichTextContent = {
63
+ formatRanges: [{ end: 4, format: { bullet: true, listMarker: 'square' }, start: 0 }],
64
+ text: 'item',
65
+ };
66
+ expect(formatTextMarkup(content)).toBe('<li type="square">item</li>');
67
+ });
68
+ });
69
+
70
+ describe('parseTextMarkup', () => {
71
+ it('returns empty content for an empty string', () => {
72
+ const content = parseTextMarkup('');
73
+ expect(content.text).toBe('');
74
+ expect(content.formatRanges).toEqual([]);
75
+ });
76
+
77
+ it('keeps plain text with no ranges', () => {
78
+ const content = parseTextMarkup('just text');
79
+ expect(content.text).toBe('just text');
80
+ expect(content.formatRanges).toEqual([]);
81
+ });
82
+
83
+ it('decodes named and numeric entities', () => {
84
+ const content = parseTextMarkup('&amp;&lt;&gt;&quot;&apos;&#65;&#x42;');
85
+ expect(content.text).toBe('&<>"\'AB');
86
+ });
87
+
88
+ it('leaves unknown named entities verbatim', () => {
89
+ expect(parseTextMarkup('&unknown;').text).toBe('&unknown;');
90
+ });
91
+
92
+ it('maps b/i/u/s tags to style booleans', () => {
93
+ expect(formatAt(parseTextMarkup('<b>x</b>'), 0)).toEqual({ bold: true });
94
+ expect(formatAt(parseTextMarkup('<i>x</i>'), 0)).toEqual({ italic: true });
95
+ expect(formatAt(parseTextMarkup('<u>x</u>'), 0)).toEqual({ underline: true });
96
+ expect(formatAt(parseTextMarkup('<s>x</s>'), 0)).toEqual({ strikethrough: true });
97
+ });
98
+
99
+ it('maps the strong/em/strike aliases to the same booleans', () => {
100
+ expect(formatAt(parseTextMarkup('<strong>x</strong>'), 0)).toEqual({ bold: true });
101
+ expect(formatAt(parseTextMarkup('<em>x</em>'), 0)).toEqual({ italic: true });
102
+ expect(formatAt(parseTextMarkup('<strike>x</strike>'), 0)).toEqual({ strikethrough: true });
103
+ });
104
+
105
+ it('leaves a named font color unresolved by default — named colors are opt-in', () => {
106
+ // The default registry is hex-only; `registerMarkupNamedColors` opts a registry into named colors.
107
+ // Without it, a named color resolves to no color gracefully rather than erroring.
108
+ expect(formatAt(parseTextMarkup('<font color="red">x</font>'), 0).color).toBeUndefined();
109
+ expect(formatAt(parseTextMarkup('<font color="cornflowerblue">x</font>'), 0).color).toBeUndefined();
110
+ });
111
+
112
+ it('uses a passed registry instead of the standard dialect', () => {
113
+ const registry = createMarkupTagRegistry();
114
+ registerMarkupTag(registry, 'hot', () => ({ color: 0xff0000 }));
115
+ const content = parseTextMarkup('<hot>x</hot><b>y</b>', registry);
116
+ expect(content.text).toBe('xy');
117
+ // `hot` is registered; `b` is not, so it keeps its text with no format.
118
+ expect(formatAt(content, 0)).toEqual({ color: 0xff0000 });
119
+ expect(formatAt(content, 1)).toEqual({});
120
+ });
121
+
122
+ it('parses font color (#rrggbb, #rgb, 0x), size, and face', () => {
123
+ expect(formatAt(parseTextMarkup('<font color="#ff0000">x</font>'), 0).color).toBe(0xff0000);
124
+ expect(formatAt(parseTextMarkup('<font color="#f00">x</font>'), 0).color).toBe(0xff0000);
125
+ expect(formatAt(parseTextMarkup('<font color="0x00ff00">x</font>'), 0).color).toBe(0x00ff00);
126
+ expect(formatAt(parseTextMarkup('<font size="24" face="Arial">x</font>'), 0)).toEqual({ font: 'Arial', size: 24 });
127
+ });
128
+
129
+ it('ignores an unparseable font color rather than throwing', () => {
130
+ expect(formatAt(parseTextMarkup('<font color="notacolor">x</font>'), 0).color).toBeUndefined();
131
+ });
132
+
133
+ it('parses anchors into url and target', () => {
134
+ const format = formatAt(parseTextMarkup('<a href="https://a.test" target="_blank">x</a>'), 0);
135
+ expect(format).toEqual({ target: '_blank', url: 'https://a.test' });
136
+ });
137
+
138
+ it('parses p align into the align field', () => {
139
+ expect(formatAt(parseTextMarkup('<p align="center">x</p>'), 0)).toEqual({ align: 'center' });
140
+ });
141
+
142
+ it('parses li into bullet and an optional list marker', () => {
143
+ expect(formatAt(parseTextMarkup('<li>x</li>'), 0)).toEqual({ bullet: true });
144
+ expect(formatAt(parseTextMarkup('<li type="square">x</li>'), 0)).toEqual({ bullet: true, listMarker: 'square' });
145
+ });
146
+
147
+ it('inserts an implicit break before a block tag but not at the start', () => {
148
+ expect(parseTextMarkup('<p align="left">first</p>').text).toBe('first');
149
+ const content = parseTextMarkup('<p align="left">a</p><p align="right">b</p>');
150
+ expect(content.text).toBe('a\nb');
151
+ expect(formatAt(content, 0)).toEqual({ align: 'left' });
152
+ expect(formatAt(content, 2)).toEqual({ align: 'right' });
153
+ });
154
+
155
+ it('collapses the block break against an existing trailing newline', () => {
156
+ const content = parseTextMarkup('a<br><li>b</li>');
157
+ expect(content.text).toBe('a\nb');
158
+ });
159
+
160
+ it('parses textformat block metrics', () => {
161
+ const format = formatAt(
162
+ parseTextMarkup('<textformat leftmargin="10" blockindent="4" tabstops="10, 20">x</textformat>'),
163
+ 0,
164
+ );
165
+ expect(format).toEqual({ blockIndent: 4, leftMargin: 10, tabStops: [10, 20] });
166
+ });
167
+
168
+ it('turns br into a newline with no orphan format', () => {
169
+ const content = parseTextMarkup('a<br>b');
170
+ expect(content.text).toBe('a\nb');
171
+ expect(content.formatRanges).toEqual([]);
172
+ });
173
+
174
+ it('keeps span text but applies no format', () => {
175
+ const content = parseTextMarkup('<span class="fancy">kept</span>');
176
+ expect(content.text).toBe('kept');
177
+ expect(content.formatRanges).toEqual([]);
178
+ });
179
+
180
+ it('drops img tags entirely', () => {
181
+ const content = parseTextMarkup('a<img src="x.png"/>b');
182
+ expect(content.text).toBe('ab');
183
+ expect(content.formatRanges).toEqual([]);
184
+ });
185
+
186
+ it('composes nested tags into a single range', () => {
187
+ const content = parseTextMarkup('<font color="#0000ff"><b>x</b></font>');
188
+ expect(formatAt(content, 0)).toEqual({ bold: true, color: 0x0000ff });
189
+ });
190
+
191
+ it('splits ranges at format boundaries', () => {
192
+ const content = parseTextMarkup('<b>a<i>b</i></b>c');
193
+ expect(content.text).toBe('abc');
194
+ expect(formatAt(content, 0)).toEqual({ bold: true });
195
+ expect(formatAt(content, 1)).toEqual({ bold: true, italic: true });
196
+ expect(formatAt(content, 2)).toEqual({});
197
+ });
198
+
199
+ it('recovers from an unclosed tag by extending to the end', () => {
200
+ const content = parseTextMarkup('<b>bold to end');
201
+ expect(content.text).toBe('bold to end');
202
+ expect(formatAt(content, 0)).toEqual({ bold: true });
203
+ expect(formatAt(content, content.text.length - 1)).toEqual({ bold: true });
204
+ });
205
+
206
+ it('keeps the text of unknown tags', () => {
207
+ const content = parseTextMarkup('<marquee>scroll</marquee>');
208
+ expect(content.text).toBe('scroll');
209
+ expect(content.formatRanges).toEqual([]);
210
+ });
211
+
212
+ it('treats a stray < with no > as literal text', () => {
213
+ const content = parseTextMarkup('a < b');
214
+ expect(content.text).toBe('a < b');
215
+ });
216
+
217
+ it('ignores an extra closing tag without throwing', () => {
218
+ const content = parseTextMarkup('a</b>b');
219
+ expect(content.text).toBe('ab');
220
+ expect(content.formatRanges).toEqual([]);
221
+ });
222
+ });
223
+
224
+ describe('textMarkupRoundTrip', () => {
225
+ const cases: string[] = [
226
+ 'plain text',
227
+ 'a <b>bold</b> and <i>italic</i> mix',
228
+ '<font color="#123456" size="20" face="Georgia">styled</font>',
229
+ '<a href="https://a.test" target="_top">link</a>',
230
+ '<p align="justify">para</p>',
231
+ '<li type="disc">bullet</li>',
232
+ '<textformat leftmargin="8" indent="2" leading="4" tabstops="10,20,30">metrics</textformat>',
233
+ '<font color="#00ff00"><b><u>deep</u></b></font>',
234
+ 'entities &amp; &lt; &gt; escaped',
235
+ 'line one<br>line two',
236
+ '<p align="left">one</p><p align="right">two</p>',
237
+ 'lead<li type="disc">item</li>',
238
+ ];
239
+
240
+ it('is a fixed point over parse, format, reparse', () => {
241
+ for (const source of cases) {
242
+ const once = parseTextMarkup(source);
243
+ const twice = parseTextMarkup(formatTextMarkup(once));
244
+ expect(twice.text).toBe(once.text);
245
+ expect(twice.formatRanges).toEqual(once.formatRanges);
246
+ }
247
+ });
248
+ });