@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.
- package/package.json +2 -2
- package/src/agent.js +105 -83
- package/src/agent.spec.js +312 -225
- package/src/ai_provider.js +2 -2
- package/src/ai_sdk_options.js +93 -39
- package/src/ai_sdk_options.spec.js +314 -154
- package/src/consts.js +6 -0
- package/src/generate.js +81 -0
- package/src/generate.spec.js +466 -0
- package/src/index.d.ts +222 -242
- package/src/index.js +3 -4
- package/src/prompt/content.js +70 -0
- package/src/prompt/content.spec.js +208 -0
- package/src/prompt/{escape.js → interpolations.js} +11 -18
- package/src/prompt/interpolations.spec.js +109 -0
- package/src/prompt/loader.full.spec.js +293 -0
- package/src/prompt/loader.js +45 -39
- package/src/prompt/loader.spec.js +186 -289
- package/src/prompt/markup/attributes.js +52 -0
- package/src/prompt/markup/attributes.spec.js +132 -0
- package/src/prompt/markup/nodes.js +71 -0
- package/src/prompt/markup/nodes.spec.js +333 -0
- package/src/prompt/markup/tokenizer.js +3 -0
- package/src/prompt/markup/tokenizer.spec.js +149 -0
- package/src/prompt/markup/tokens.js +26 -0
- package/src/prompt/markup/tokens.spec.js +250 -0
- package/src/prompt/validations.js +98 -67
- package/src/prompt/validations.spec.js +204 -47
- package/src/{prompt/load_content.js → utils/file.js} +12 -15
- package/src/utils/file.spec.js +89 -0
- package/src/utils/models.js +15 -0
- package/src/utils/models.spec.js +119 -0
- package/src/utils/skills.js +74 -0
- package/src/utils/skills.spec.js +168 -0
- package/src/utils/sources.js +48 -0
- package/src/utils/sources.spec.js +122 -0
- package/src/utils/stream.js +22 -0
- package/src/utils/stream.spec.js +55 -0
- package/src/utils/tools.js +47 -0
- package/src/utils/tools.spec.js +167 -0
- package/src/utils/wrap.js +148 -0
- package/src/utils/wrap.spec.js +359 -0
- package/src/validations.js +149 -27
- package/src/validations.spec.js +451 -53
- package/src/ai_model.js +0 -60
- package/src/ai_model.spec.js +0 -259
- package/src/ai_sdk.js +0 -92
- package/src/ai_sdk.spec.js +0 -564
- package/src/prompt/block_options.js +0 -58
- package/src/prompt/block_options.spec.js +0 -71
- package/src/prompt/blocks.js +0 -47
- package/src/prompt/blocks.spec.js +0 -63
- package/src/prompt/escape.spec.js +0 -159
- package/src/prompt/load_content.spec.js +0 -83
- package/src/prompt/loader_validation.spec.js +0 -128
- package/src/prompt/parser.js +0 -16
- package/src/prompt/parser.spec.js +0 -186
- package/src/prompt/prepare_text.js +0 -27
- package/src/prompt/prepare_text.spec.js +0 -141
- package/src/prompt/skill.js +0 -128
- package/src/prompt/skill.spec.js +0 -172
- package/src/utils/message.js +0 -3
- package/src/utils/message.spec.js +0 -29
- package/src/utils/response_wrappers.js +0 -100
- package/src/utils/response_wrappers.spec.js +0 -240
- package/src/utils/source_extraction.js +0 -53
- package/src/utils/source_extraction.spec.js +0 -194
- package/src/utils/trace.js +0 -19
- package/src/utils/trace.spec.js +0 -112
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Role } from '../consts.js';
|
|
2
|
+
import { decode } from './interpolations.js';
|
|
3
|
+
import { FatalError } from '@outputai/core';
|
|
4
|
+
import { parseMarkup } from './markup/nodes.js';
|
|
5
|
+
|
|
6
|
+
/** Merge two objects 2-level deep. */
|
|
7
|
+
const mergeOptions = ( base = {}, extra = {} ) => {
|
|
8
|
+
const merged = { ...base };
|
|
9
|
+
for ( const [ namespace, options ] of Object.entries( extra ) ) {
|
|
10
|
+
merged[namespace] = { ...merged[namespace], ...options };
|
|
11
|
+
}
|
|
12
|
+
return merged;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Gets the options attribute from the message attributes */
|
|
16
|
+
const extractOptions = attributes => {
|
|
17
|
+
const { options, ...unsupportedAttributes } = attributes;
|
|
18
|
+
if ( Object.keys( unsupportedAttributes ).length > 0 ) {
|
|
19
|
+
throw new FatalError( 'Message has unsupported attributes. The only supported attribute is "options".' );
|
|
20
|
+
}
|
|
21
|
+
if ( options === true ) {
|
|
22
|
+
throw new FatalError( 'Message "options" attribute must have a value.' );
|
|
23
|
+
}
|
|
24
|
+
const rawOptions = options?.trim();
|
|
25
|
+
return rawOptions ? rawOptions.split( /\s+/ ) : null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Merge down messageOptions selected from a list */
|
|
29
|
+
const resolveProviderOptions = ( options, messageOptions ) =>
|
|
30
|
+
options.reduce( ( result, option ) => {
|
|
31
|
+
if ( !messageOptions[option] ) {
|
|
32
|
+
throw new FatalError( `Message "options" attribute references unknown option "${option}" in \`config.messageOptions\`.` );
|
|
33
|
+
}
|
|
34
|
+
return mergeOptions( result, messageOptions[option] );
|
|
35
|
+
}, {} );
|
|
36
|
+
|
|
37
|
+
const promptRoleSet = new Set( [ Role.SYSTEM, Role.USER, Role.ASSISTANT ] );
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract messages or instructions from prompt content. A leading role tag yields
|
|
41
|
+
* messages and null instructions; otherwise instructions are the decoded body.
|
|
42
|
+
*/
|
|
43
|
+
export const parseContent = ( content, messageOptions ) => {
|
|
44
|
+
const nodes = parseMarkup( content );
|
|
45
|
+
|
|
46
|
+
if ( nodes.length === 0 ) {
|
|
47
|
+
return { messages: [], instructions: decode( content.trim() ) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const invalidRole = nodes.find( node => !promptRoleSet.has( node.tagName ) );
|
|
51
|
+
if ( invalidRole ) {
|
|
52
|
+
throw new FatalError( `Message has invalid role "${invalidRole.tagName}".` );
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
messages: nodes.map( node => {
|
|
57
|
+
const options = extractOptions( node.attributes );
|
|
58
|
+
if ( options && Object.keys( messageOptions ?? {} ).length === 0 ) {
|
|
59
|
+
throw new FatalError( 'Message has "options" attribute but `config.messageOptions` is empty.' );
|
|
60
|
+
}
|
|
61
|
+
const providerOptions = options ? resolveProviderOptions( options, messageOptions ) : null;
|
|
62
|
+
return {
|
|
63
|
+
role: node.tagName.toLowerCase(),
|
|
64
|
+
content: decode( node.content.trim() ),
|
|
65
|
+
...( providerOptions && { providerOptions } )
|
|
66
|
+
};
|
|
67
|
+
} ),
|
|
68
|
+
instructions: null
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FatalError } from '@outputai/core';
|
|
3
|
+
import { Role } from '../consts.js';
|
|
4
|
+
import { parseContent } from './content.js';
|
|
5
|
+
|
|
6
|
+
const cached = { anthropic: { cacheControl: { type: 'ephemeral' } } };
|
|
7
|
+
const cachedLong = { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } };
|
|
8
|
+
const thinking = { anthropic: { thinking: { type: 'enabled' } } };
|
|
9
|
+
const openaiKey = { openai: { promptCacheKey: 'enrich-v1' } };
|
|
10
|
+
|
|
11
|
+
describe( 'parseContent', () => {
|
|
12
|
+
describe( 'messages and instructions', () => {
|
|
13
|
+
it( 'returns messages and null instructions when role tags are present', () => {
|
|
14
|
+
expect( parseContent( '<system>Hi</system>\n<user>Yo</user>' ) ).toEqual( {
|
|
15
|
+
messages: [
|
|
16
|
+
{ role: Role.SYSTEM, content: 'Hi' },
|
|
17
|
+
{ role: Role.USER, content: 'Yo' }
|
|
18
|
+
],
|
|
19
|
+
instructions: null
|
|
20
|
+
} );
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
it( 'treats content that starts with text as instructions', () => {
|
|
24
|
+
expect( parseContent( 'Note\n<user>Yo</user>\nTail' ) ).toEqual( {
|
|
25
|
+
messages: [],
|
|
26
|
+
instructions: 'Note\n<user>Yo</user>\nTail'
|
|
27
|
+
} );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
it( 'returns instructions and no messages when there are no role tags', () => {
|
|
31
|
+
expect( parseContent( 'Generate a cinematic poster.' ) ).toEqual( {
|
|
32
|
+
messages: [],
|
|
33
|
+
instructions: 'Generate a cinematic poster.'
|
|
34
|
+
} );
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
it( 'trims instructions', () => {
|
|
38
|
+
expect( parseContent( ' poster ' ).instructions ).toBe( 'poster' );
|
|
39
|
+
} );
|
|
40
|
+
|
|
41
|
+
it( 'extracts every authored prompt role', () => {
|
|
42
|
+
const roles = [ Role.SYSTEM, Role.USER, Role.ASSISTANT ];
|
|
43
|
+
const body = roles.map( role => `<${role}>${role} body</${role}>` ).join( '\n' );
|
|
44
|
+
|
|
45
|
+
expect( parseContent( body ).messages.map( message => message.role ) ).toEqual( roles );
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'omits providerOptions when the tag has no options attribute', () => {
|
|
49
|
+
expect( parseContent( '<user>Hi</user>' ).messages[0] ).toEqual( { role: Role.USER, content: 'Hi' } );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
it( 'trims each message body', () => {
|
|
53
|
+
expect( parseContent( '<user>\n Hello \n</user>' ).messages[0].content ).toBe( 'Hello' );
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
it( 'decodes XML entities in message content', () => {
|
|
57
|
+
expect( parseContent( '<user>R&D < Speed</user>' ).messages[0].content ).toBe( 'R&D < Speed' );
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
it( 'keeps a different nested role tag inside the top-level message', () => {
|
|
61
|
+
const body = [
|
|
62
|
+
'<user>',
|
|
63
|
+
'Here:',
|
|
64
|
+
'```',
|
|
65
|
+
'<system>sys</system>',
|
|
66
|
+
'```',
|
|
67
|
+
'</user>'
|
|
68
|
+
].join( '\n' );
|
|
69
|
+
|
|
70
|
+
expect( parseContent( body ) ).toEqual( {
|
|
71
|
+
messages: [ {
|
|
72
|
+
role: Role.USER,
|
|
73
|
+
content: 'Here:\n```\n<system>sys</system>\n```'
|
|
74
|
+
} ],
|
|
75
|
+
instructions: null
|
|
76
|
+
} );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
it( 'rejects a nested tag with the same role', () => {
|
|
80
|
+
expect( () => parseContent( '<user>ass<user>asasas</user></user>' ) )
|
|
81
|
+
.toThrow( 'Invalid child tag: <user> cannot appear inside another <user>. Escape it as "<user>".' );
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
it( 'rejects an invalid top-level role', () => {
|
|
85
|
+
expect( () => parseContent( '<div><user>Hi</user></div>' ) )
|
|
86
|
+
.toThrow( 'Message has invalid role "div".' );
|
|
87
|
+
expect( () => parseContent( '<tool>Search result</tool>' ) )
|
|
88
|
+
.toThrow( 'Message has invalid role "tool".' );
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
it( 'keeps closed inner markup in the message body', () => {
|
|
92
|
+
expect( parseContent( '<user>Compare <div></div> and <span></span> tags</user>' ).messages[0].content )
|
|
93
|
+
.toBe( 'Compare <div></div> and <span></span> tags' );
|
|
94
|
+
} );
|
|
95
|
+
} );
|
|
96
|
+
|
|
97
|
+
describe( 'options attribute', () => {
|
|
98
|
+
it( 'resolves a referenced messageOptions set into providerOptions', () => {
|
|
99
|
+
expect( parseContent( '<system options="cached">Docs</system>\n<user>Hello</user>', { cached } ) ).toEqual( {
|
|
100
|
+
messages: [
|
|
101
|
+
{ role: Role.SYSTEM, content: 'Docs', providerOptions: cached },
|
|
102
|
+
{ role: Role.USER, content: 'Hello' }
|
|
103
|
+
],
|
|
104
|
+
instructions: null
|
|
105
|
+
} );
|
|
106
|
+
} );
|
|
107
|
+
|
|
108
|
+
it( 'does not put attributes on the message', () => {
|
|
109
|
+
const [ system ] = parseContent( '<system options="cached">Docs</system>', { cached } ).messages;
|
|
110
|
+
expect( system ).not.toHaveProperty( 'attributes' );
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
it( 'lowercases the options attribute name', () => {
|
|
114
|
+
const [ system ] = parseContent( '<system OPTIONS="cached">Docs</system>', { cached } ).messages;
|
|
115
|
+
expect( system.providerOptions ).toEqual( cached );
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
it( 'splits multiple set names on whitespace', () => {
|
|
119
|
+
const [ system ] = parseContent(
|
|
120
|
+
'<system options="cached openaiKey">Docs</system>',
|
|
121
|
+
{ cached: cachedLong, openaiKey }
|
|
122
|
+
).messages;
|
|
123
|
+
|
|
124
|
+
expect( system.providerOptions ).toEqual( { ...cachedLong, ...openaiKey } );
|
|
125
|
+
} );
|
|
126
|
+
|
|
127
|
+
it( 'trims extra whitespace around set names', () => {
|
|
128
|
+
const [ system ] = parseContent( '<system options=" cached ">Docs</system>', { cached } ).messages;
|
|
129
|
+
expect( system.providerOptions ).toEqual( cached );
|
|
130
|
+
} );
|
|
131
|
+
|
|
132
|
+
it( 'treats blank or whitespace-only options as no options', () => {
|
|
133
|
+
expect( parseContent( '<system options=" ">Docs</system>' ).messages ).toEqual( [
|
|
134
|
+
{ role: Role.SYSTEM, content: 'Docs' }
|
|
135
|
+
] );
|
|
136
|
+
expect( parseContent( '<system options="">Docs</system>' ).messages ).toEqual( [
|
|
137
|
+
{ role: Role.SYSTEM, content: 'Docs' }
|
|
138
|
+
] );
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
it( 'throws when options has no value', () => {
|
|
142
|
+
expect( () => parseContent( '<system options>Docs</system>' ) )
|
|
143
|
+
.toThrow( new FatalError( 'Message "options" attribute must have a value.' ) );
|
|
144
|
+
} );
|
|
145
|
+
|
|
146
|
+
it( 'merges keys from two sets that share a provider namespace', () => {
|
|
147
|
+
const [ system ] = parseContent(
|
|
148
|
+
'<system options="cached thinking">Docs</system>',
|
|
149
|
+
{ cached, thinking }
|
|
150
|
+
).messages;
|
|
151
|
+
|
|
152
|
+
expect( system.providerOptions ).toEqual( {
|
|
153
|
+
anthropic: {
|
|
154
|
+
cacheControl: { type: 'ephemeral' },
|
|
155
|
+
thinking: { type: 'enabled' }
|
|
156
|
+
}
|
|
157
|
+
} );
|
|
158
|
+
} );
|
|
159
|
+
|
|
160
|
+
it( 'lets a later set win on overlapping keys in the same namespace', () => {
|
|
161
|
+
const [ system ] = parseContent(
|
|
162
|
+
'<system options="short long">Docs</system>',
|
|
163
|
+
{ short: cached, long: cachedLong }
|
|
164
|
+
).messages;
|
|
165
|
+
|
|
166
|
+
expect( system.providerOptions ).toEqual( cachedLong );
|
|
167
|
+
} );
|
|
168
|
+
|
|
169
|
+
it( 'replaces nested objects instead of deep-merging them', () => {
|
|
170
|
+
const [ system ] = parseContent(
|
|
171
|
+
'<system options="withTtl noTtl">Docs</system>',
|
|
172
|
+
{ withTtl: cachedLong, noTtl: cached }
|
|
173
|
+
).messages;
|
|
174
|
+
|
|
175
|
+
expect( system.providerOptions.anthropic.cacheControl ).toEqual( { type: 'ephemeral' } );
|
|
176
|
+
} );
|
|
177
|
+
|
|
178
|
+
it( 'applies the same set twice without changing the result', () => {
|
|
179
|
+
const once = parseContent( '<system options="cached">Docs</system>', { cached } );
|
|
180
|
+
const twice = parseContent( '<system options="cached cached">Docs</system>', { cached } );
|
|
181
|
+
|
|
182
|
+
expect( twice.messages[0].providerOptions ).toEqual( once.messages[0].providerOptions );
|
|
183
|
+
} );
|
|
184
|
+
|
|
185
|
+
it( 'throws when the tag has an unsupported attribute', () => {
|
|
186
|
+
expect( () => parseContent( '<system options="cached" ttl="1h">Docs</system>', { cached } ) )
|
|
187
|
+
.toThrow( FatalError );
|
|
188
|
+
expect( () => parseContent( '<system pinned>Docs</system>' ) ).toThrow( /unsupported attributes/ );
|
|
189
|
+
expect( () => parseContent( '<user data="x">Hi</user>' ) ).toThrow( /unsupported attributes/ );
|
|
190
|
+
} );
|
|
191
|
+
|
|
192
|
+
it( 'throws when options is set but messageOptions is missing or empty', () => {
|
|
193
|
+
expect( () => parseContent( '<system options="cached">Docs</system>' ) )
|
|
194
|
+
.toThrow( /config.messageOptions/ );
|
|
195
|
+
expect( () => parseContent( '<system options="cached">Docs</system>', {} ) )
|
|
196
|
+
.toThrow( /config.messageOptions/ );
|
|
197
|
+
} );
|
|
198
|
+
|
|
199
|
+
it( 'throws when a set name is not in messageOptions', () => {
|
|
200
|
+
expect( () => parseContent( '<user options="missing">Hello</user>', { cached } ) )
|
|
201
|
+
.toThrow( /unknown option "missing"/ );
|
|
202
|
+
} );
|
|
203
|
+
|
|
204
|
+
it( 'does not throw on missing messageOptions when no tag uses options', () => {
|
|
205
|
+
expect( () => parseContent( '<user>Hello</user>' ) ).not.toThrow();
|
|
206
|
+
} );
|
|
207
|
+
} );
|
|
208
|
+
} );
|
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
import { encodeXML, decodeXML } from 'entities';
|
|
2
1
|
import { Objects } from '@outputai/core/sdk/helpers';
|
|
2
|
+
import { encodeXML, decodeXML } from 'entities';
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
export const interpolationFilterToken = '__var_safe';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* XML-escapes a value.
|
|
8
8
|
* @param {unknown} value - Any value to escape
|
|
9
9
|
* @returns {string} Escaped value or '' if original value was null/undefined
|
|
10
10
|
*/
|
|
11
|
-
export const
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Sets up the encoder filter on a Liquid instance.
|
|
15
|
-
* @param {object} liquid - LiquidJS instance
|
|
16
|
-
*/
|
|
17
|
-
export const setupLiquidEncodeFilter = liquid => liquid.registerFilter( VAR_SAFE_FILTER, encodeFilter );
|
|
11
|
+
export const encode = value => [ null, undefined ].includes( value ) ? '' : encodeXML( String( value ) );
|
|
18
12
|
|
|
19
13
|
/**
|
|
20
14
|
* Matches {% raw %}...{% endraw %} or {{ ... }} tags
|
|
@@ -22,10 +16,9 @@ export const setupLiquidEncodeFilter = liquid => liquid.registerFilter( VAR_SAFE
|
|
|
22
16
|
const VAR_OR_RAW = /(\{%\s*raw\s*%\}[\s\S]*?\{%\s*endraw\s*%\})|\{\{\s*([\s\S]+?)\s*\}\}/g;
|
|
23
17
|
|
|
24
18
|
/**
|
|
25
|
-
*
|
|
19
|
+
* Appends `| __var_safe` filter to every `{{ ... }}` expression.
|
|
26
20
|
*
|
|
27
|
-
*
|
|
28
|
-
* by the filter registered on Liquid before parsePrompt tokenizes message blocks.
|
|
21
|
+
* The filter XML-escape the interpolated content so it cannot be parsed as new message blocks.
|
|
29
22
|
*
|
|
30
23
|
* Without this, a variable whose value contains `<system>` or `</user>` would inject extra
|
|
31
24
|
* message blocks.
|
|
@@ -36,16 +29,16 @@ const VAR_OR_RAW = /(\{%\s*raw\s*%\}[\s\S]*?\{%\s*endraw\s*%\})|\{\{\s*([\s\S]+?
|
|
|
36
29
|
* Note: The `g` flag advances past any matched raw regions, so `{{ ... }}` inside them are
|
|
37
30
|
* preserved as well.
|
|
38
31
|
*
|
|
39
|
-
* @param {string} raw - Raw string
|
|
40
|
-
* @returns {string}
|
|
32
|
+
* @param {string} raw - Raw template string
|
|
33
|
+
* @returns {string} Template with `| __var_safe` appended to each interpolation
|
|
41
34
|
*/
|
|
42
|
-
export const
|
|
35
|
+
export const pipeInterpolations = raw =>
|
|
43
36
|
raw.replace( VAR_OR_RAW, ( _, rawRegion, expressionContent ) =>
|
|
44
|
-
rawRegion === undefined ? `{{ ${expressionContent.trim()} | ${
|
|
37
|
+
rawRegion === undefined ? `{{ ${expressionContent.trim()} | ${interpolationFilterToken} }}` : rawRegion
|
|
45
38
|
);
|
|
46
39
|
|
|
47
40
|
/**
|
|
48
|
-
*
|
|
41
|
+
* Recursive decode a XML-encoded value.
|
|
49
42
|
* @param {unknown} value - Value to decode
|
|
50
43
|
* @returns {unknown} Decoded value
|
|
51
44
|
*/
|
|
@@ -58,7 +51,7 @@ export const decode = value => {
|
|
|
58
51
|
}
|
|
59
52
|
if ( Objects.isPlainObject( value ) ) {
|
|
60
53
|
return Object.fromEntries(
|
|
61
|
-
Object.entries( value ).map( ( [
|
|
54
|
+
Object.entries( value ).map( ( [ key, item ] ) => [ key, decode( item ) ] )
|
|
62
55
|
);
|
|
63
56
|
}
|
|
64
57
|
return value;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { decode, encode, interpolationFilterToken, pipeInterpolations } from './interpolations.js';
|
|
3
|
+
|
|
4
|
+
describe( 'encode', () => {
|
|
5
|
+
it( 'encodes XML special characters', () => {
|
|
6
|
+
expect( encode( '<a & b>' ) ).toBe( '<a & b>' );
|
|
7
|
+
} );
|
|
8
|
+
|
|
9
|
+
it( 'encodes a tag-shaped substring so extractMessages cannot match it', () => {
|
|
10
|
+
expect( encode( '<system>x</system>' ) ).toBe( '<system>x</system>' );
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
it( 'returns an empty string for null and undefined', () => {
|
|
14
|
+
expect( encode( null ) ).toBe( '' );
|
|
15
|
+
expect( encode( undefined ) ).toBe( '' );
|
|
16
|
+
} );
|
|
17
|
+
|
|
18
|
+
it( 'coerces numbers and booleans to string', () => {
|
|
19
|
+
expect( encode( 42 ) ).toBe( '42' );
|
|
20
|
+
expect( encode( true ) ).toBe( 'true' );
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
it( 'passes empty and plain strings through unchanged', () => {
|
|
24
|
+
expect( encode( '' ) ).toBe( '' );
|
|
25
|
+
expect( encode( 'hello world' ) ).toBe( 'hello world' );
|
|
26
|
+
} );
|
|
27
|
+
} );
|
|
28
|
+
|
|
29
|
+
describe( 'pipeInterpolations', () => {
|
|
30
|
+
const piped = expr => `{{ ${expr} | ${interpolationFilterToken} }}`;
|
|
31
|
+
|
|
32
|
+
it( 'appends the safety filter to a single interpolation', () => {
|
|
33
|
+
expect( pipeInterpolations( '{{ name }}' ) ).toBe( piped( 'name' ) );
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
it( 'appends the safety filter to every interpolation', () => {
|
|
37
|
+
expect( pipeInterpolations( '{{ a }} and {{ b }}' ) ).toBe(
|
|
38
|
+
`${piped( 'a' )} and ${piped( 'b' )}`
|
|
39
|
+
);
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
it( 'appends the safety filter last in an existing filter chain', () => {
|
|
43
|
+
expect( pipeInterpolations( '{{ x | upcase }}' ) ).toBe( piped( 'x | upcase' ) );
|
|
44
|
+
expect( pipeInterpolations( '{{ x | a | b }}' ) ).toBe( piped( 'x | a | b' ) );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'handles dotted property paths', () => {
|
|
48
|
+
expect( pipeInterpolations( '{{ obj.field }}' ) ).toBe( piped( 'obj.field' ) );
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
it( 'preserves a {% raw %} block even when it contains {{ ... }}', () => {
|
|
52
|
+
const input = '{% raw %}{{ literal }}{% endraw %}';
|
|
53
|
+
expect( pipeInterpolations( input ) ).toBe( input );
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
it( 'rewrites interpolations outside a raw block while preserving the raw block', () => {
|
|
57
|
+
expect( pipeInterpolations( '{{ a }}{% raw %}{{ b }}{% endraw %}{{ c }}' ) ).toBe(
|
|
58
|
+
`${piped( 'a' )}{% raw %}{{ b }}{% endraw %}${piped( 'c' )}`
|
|
59
|
+
);
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'leaves control tags untouched and still pipes interpolations inside them', () => {
|
|
63
|
+
expect( pipeInterpolations( '{% if cond %}{{ x }}{% endif %}' ) ).toBe(
|
|
64
|
+
`{% if cond %}${piped( 'x' )}{% endif %}`
|
|
65
|
+
);
|
|
66
|
+
expect( pipeInterpolations( '{% for x in xs %}{{ x }}{% endfor %}' ) ).toBe(
|
|
67
|
+
`{% for x in xs %}${piped( 'x' )}{% endfor %}`
|
|
68
|
+
);
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'normalizes interior whitespace', () => {
|
|
72
|
+
expect( pipeInterpolations( '{{x}}' ) ).toBe( piped( 'x' ) );
|
|
73
|
+
expect( pipeInterpolations( '{{ x }}' ) ).toBe( piped( 'x' ) );
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'returns the input unchanged when there are no interpolations', () => {
|
|
77
|
+
expect( pipeInterpolations( '<user>plain text</user>' ) ).toBe( '<user>plain text</user>' );
|
|
78
|
+
expect( pipeInterpolations( '' ) ).toBe( '' );
|
|
79
|
+
} );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
describe( 'decode', () => {
|
|
83
|
+
it( 'decodes XML entities in a string', () => {
|
|
84
|
+
expect( decode( 'R&D < Speed > "Limits"' ) ).toBe( 'R&D < Speed > "Limits"' );
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
it( 'decodes XML entities recursively in arrays and plain objects', () => {
|
|
88
|
+
expect( decode( {
|
|
89
|
+
label: 'R&D',
|
|
90
|
+
values: [ 'A < B' ],
|
|
91
|
+
nested: {
|
|
92
|
+
title: '"Race"'
|
|
93
|
+
}
|
|
94
|
+
} ) ).toEqual( {
|
|
95
|
+
label: 'R&D',
|
|
96
|
+
values: [ 'A < B' ],
|
|
97
|
+
nested: {
|
|
98
|
+
title: '"Race"'
|
|
99
|
+
}
|
|
100
|
+
} );
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
it( 'returns non-string scalar values unchanged', () => {
|
|
104
|
+
expect( decode( null ) ).toBeNull();
|
|
105
|
+
expect( decode( undefined ) ).toBeUndefined();
|
|
106
|
+
expect( decode( 42 ) ).toBe( 42 );
|
|
107
|
+
expect( decode( true ) ).toBe( true );
|
|
108
|
+
} );
|
|
109
|
+
} );
|