@outputai/llm 0.11.0 → 0.11.1-next.2223fa5.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 (69) hide show
  1. package/package.json +2 -2
  2. package/src/agent.js +105 -83
  3. package/src/agent.spec.js +312 -225
  4. package/src/ai_provider.js +2 -2
  5. package/src/ai_sdk_options.js +93 -39
  6. package/src/ai_sdk_options.spec.js +314 -154
  7. package/src/consts.js +6 -0
  8. package/src/generate.js +81 -0
  9. package/src/generate.spec.js +466 -0
  10. package/src/index.d.ts +222 -242
  11. package/src/index.js +3 -4
  12. package/src/prompt/content.js +70 -0
  13. package/src/prompt/content.spec.js +208 -0
  14. package/src/prompt/{escape.js → interpolations.js} +11 -18
  15. package/src/prompt/interpolations.spec.js +109 -0
  16. package/src/prompt/loader.full.spec.js +293 -0
  17. package/src/prompt/loader.js +45 -39
  18. package/src/prompt/loader.spec.js +186 -289
  19. package/src/prompt/markup/attributes.js +52 -0
  20. package/src/prompt/markup/attributes.spec.js +132 -0
  21. package/src/prompt/markup/nodes.js +71 -0
  22. package/src/prompt/markup/nodes.spec.js +333 -0
  23. package/src/prompt/markup/tokenizer.js +3 -0
  24. package/src/prompt/markup/tokenizer.spec.js +149 -0
  25. package/src/prompt/markup/tokens.js +26 -0
  26. package/src/prompt/markup/tokens.spec.js +250 -0
  27. package/src/prompt/validations.js +98 -67
  28. package/src/prompt/validations.spec.js +204 -47
  29. package/src/{prompt/load_content.js → utils/file.js} +12 -15
  30. package/src/utils/file.spec.js +89 -0
  31. package/src/utils/models.js +15 -0
  32. package/src/utils/models.spec.js +119 -0
  33. package/src/utils/skills.js +74 -0
  34. package/src/utils/skills.spec.js +168 -0
  35. package/src/utils/sources.js +48 -0
  36. package/src/utils/sources.spec.js +122 -0
  37. package/src/utils/stream.js +22 -0
  38. package/src/utils/stream.spec.js +55 -0
  39. package/src/utils/tools.js +47 -0
  40. package/src/utils/tools.spec.js +167 -0
  41. package/src/utils/wrap.js +148 -0
  42. package/src/utils/wrap.spec.js +359 -0
  43. package/src/validations.js +149 -27
  44. package/src/validations.spec.js +451 -53
  45. package/src/ai_model.js +0 -60
  46. package/src/ai_model.spec.js +0 -259
  47. package/src/ai_sdk.js +0 -92
  48. package/src/ai_sdk.spec.js +0 -564
  49. package/src/prompt/block_options.js +0 -58
  50. package/src/prompt/block_options.spec.js +0 -71
  51. package/src/prompt/blocks.js +0 -47
  52. package/src/prompt/blocks.spec.js +0 -63
  53. package/src/prompt/escape.spec.js +0 -159
  54. package/src/prompt/load_content.spec.js +0 -83
  55. package/src/prompt/loader_validation.spec.js +0 -128
  56. package/src/prompt/parser.js +0 -16
  57. package/src/prompt/parser.spec.js +0 -186
  58. package/src/prompt/prepare_text.js +0 -27
  59. package/src/prompt/prepare_text.spec.js +0 -141
  60. package/src/prompt/skill.js +0 -128
  61. package/src/prompt/skill.spec.js +0 -172
  62. package/src/utils/message.js +0 -3
  63. package/src/utils/message.spec.js +0 -29
  64. package/src/utils/response_wrappers.js +0 -100
  65. package/src/utils/response_wrappers.spec.js +0 -240
  66. package/src/utils/source_extraction.js +0 -53
  67. package/src/utils/source_extraction.spec.js +0 -194
  68. package/src/utils/trace.js +0 -19
  69. package/src/utils/trace.spec.js +0 -112
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { extractAttributes } from './attributes.js';
3
+
4
+ describe( 'extractAttributes', () => {
5
+ it( 'returns no attributes for empty input', () => {
6
+ expect( extractAttributes( '' ) ).toEqual( {} );
7
+ } );
8
+
9
+ it( 'returns no attributes for whitespace-only input', () => {
10
+ expect( extractAttributes( ' \n\t ' ) ).toEqual( {} );
11
+ } );
12
+
13
+ it( 'parses bare attributes as true', () => {
14
+ expect( extractAttributes( 'cached pinned' ) ).toEqual( {
15
+ cached: true,
16
+ pinned: true
17
+ } );
18
+ } );
19
+
20
+ it( 'lowercases attribute names', () => {
21
+ expect( extractAttributes( 'OPTIONS=cached PINNED' ) ).toEqual( {
22
+ options: 'cached',
23
+ pinned: true
24
+ } );
25
+ } );
26
+
27
+ it( 'preserves unquoted values as strings', () => {
28
+ expect( extractAttributes( 'enabled=false count=001 ratio=1.25' ) ).toEqual( {
29
+ enabled: 'false',
30
+ count: '001',
31
+ ratio: '1.25'
32
+ } );
33
+ } );
34
+
35
+ it( 'removes matching double quotes', () => {
36
+ expect( extractAttributes( 'options="cached shared"' ) ).toEqual( {
37
+ options: 'cached shared'
38
+ } );
39
+ } );
40
+
41
+ it( 'removes matching single quotes', () => {
42
+ expect( extractAttributes( 'options=\'cached shared\'' ) ).toEqual( {
43
+ options: 'cached shared'
44
+ } );
45
+ } );
46
+
47
+ it( 'allows the opposite quote type inside quoted values', () => {
48
+ expect( extractAttributes( 'single="it\'s ready" double=\'say "ready"\'' ) ).toEqual( {
49
+ single: 'it\'s ready',
50
+ double: 'say "ready"'
51
+ } );
52
+ } );
53
+
54
+ it( 'preserves equals and greater-than characters inside quoted values', () => {
55
+ expect( extractAttributes( 'condition="score >= 10" expression=\'a = b\'' ) ).toEqual( {
56
+ condition: 'score >= 10',
57
+ expression: 'a = b'
58
+ } );
59
+ } );
60
+
61
+ it( 'supports whitespace around equals signs', () => {
62
+ expect( extractAttributes( 'options = "cached shared" mode = fast pinned' ) ).toEqual( {
63
+ options: 'cached shared',
64
+ mode: 'fast',
65
+ pinned: true
66
+ } );
67
+ } );
68
+
69
+ it( 'supports tabs and newlines between attributes', () => {
70
+ expect( extractAttributes( 'options="cached"\nmode=fast\tpinned' ) ).toEqual( {
71
+ options: 'cached',
72
+ mode: 'fast',
73
+ pinned: true
74
+ } );
75
+ } );
76
+
77
+ it( 'ignores surrounding whitespace', () => {
78
+ expect( extractAttributes( ' options = "cached" pinned ' ) ).toEqual( {
79
+ options: 'cached',
80
+ pinned: true
81
+ } );
82
+ } );
83
+
84
+ it( 'parses an explicitly empty value', () => {
85
+ expect( extractAttributes( 'options=' ) ).toEqual( { options: '' } );
86
+ } );
87
+
88
+ it( 'uses the last value when an attribute is repeated', () => {
89
+ expect( extractAttributes( 'mode=slow mode=fast' ) ).toEqual( { mode: 'fast' } );
90
+ } );
91
+
92
+ it( 'rejects an unterminated single-quoted value', () => {
93
+ expect( () => extractAttributes( 'color=\'red' ) )
94
+ .toThrow( /^Invalid attribute "color": quoted value is missing its closing quote \('\)\.$/ );
95
+ } );
96
+
97
+ it( 'rejects an unterminated double-quoted value', () => {
98
+ expect( () => extractAttributes( 'color="red' ) )
99
+ .toThrow( /^Invalid attribute "color": quoted value is missing its closing quote \("\)\.$/ );
100
+ } );
101
+
102
+ it( 'rejects values closed by a different quote type', () => {
103
+ expect( () => extractAttributes( 'color=\'red"' ) ).toThrow();
104
+ expect( () => extractAttributes( 'color="red\'' ) ).toThrow();
105
+ } );
106
+
107
+ it( 'rejects stray quotes in unquoted values', () => {
108
+ expect( () => extractAttributes( 'color=red\'' ) ).toThrow();
109
+ expect( () => extractAttributes( 'color=red"' ) ).toThrow();
110
+ expect( () => extractAttributes( 'color=arby\'s' ) ).toThrow();
111
+ expect( () => extractAttributes( 'color=jack"madman"silva' ) ).toThrow();
112
+ } );
113
+
114
+ it( 'rejects matching quote characters inside quoted values', () => {
115
+ expect( () => extractAttributes( 'message="say "hello""' ) )
116
+ .toThrow( /^Invalid attribute "message": quoted value contains an unexpected quote \("\)\.$/ );
117
+ expect( () => extractAttributes( 'message=\'say \'hello\'\'' ) ).toThrow();
118
+ expect( () => extractAttributes( 'color="red"dark"' ) ).toThrow();
119
+ expect( () => extractAttributes( 'color=\'red\'dark\'' ) ).toThrow();
120
+ } );
121
+
122
+ it( 'rejects an empty attribute name', () => {
123
+ expect( () => extractAttributes( '=value' ) )
124
+ .toThrow( /^Invalid attribute "=value": name is missing\.$/ );
125
+ } );
126
+
127
+ it( 'rejects invalid valued and bare attribute names', () => {
128
+ expect( () => extractAttributes( 'bad/name=value' ) )
129
+ .toThrow( /^Invalid attribute name "bad\/name"\.$/ );
130
+ expect( () => extractAttributes( 'bad/name' ) ).toThrow();
131
+ } );
132
+ } );
@@ -0,0 +1,71 @@
1
+ import { isOpeningTag, isClosingTag, isEmptyText, isSelfClosingTag, isComment, extractTag } from './tokens.js' ;
2
+ import { tokenize } from './tokenizer.js';
3
+ import { ValidationError } from '@outputai/core';
4
+
5
+ class Node {
6
+ constructor( tagName, attributes, content = '' ) {
7
+ this.tagName = tagName;
8
+ this.attributes = attributes;
9
+ this.content = content;
10
+ }
11
+ }
12
+
13
+ export const parseMarkup = content => {
14
+ const tokens = tokenize( content );
15
+ if ( !tokens ) {
16
+ return [];
17
+ }
18
+
19
+ const state = {
20
+ currentNode: null
21
+ };
22
+
23
+ const nodes = [];
24
+
25
+ // If the first ever valid text isnt a markup or comment, don't try to parse and return no nodes
26
+ const firstContentToken = tokens.find( token => !isComment( token ) && !isEmptyText( token ) );
27
+ if ( firstContentToken && !isOpeningTag( firstContentToken ) && !isClosingTag( firstContentToken ) && !isSelfClosingTag( firstContentToken ) ) {
28
+ return [];
29
+ }
30
+
31
+ for ( const token of tokens ) {
32
+ if ( isOpeningTag( token ) ) {
33
+ const { tagName, attributes } = extractTag( token );
34
+ if ( !state.currentNode ) {
35
+ state.currentNode = new Node( tagName, attributes );
36
+ } else {
37
+ if ( tagName === state.currentNode.tagName ) {
38
+ throw new ValidationError(
39
+ `Invalid child tag: <${tagName}> cannot appear inside another <${tagName}>. Escape it as "&lt;${tagName}&gt;".`
40
+ );
41
+ }
42
+ state.currentNode.content += token;
43
+ }
44
+ } else if ( isClosingTag( token ) ) {
45
+ if ( !state.currentNode ) {
46
+ throw new ValidationError( `Invalid root tag: closing tag "${token}" has no matching opening tag.` );
47
+ }
48
+ if ( extractTag( token ).tagName === state.currentNode.tagName ) {
49
+ nodes.push( state.currentNode );
50
+ state.currentNode = null;
51
+ } else {
52
+ state.currentNode.content += token;
53
+ }
54
+ } else {
55
+ if ( state.currentNode ) {
56
+ state.currentNode.content += token;
57
+ } else {
58
+ if ( isSelfClosingTag( token ) ) {
59
+ throw new ValidationError( `Invalid root tag: self-closing tag "${token}" is not allowed.` );
60
+ } else if ( !isComment( token ) && !isEmptyText( token ) ) {
61
+ throw new ValidationError( 'Invalid root text: text is not allowed outside message tags.' );
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ if ( state.currentNode ) {
68
+ throw new ValidationError( `Invalid tag: <${state.currentNode.tagName}> is missing its closing tag.` );
69
+ }
70
+ return nodes;
71
+ };
@@ -0,0 +1,333 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseMarkup } from './nodes.js';
3
+
4
+ const blocks = content =>
5
+ parseMarkup( content ).map( ( { tagName, content: blockContent } ) => ( {
6
+ tagName,
7
+ content: blockContent
8
+ } ) );
9
+
10
+ describe( 'parseMarkup', () => {
11
+ describe( 'top-level blocks', () => {
12
+ it( 'parses one generic block', () => {
13
+ expect( blocks( '<user>Hello</user>' ) ).toEqual( [
14
+ { tagName: 'user', content: 'Hello' }
15
+ ] );
16
+ } );
17
+
18
+ it( 'parses multiple sibling blocks', () => {
19
+ expect( blocks( '<system>Rules</system>\n\n<user>Question</user>' ) ).toEqual( [
20
+ { tagName: 'system', content: 'Rules' },
21
+ { tagName: 'user', content: 'Question' }
22
+ ] );
23
+ } );
24
+
25
+ it( 'supports generic and hyphenated tag names', () => {
26
+ expect( blocks( '<custom-block>Value</custom-block>' ) ).toEqual( [
27
+ { tagName: 'custom-block', content: 'Value' }
28
+ ] );
29
+ } );
30
+
31
+ it( 'matches tag names case-insensitively', () => {
32
+ expect( blocks( '<User>Hello</USER>' ) ).toEqual( [
33
+ { tagName: 'user', content: 'Hello' }
34
+ ] );
35
+ } );
36
+
37
+ it( 'parses an empty block', () => {
38
+ expect( blocks( '<user></user>' ) ).toEqual( [
39
+ { tagName: 'user', content: '' }
40
+ ] );
41
+ } );
42
+
43
+ it( 'rejects a top-level self-closing block', () => {
44
+ expect( () => parseMarkup( '<user />' ) )
45
+ .toThrow( /^Invalid root tag: self-closing tag "<user \/>" is not allowed\.$/ );
46
+ } );
47
+
48
+ it( 'returns no blocks for empty content', () => {
49
+ expect( parseMarkup( '' ) ).toEqual( [] );
50
+ } );
51
+
52
+ it( 'returns no blocks for plain text', () => {
53
+ expect( parseMarkup( 'Generate a cinematic poster.' ) ).toEqual( [] );
54
+ } );
55
+
56
+ it( 'returns no blocks for text mixed with whitespace and comments', () => {
57
+ expect( parseMarkup( '<!-- before -->\nGenerate a poster.\n<!-- after -->' ) ).toEqual( [] );
58
+ } );
59
+
60
+ it( 'returns no blocks when text precedes otherwise valid markup', () => {
61
+ expect( parseMarkup( 'Note\n<user>Hello</user>\nTail' ) ).toEqual( [] );
62
+ } );
63
+
64
+ it( 'uses the first meaningful token after leading comments', () => {
65
+ expect( parseMarkup( '<!-- note -->\nExplain <user>Hello</user> literally.' ) ).toEqual( [] );
66
+ } );
67
+
68
+ it( 'does not validate later tag-shaped content after instruction mode starts', () => {
69
+ expect( parseMarkup( 'Explain </user>, <image />, and <user> examples.' ) ).toEqual( [] );
70
+ } );
71
+
72
+ it( 'returns no blocks for tag-shaped text that is not a valid tag', () => {
73
+ expect( parseMarkup( 'Use a<b, b>c, and c < d.' ) ).toEqual( [] );
74
+ } );
75
+
76
+ it( 'treats an unterminated comment marker as plain text', () => {
77
+ expect( parseMarkup( '<!-- unfinished' ) ).toEqual( [] );
78
+ } );
79
+
80
+ it( 'returns no blocks for whitespace and comments only', () => {
81
+ expect( parseMarkup( '\n<!-- first -->\n<!-- second\nnote -->\n' ) ).toEqual( [] );
82
+ } );
83
+
84
+ it( 'ignores surrounding whitespace and comments', () => {
85
+ expect( blocks( '<!-- prompt note -->\n\n<user>Hello</user>\n' ) ).toEqual( [
86
+ { tagName: 'user', content: 'Hello' }
87
+ ] );
88
+ } );
89
+
90
+ it( 'ignores multiline comments outside blocks', () => {
91
+ expect( blocks( '<!-- prompt\nnote -->\n<user>Hello</user>' ) ).toEqual( [
92
+ { tagName: 'user', content: 'Hello' }
93
+ ] );
94
+ } );
95
+
96
+ it( 'ignores comments before, between, and after blocks', () => {
97
+ expect( blocks(
98
+ '<!-- before --><system>Rules</system><!-- between --><user>Question</user><!-- after -->'
99
+ ) ).toEqual( [
100
+ { tagName: 'system', content: 'Rules' },
101
+ { tagName: 'user', content: 'Question' }
102
+ ] );
103
+ } );
104
+
105
+ it( 'supports whitespace inside a closing tag', () => {
106
+ expect( blocks( '<user>Hello</ user >' ) ).toEqual( [
107
+ { tagName: 'user', content: 'Hello' }
108
+ ] );
109
+ } );
110
+ } );
111
+
112
+ describe( 'opaque block content', () => {
113
+ it( 'preserves a closed nested tag with a different name', () => {
114
+ expect( blocks( '<user>Use <title>Example</title> here.</user>' ) ).toEqual( [
115
+ { tagName: 'user', content: 'Use <title>Example</title> here.' }
116
+ ] );
117
+ } );
118
+
119
+ it( 'preserves an open nested tag with a different name', () => {
120
+ expect( blocks( '<user>Use the <title> tag here.</user>' ) ).toEqual( [
121
+ { tagName: 'user', content: 'Use the <title> tag here.' }
122
+ ] );
123
+ } );
124
+
125
+ it( 'preserves several unclosed nested tags with different names', () => {
126
+ expect( blocks( '<user><article>Use <title>Example.</user>' ) ).toEqual( [
127
+ { tagName: 'user', content: '<article>Use <title>Example.' }
128
+ ] );
129
+ } );
130
+
131
+ it( 'preserves a nested tag matching another supported role', () => {
132
+ expect( blocks( '<user>Describe the <system> tag.</user>' ) ).toEqual( [
133
+ { tagName: 'user', content: 'Describe the <system> tag.' }
134
+ ] );
135
+ } );
136
+
137
+ it( 'preserves angle-bracket TypeScript syntax', () => {
138
+ expect( blocks( '<user>Explain Array<string> and Promise<Result>.</user>' ) ).toEqual( [
139
+ { tagName: 'user', content: 'Explain Array<string> and Promise<Result>.' }
140
+ ] );
141
+ } );
142
+
143
+ it( 'preserves unmatched angle brackets in text', () => {
144
+ expect( blocks( '<user>Use a<b, b>c, c < d and d > e.</user>' ) ).toEqual( [
145
+ { tagName: 'user', content: 'Use a<b, b>c, c < d and d > e.' }
146
+ ] );
147
+ } );
148
+
149
+ it( 'preserves a different unmatched closing tag', () => {
150
+ expect( blocks( '<user>Show </title> literally.</user>' ) ).toEqual( [
151
+ { tagName: 'user', content: 'Show </title> literally.' }
152
+ ] );
153
+ } );
154
+
155
+ it( 'preserves comments inside a block', () => {
156
+ expect( blocks( '<user>Before<!-- hidden > note -->After</user>' ) ).toEqual( [
157
+ { tagName: 'user', content: 'Before<!-- hidden > note -->After' }
158
+ ] );
159
+ } );
160
+
161
+ it( 'preserves multiline comments inside a block', () => {
162
+ expect( blocks( '<user>Before<!-- hidden\nnote -->After</user>' ) ).toEqual( [
163
+ { tagName: 'user', content: 'Before<!-- hidden\nnote -->After' }
164
+ ] );
165
+ } );
166
+
167
+ it( 'preserves a closing-tag-shaped value inside a comment', () => {
168
+ expect( blocks( '<user>Before<!-- </user> -->After</user>' ) ).toEqual( [
169
+ { tagName: 'user', content: 'Before<!-- </user> -->After' }
170
+ ] );
171
+ } );
172
+
173
+ it( 'preserves empty comments inside a block', () => {
174
+ expect( blocks( '<user>Before<!---->After</user>' ) ).toEqual( [
175
+ { tagName: 'user', content: 'Before<!---->After' }
176
+ ] );
177
+ } );
178
+
179
+ it( 'preserves whitespace, line endings, and Unicode content exactly', () => {
180
+ expect( blocks( '<user>\r\n Olá 👋\t \r\n</user>' ) ).toEqual( [
181
+ { tagName: 'user', content: '\r\n Olá 👋\t \r\n' }
182
+ ] );
183
+ } );
184
+
185
+ it( 'preserves self-closing tags inside a block', () => {
186
+ expect( blocks( '<user>Before<image source="avatar.png" />After</user>' ) ).toEqual( [
187
+ { tagName: 'user', content: 'Before<image source="avatar.png" />After' }
188
+ ] );
189
+ } );
190
+
191
+ it( 'preserves a self-closing tag matching the block name', () => {
192
+ expect( blocks( '<user>Write <user /> literally.</user>' ) ).toEqual( [
193
+ { tagName: 'user', content: 'Write <user /> literally.' }
194
+ ] );
195
+ } );
196
+
197
+ it( 'allows escaped same-name tags as text', () => {
198
+ expect( blocks( '<user>Use &lt;user&gt; and &lt;/user&gt;.</user>' ) ).toEqual( [
199
+ { tagName: 'user', content: 'Use &lt;user&gt; and &lt;/user&gt;.' }
200
+ ] );
201
+ } );
202
+
203
+ } );
204
+
205
+ describe( 'attributes', () => {
206
+ it( 'parses quoted, unquoted, and boolean attributes', () => {
207
+ const [ node ] = parseMarkup( '<user options="cached" mode=fast pinned>Hello</user>' );
208
+
209
+ expect( node.attributes ).toEqual( {
210
+ options: 'cached',
211
+ mode: 'fast',
212
+ pinned: true
213
+ } );
214
+ } );
215
+
216
+ it( 'supports whitespace around attribute equals signs', () => {
217
+ const [ node ] = parseMarkup( '<user options = "cached shared" mode = fast pinned>Hello</user>' );
218
+
219
+ expect( node.attributes ).toEqual( {
220
+ options: 'cached shared',
221
+ mode: 'fast',
222
+ pinned: true
223
+ } );
224
+ } );
225
+
226
+ it( 'lowercases attribute names and preserves values as strings', () => {
227
+ const [ node ] = parseMarkup( '<user ENABLED=false COUNT=001 RATIO=1.25>Hello</user>' );
228
+
229
+ expect( node.attributes ).toEqual( {
230
+ enabled: 'false',
231
+ count: '001',
232
+ ratio: '1.25'
233
+ } );
234
+ } );
235
+
236
+ it( 'parses empty explicit attribute values', () => {
237
+ const [ node ] = parseMarkup( '<user first="" second=\'\' third=>Hello</user>' );
238
+
239
+ expect( node.attributes ).toEqual( {
240
+ first: '',
241
+ second: '',
242
+ third: ''
243
+ } );
244
+ } );
245
+
246
+ it( 'allows the opposite quote type inside quoted values', () => {
247
+ const [ node ] = parseMarkup( '<user single="it\'s ready" double=\'say "ready"\'>Hello</user>' );
248
+
249
+ expect( node.attributes ).toEqual( {
250
+ single: 'it\'s ready',
251
+ double: 'say "ready"'
252
+ } );
253
+ } );
254
+
255
+ it( 'allows greater-than characters inside double-quoted attributes', () => {
256
+ const [ node ] = parseMarkup( '<user condition="score > 10">Hello</user>' );
257
+
258
+ expect( node.attributes ).toEqual( { condition: 'score > 10' } );
259
+ expect( node.content ).toBe( 'Hello' );
260
+ } );
261
+
262
+ it( 'allows greater-than characters inside single-quoted attributes', () => {
263
+ const [ node ] = parseMarkup( '<user condition=\'score > 10\'>Hello</user>' );
264
+
265
+ expect( node.attributes ).toEqual( { condition: 'score > 10' } );
266
+ expect( node.content ).toBe( 'Hello' );
267
+ } );
268
+
269
+ it( 'rejects on single-quoted attributes missing end quote', () => {
270
+ expect( () => parseMarkup( '<user color=\'red>Hello</user>' ) ).toThrow();
271
+ } );
272
+
273
+ it( 'rejects on double-quoted attributes missing end quote', () => {
274
+ expect( () => parseMarkup( '<user color="red>Hello</user>' ) ).toThrow();
275
+ } );
276
+
277
+ it( 'rejects attributes closed by a different quote type', () => {
278
+ expect( () => parseMarkup( '<user color=\'red">Hello</user>' ) ).toThrow();
279
+ expect( () => parseMarkup( '<user color="red\'>Hello</user>' ) ).toThrow();
280
+ } );
281
+
282
+ it( 'rejects stray quotes in unquoted attribute values', () => {
283
+ expect( () => parseMarkup( '<user color=red\'>Hello</user>' ) ).toThrow();
284
+ expect( () => parseMarkup( '<user color=red">Hello</user>' ) ).toThrow();
285
+ } );
286
+ } );
287
+
288
+ describe( 'invalid markup', () => {
289
+ it( 'rejects a closed nested tag with the same name', () => {
290
+ expect( () => parseMarkup( '<user>Outer <user>Inner</user> tail</user>' ) )
291
+ .toThrow( /^Invalid child tag: <user> cannot appear inside another <user>\. Escape it as "&lt;user&gt;"\.$/ );
292
+ } );
293
+
294
+ it( 'rejects an open nested tag with the same name', () => {
295
+ expect( () => parseMarkup( '<user>Use the <user> tag.</user>' ) ).toThrow();
296
+ } );
297
+
298
+ it( 'rejects a same-name nested tag with different casing', () => {
299
+ expect( () => parseMarkup( '<User>Use the <user> tag.</USER>' ) ).toThrow();
300
+ } );
301
+
302
+ it( 'rejects a missing top-level closing tag', () => {
303
+ expect( () => parseMarkup( '<user>Hello' ) )
304
+ .toThrow( /^Invalid tag: <user> is missing its closing tag\.$/ );
305
+ } );
306
+
307
+ it( 'rejects a mismatched top-level closing tag', () => {
308
+ expect( () => parseMarkup( '<user>Hello</system>' ) ).toThrow();
309
+ } );
310
+
311
+ it( 'rejects an unmatched closing tag outside a block', () => {
312
+ expect( () => parseMarkup( '</user>' ) )
313
+ .toThrow( /^Invalid root tag: closing tag "<\/user>" has no matching opening tag\.$/ );
314
+ } );
315
+
316
+ it( 'treats non-whitespace text before a block as instruction mode', () => {
317
+ expect( parseMarkup( 'Prefix <user>Hello</user>' ) ).toEqual( [] );
318
+ } );
319
+
320
+ it( 'rejects non-whitespace text between blocks', () => {
321
+ expect( () => parseMarkup( '<system>Rules</system> stray <user>Hello</user>' ) ).toThrow();
322
+ } );
323
+
324
+ it( 'rejects non-whitespace text after a block', () => {
325
+ expect( () => parseMarkup( '<user>Hello</user> suffix' ) ).toThrow();
326
+ } );
327
+
328
+ it( 'rejects root text immediately after markup mode starts', () => {
329
+ expect( () => parseMarkup( '<system>Rules</system> stray <user>Hello' ) )
330
+ .toThrow( /^Invalid root text: text is not allowed outside message tags\.$/ );
331
+ } );
332
+ } );
333
+ } );
@@ -0,0 +1,3 @@
1
+ const tokenizer = /<!--[\s\S]*?-->|<\/\s*[A-Za-z][\w-]*\s*>|<[A-Za-z][\w-]*(?:"[^"]*"|'[^']*'|[^'"<>])*>|<[A-Za-z][\w-]*[^<>]*>|[^<]+|</g;
2
+
3
+ export const tokenize = value => value.match( tokenizer );
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { tokenize } from './tokenizer.js';
3
+
4
+ describe( 'tokenize', () => {
5
+ it( 'returns null for empty input', () => {
6
+ expect( tokenize( '' ) ).toBeNull();
7
+ } );
8
+
9
+ it( 'returns plain text as one token', () => {
10
+ expect( tokenize( 'plain text' ) ).toEqual( [ 'plain text' ] );
11
+ } );
12
+
13
+ it( 'separates opening tags, content, and closing tags', () => {
14
+ expect( tokenize( '<user>Hello</user>' ) ).toEqual( [
15
+ '<user>',
16
+ 'Hello',
17
+ '</user>'
18
+ ] );
19
+ } );
20
+
21
+ it( 'tokenizes multiple sibling blocks and their whitespace', () => {
22
+ expect( tokenize( '<system>Rules</system>\n\n<user>Question</user>' ) ).toEqual( [
23
+ '<system>',
24
+ 'Rules',
25
+ '</system>',
26
+ '\n\n',
27
+ '<user>',
28
+ 'Question',
29
+ '</user>'
30
+ ] );
31
+ } );
32
+
33
+ it( 'keeps opening tags with attributes intact', () => {
34
+ expect( tokenize( '<user options="cached shared" mode=fast>Hello</user>' ) ).toEqual( [
35
+ '<user options="cached shared" mode=fast>',
36
+ 'Hello',
37
+ '</user>'
38
+ ] );
39
+ } );
40
+
41
+ it( 'keeps greater-than characters inside quoted attributes', () => {
42
+ expect( tokenize( '<user condition="score > 10">Hello</user>' ) ).toEqual( [
43
+ '<user condition="score > 10">',
44
+ 'Hello',
45
+ '</user>'
46
+ ] );
47
+ } );
48
+
49
+ it( 'keeps self-closing tags intact', () => {
50
+ expect( tokenize( '<user>Before<image source="avatar.png" />After</user>' ) ).toEqual( [
51
+ '<user>',
52
+ 'Before',
53
+ '<image source="avatar.png" />',
54
+ 'After',
55
+ '</user>'
56
+ ] );
57
+ } );
58
+
59
+ it( 'supports whitespace in closing tags', () => {
60
+ expect( tokenize( '<user>Hello</ user >' ) ).toEqual( [
61
+ '<user>',
62
+ 'Hello',
63
+ '</ user >'
64
+ ] );
65
+ } );
66
+
67
+ it( 'keeps single-line comments intact', () => {
68
+ expect( tokenize( '<!-- note --><user>Hello</user>' ) ).toEqual( [
69
+ '<!-- note -->',
70
+ '<user>',
71
+ 'Hello',
72
+ '</user>'
73
+ ] );
74
+ } );
75
+
76
+ it( 'keeps multiline comments intact', () => {
77
+ expect( tokenize( '<!-- first\nsecond --><user>Hello</user>' ) ).toEqual( [
78
+ '<!-- first\nsecond -->',
79
+ '<user>',
80
+ 'Hello',
81
+ '</user>'
82
+ ] );
83
+ } );
84
+
85
+ it( 'tokenizes adjacent comments separately', () => {
86
+ expect( tokenize( '<!-- first --><!-- second -->' ) ).toEqual( [
87
+ '<!-- first -->',
88
+ '<!-- second -->'
89
+ ] );
90
+ } );
91
+
92
+ it( 'preserves a lone less-than character', () => {
93
+ expect( tokenize( '<' ) ).toEqual( [ '<' ] );
94
+ } );
95
+
96
+ it( 'preserves an unterminated comment losslessly', () => {
97
+ const input = '<!-- unfinished';
98
+ const tokens = tokenize( input );
99
+
100
+ expect( tokens ).toEqual( [ '<', '!-- unfinished' ] );
101
+ expect( tokens.join( '' ) ).toBe( input );
102
+ } );
103
+
104
+ it( 'keeps a tag with an unterminated attribute quote intact for later validation', () => {
105
+ const input = '<user color=\'red>Hello</user>';
106
+
107
+ expect( tokenize( input ) ).toEqual( [
108
+ '<user color=\'red>',
109
+ 'Hello',
110
+ '</user>'
111
+ ] );
112
+ } );
113
+
114
+ it( 'preserves TypeScript angle-bracket syntax losslessly', () => {
115
+ const input = 'Explain Array<string> and Promise<Result>.';
116
+
117
+ expect( tokenize( input ).join( '' ) ).toBe( input );
118
+ } );
119
+
120
+ it( 'preserves comparison operators and tag-shaped text losslessly', () => {
121
+ const input = 'Use 1<2, a<b, b>c, c < d, and d > e.';
122
+
123
+ expect( tokenize( input ).join( '' ) ).toBe( input );
124
+ } );
125
+
126
+ it( 'preserves every character in mixed markup', () => {
127
+ const input = '<!-- note -->\n<user condition="a > b">Use x<y and <title>.</user>';
128
+
129
+ expect( tokenize( input ).join( '' ) ).toBe( input );
130
+ } );
131
+
132
+ it.each( [
133
+ '',
134
+ '<',
135
+ '<<<user>',
136
+ '<!-- first --><!-- second -->',
137
+ '<!-- unfinished',
138
+ '<user>Hello</user>',
139
+ '<user />',
140
+ '<user condition="score > 10">Hello</user>',
141
+ '<user color=\'red>Hello</user>',
142
+ '<user color=\'red">Hello</user>',
143
+ '<b,b>',
144
+ '<user>Use 1<2, a<b, and b>c.</user>',
145
+ '<user>\r\nUnicode: Olá 👋\r\n</user>'
146
+ ] )( 'is lossless for %s', input => {
147
+ expect( ( tokenize( input ) ?? [] ).join( '' ) ).toBe( input );
148
+ } );
149
+ } );