diff --git a/packages/insomnia/src/plugins/__mocks__/index.ts b/packages/insomnia/src/plugins/__mocks__/index.ts new file mode 100644 index 0000000000..094f39e5aa --- /dev/null +++ b/packages/insomnia/src/plugins/__mocks__/index.ts @@ -0,0 +1,3 @@ +import { vi } from 'vitest'; + +export const getTemplateTags = vi.fn(async () => []); diff --git a/packages/insomnia/src/templating/__tests__/liquid-compat.test.ts b/packages/insomnia/src/templating/__tests__/liquid-regression.test.ts similarity index 63% rename from packages/insomnia/src/templating/__tests__/liquid-compat.test.ts rename to packages/insomnia/src/templating/__tests__/liquid-regression.test.ts index a575f651d9..f9827b6c0d 100644 --- a/packages/insomnia/src/templating/__tests__/liquid-compat.test.ts +++ b/packages/insomnia/src/templating/__tests__/liquid-regression.test.ts @@ -1,6 +1,10 @@ -// Compatibility tests confirming LiquidJS renders templates that previously -// worked under Nunjucks. Run with: npm test -w insomnia -import { describe, expect, it } from 'vitest'; +// Regression tests confirming LiquidJS renders templates correctly — including +// templates that previously worked under Nunjucks and every supported block/tag +// construct. Security-specific tests live in liquid-security.test.ts. +// Run with: npm test -w insomnia +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('~/plugins'); import { render } from '../index'; @@ -131,6 +135,33 @@ describe('error handling', () => { it('ignoreUndefinedEnvVariable suppresses throw', async () => { expect(await render('{{ missing }}', { context: {}, ignoreUndefinedEnvVariable: true })).toBe(''); }); + + // Template-scoped variables (assign/for/capture) must NOT appear as missing env vars + it('does not report {% assign %} variables as undefined environment variables', async () => { + const err: any = await Promise.resolve(render('{% assign age = 25 %}{{ age }} {{ MISSING }}', { context: {} })).catch((e: any) => e); + expect(err.extraInfo?.undefinedEnvironmentVariables).toEqual(['MISSING']); + }); + + it('does not report {% for %} loop variables as undefined environment variables', async () => { + const err: any = await Promise.resolve(render( + '{% for i in (1..3) %}{{ i }}{% endfor %}{{ MISSING }}', + { context: {} }, + )).catch((e: any) => e); + expect(err.extraInfo?.undefinedEnvironmentVariables).toEqual(['MISSING']); + }); + + it('does not report {% capture %} variables as undefined environment variables', async () => { + const err: any = await Promise.resolve(render( + '{% capture greeting %}hello{% endcapture %}{{ greeting }} {{ MISSING }}', + { context: {} }, + )).catch((e: any) => e); + expect(err.extraInfo?.undefinedEnvironmentVariables).toEqual(['MISSING']); + }); + + it('deduplicates repeated occurrences of the same undefined variable', async () => { + const err: any = await Promise.resolve(render('{{ MISSING }} {{ MISSING }}', { context: {} })).catch((e: any) => e); + expect(err.extraInfo?.undefinedEnvironmentVariables).toEqual(['MISSING']); + }); }); describe('nunjucks breaking changes', () => { @@ -226,77 +257,6 @@ describe('edge cases', () => { }); }); -describe('prototype chain isolation', () => { - // ownPropertyOnly: true prevents traversal up the prototype chain from a context object. - // All four tests confirm that inherited properties are not reachable from templates. - - // constructor is on Object.prototype — must not be accessible from a template - it('cannot access constructor via template', async () => { - await expect(render('{{ constructor }}', { context: {} })).rejects.toBeDefined(); - }); - - // __proto__ access must throw, not silently resolve to the prototype object - it('cannot access __proto__ via template', async () => { - await expect(render('{{ __proto__ }}', { context: {} })).rejects.toBeDefined(); - }); - - // Dot traversal into a context object must not escape to its prototype - it('cannot traverse prototype through a context object', async () => { - await expect(render('{{ obj.constructor }}', { context: { obj: {} } })).rejects.toBeDefined(); - }); - - // toString lives on Object.prototype and must not be reachable via dot access - it('does not expose toString from prototype', async () => { - await expect(render('{{ obj.toString }}', { context: { obj: {} } })).rejects.toBeDefined(); - }); -}); - -describe('template injection isolation', () => { - // Values from context are rendered as literals — they are never re-evaluated as templates. - - // A context value containing {{ }} must be output as-is, not parsed as a template - it('context value containing {{ }} is not re-rendered', async () => { - const injected = '{{ secret }}'; - expect(await render('{{ input }}', { context: { input: injected, secret: 'LEAKED' } })).toBe(injected); - }); - - // Control flow syntax inside a value must also be treated as a plain string - it('control flow syntax in a value is not re-rendered', async () => { - expect(await render('{{ v }}', { context: { v: '{% if true %}yes{% endif %}' } })).toBe( - '{% if true %}yes{% endif %}', - ); - }); -}); - -describe('file-loading tags blocked', () => { - // include/render/layout load files from disk and are disabled; all access must - // go through the File template tag which routes through window.main.secureReadFile. - - // Variable path: attacker-controlled tpl value must not reach the filesystem - it('include with a variable path is blocked', async () => { - await expect( - render('{% include tpl %}', { context: { tpl: '/sensitive/secrets.txt' } }), - ).rejects.toThrow(/disabled/); - }); - - // Static path: even a hardcoded filename must be blocked at the tag level - it('include with a static literal path is blocked', async () => { - await expect( - render('{% include package.json %}', { context: {} }), - ).rejects.toThrow(/disabled/); - }); - - // render is a Liquid built-in for partial templates — blocked for the same reason as include - it('render tag is blocked', async () => { - await expect(render("{% render 'snippet' %}", { context: {} })).rejects.toThrow(/disabled/); - }); - - // layout loads a base template file from disk — same attack surface as include/render - it('layout tag is blocked', async () => { - await expect(render("{% layout 'base' %}", { context: {} })).rejects.toThrow(/disabled/); - }); -}); - describe('unless tag', () => { // unless is the inverse of if — body renders when condition is false it('renders body when condition is false', async () => { @@ -511,149 +471,3 @@ describe('capture and tablerow tags', () => { }); }); -// LiquidJS renders strings verbatim — it is not an HTML sanitizer. -// These tests document that responsibility: sanitization must happen at the DOM -// insertion site (React JSX {value} is safe; innerHTML is not). -describe('XSS: variable output passthrough', () => { - // Script tags in context values are passed through unchanged — no encoding applied - it('script tag value is rendered verbatim', async () => { - const payload = ''; - expect( - await render('{{ v }}', { context: { v: payload }, ignoreUndefinedEnvVariable: true }), - ).toBe(payload); - }); - - // SVG event handler attributes are also passed through unchanged - it('svg event handler value is rendered verbatim', async () => { - const payload = ''; - expect( - await render('{{ v }}', { context: { v: payload }, ignoreUndefinedEnvVariable: true }), - ).toBe(payload); - }); - - // HTML entities are not decoded — < stays <, never becomes < - it('html-encoded payload is not double-decoded', async () => { - const encoded = '<script>alert(1)</script>'; - expect( - await render('{{ v }}', { context: { v: encoded }, ignoreUndefinedEnvVariable: true }), - ).toBe(encoded); - }); -}); - -describe('XSS: filter chain passthrough', () => { - // Filters that manipulate strings can introduce angle brackets — output is still verbatim - it('replace filter can introduce angle brackets — output is verbatim', async () => { - const result = await render( - "{{ v | replace: 'OPEN', '' }}", - { context: { v: 'OPENalert(1)CLOSE' } }, - ); - expect(result).toBe(''); - }); - - // Case filters preserve HTML characters rather than stripping or encoding them - it('upcase/downcase do not strip or encode html', async () => { - const result = await render('{{ v | upcase }}', { context: { v: '' } }); - expect(result).toBe(''); - }); -}); - -describe('assign and capture: no re-evaluation', () => { - // Assigning a string that contains {{ }} stores it as a literal, not a template - it('assigned string containing {{ }} is treated as a literal', async () => { - const result = await render( - '{% assign evil = "{{ secret }}" %}{{ evil }}', - { context: { secret: 'LEAKED' }, ignoreUndefinedEnvVariable: true }, - ); - expect(result).toBe('{{ secret }}'); - }); - - // A captured block is rendered once at capture time; the stored string is output as-is - it('capture output is not re-rendered after storage', async () => { - const result = await render( - '{% capture block %}{{ secret }}{% endcapture %}{{ block }}', - { context: { secret: 'visible' } }, - ); - expect(result).toBe('visible'); - }); - - // HTML assembled by concatenating captures is verbatim — only dangerous with innerHTML - it('html assembled via capture is verbatim — dangerous only if used with innerHTML', async () => { - const result = await render( - '{% capture tag %}{% endcapture %}{{ tag }}alert(1){{ end }}', - { context: {} }, - ); - expect(result).toBe(''); - }); -}); - -describe('prototype pollution resistance', () => { - // Passing a context value must never modify Object.prototype - it('context key named __proto__ does not pollute Object prototype', async () => { - const before = ({} as any).polluted; - await Promise.resolve(render('{{ v }}', { context: { v: 'safe' }, ignoreUndefinedEnvVariable: true })).catch(() => {}); - expect(({} as any).polluted).toBe(before); - }); - - // Multi-level dot access into a prototype property must be blocked by ownPropertyOnly - it('deeply nested constructor access is blocked by ownPropertyOnly', async () => { - await expect(render('{{ obj.constructor.name }}', { context: { obj: {} } })).rejects.toBeDefined(); - }); - - // toString is inherited from Object.prototype and must not be reachable via dot notation - it('toString cannot be called via prototype traversal', async () => { - await expect(render('{{ obj.toString }}', { context: { obj: {} } })).rejects.toBeDefined(); - }); - - // hasOwnProperty is also an inherited method and must be blocked - it('hasOwnProperty is not reachable via template', async () => { - await expect(render('{{ obj.hasOwnProperty }}', { context: { obj: {} } })).rejects.toBeDefined(); - }); -}); - -describe('DoS resistance', () => { - // (1..11_000_000) exceeds the 10 MB memoryLimit tracked during range expansion - it('memoryLimit aborts enormous range expansions', async () => { - await expect( - render('{% for i in (1..11000000) %}{{ i }}{% endfor %}', { context: {} }), - ).rejects.toBeDefined(); - }); - - // 200 levels of nested if must parse and render without a stack overflow - it('deeply nested if blocks do not cause unbounded recursion', async () => { - const depth = 200; - const template = '{% if x %}'.repeat(depth) + 'deep' + '{% endif %}'.repeat(depth); - expect(await render(template, { context: { x: true } })).toBe('deep'); - }); - - // A 100-filter chain of no-ops must resolve in finite time without hanging - it('very long filter chain resolves without hanging', async () => { - const filters = Array.from({ length: 50 }, () => 'upcase | downcase').join(' | '); - expect(await render(`{{ v | ${filters} }}`, { context: { v: 'hello' } })).toBe('hello'); - }); -}); - -describe('unicode and special byte inputs', () => { - // Null bytes embedded in string values must be preserved, not stripped - it('null byte in a context value is preserved verbatim', async () => { - const nul = String.fromCodePoint(0); - expect(await render('{{ v }}', { context: { v: `before${nul}after` } })).toBe(`before${nul}after`); - }); - - // Zero-width joiners and non-joiners must pass through without being collapsed - it('zero-width characters pass through unchanged', async () => { - const zwsp = '​‌‍'; - expect(await render('{{ v }}', { context: { v: `hello${zwsp}world` } })).toBe(`hello${zwsp}world`); - }); - - // U+202E (right-to-left override) can make "U+202Etxt.exe" appear as "exe.txt" in some UIs; - // the engine must not strip it — callers are responsible for detecting it if needed. - it('right-to-left override character is not stripped', async () => { - const rtlo = String.fromCodePoint(8238); // U+202E RIGHT-TO-LEFT OVERRIDE - expect(await render('{{ v }}', { context: { v: `${rtlo}txt.exe` } })).toBe(`${rtlo}txt.exe`); - }); - - // Multi-byte emoji (surrogate pairs) must round-trip without corruption - it('emoji renders correctly', async () => { - expect(await render('{{ v }}', { context: { v: '🔥💧' } })).toBe('🔥💧'); - }); -}); diff --git a/packages/insomnia/src/templating/__tests__/liquid-security.test.ts b/packages/insomnia/src/templating/__tests__/liquid-security.test.ts new file mode 100644 index 0000000000..dbd17be444 --- /dev/null +++ b/packages/insomnia/src/templating/__tests__/liquid-security.test.ts @@ -0,0 +1,226 @@ +// Security tests for the LiquidJS render path: sandbox/prototype isolation, template +// injection, blocked file-loading tags, XSS passthrough expectations, prototype pollution, +// DoS limits, and special-byte handling. Run with: npm test -w insomnia +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('~/plugins'); + +import { render } from '../index'; + +describe('prototype chain isolation', () => { + // ownPropertyOnly: true prevents traversal up the prototype chain from a context object. + // All four tests confirm that inherited properties are not reachable from templates. + + // constructor is on Object.prototype — must not be accessible from a template + it('cannot access constructor via template', async () => { + await expect(render('{{ constructor }}', { context: {} })).rejects.toBeDefined(); + }); + + // __proto__ access must throw, not silently resolve to the prototype object + it('cannot access __proto__ via template', async () => { + await expect(render('{{ __proto__ }}', { context: {} })).rejects.toBeDefined(); + }); + + // Dot traversal into a context object must not escape to its prototype + it('cannot traverse prototype through a context object', async () => { + await expect(render('{{ obj.constructor }}', { context: { obj: {} } })).rejects.toBeDefined(); + }); + + // toString lives on Object.prototype and must not be reachable via dot access + it('does not expose toString from prototype', async () => { + await expect(render('{{ obj.toString }}', { context: { obj: {} } })).rejects.toBeDefined(); + }); +}); + +describe('template injection isolation', () => { + // Values from context are rendered as literals — they are never re-evaluated as templates. + + // A context value containing {{ }} must be output as-is, not parsed as a template + it('context value containing {{ }} is not re-rendered', async () => { + const injected = '{{ secret }}'; + expect(await render('{{ input }}', { context: { input: injected, secret: 'LEAKED' } })).toBe(injected); + }); + + // Control flow syntax inside a value must also be treated as a plain string + it('control flow syntax in a value is not re-rendered', async () => { + expect(await render('{{ v }}', { context: { v: '{% if true %}yes{% endif %}' } })).toBe( + '{% if true %}yes{% endif %}', + ); + }); +}); + +describe('file-loading tags blocked', () => { + // include/render/layout load files from disk and are disabled; all access must + // go through the File template tag which routes through window.main.secureReadFile. + + // Variable path: attacker-controlled tpl value must not reach the filesystem + it('include with a variable path is blocked', async () => { + await expect( + render('{% include tpl %}', { context: { tpl: '/sensitive/secrets.txt' } }), + ).rejects.toThrow(/disabled/); + }); + + // Static path: even a hardcoded filename must be blocked at the tag level + it('include with a static literal path is blocked', async () => { + await expect( + render('{% include package.json %}', { context: {} }), + ).rejects.toThrow(/disabled/); + }); + + // render is a Liquid built-in for partial templates — blocked for the same reason as include + it('render tag is blocked', async () => { + await expect(render("{% render 'snippet' %}", { context: {} })).rejects.toThrow(/disabled/); + }); + + // layout loads a base template file from disk — same attack surface as include/render + it('layout tag is blocked', async () => { + await expect(render("{% layout 'base' %}", { context: {} })).rejects.toThrow(/disabled/); + }); +}); + +// LiquidJS renders strings verbatim — it is not an HTML sanitizer. +// These tests document that responsibility: sanitization must happen at the DOM +// insertion site (React JSX {value} is safe; innerHTML is not). +describe('XSS: variable output passthrough', () => { + // Script tags in context values are passed through unchanged — no encoding applied + it('script tag value is rendered verbatim', async () => { + const payload = ''; + expect( + await render('{{ v }}', { context: { v: payload }, ignoreUndefinedEnvVariable: true }), + ).toBe(payload); + }); + + // SVG event handler attributes are also passed through unchanged + it('svg event handler value is rendered verbatim', async () => { + const payload = ''; + expect( + await render('{{ v }}', { context: { v: payload }, ignoreUndefinedEnvVariable: true }), + ).toBe(payload); + }); + + // HTML entities are not decoded — < stays <, never becomes < + it('html-encoded payload is not double-decoded', async () => { + const encoded = '<script>alert(1)</script>'; + expect( + await render('{{ v }}', { context: { v: encoded }, ignoreUndefinedEnvVariable: true }), + ).toBe(encoded); + }); +}); + +describe('XSS: filter chain passthrough', () => { + // Filters that manipulate strings can introduce angle brackets — output is still verbatim + it('replace filter can introduce angle brackets — output is verbatim', async () => { + const result = await render( + "{{ v | replace: 'OPEN', '' }}", + { context: { v: 'OPENalert(1)CLOSE' } }, + ); + expect(result).toBe(''); + }); + + // Case filters preserve HTML characters rather than stripping or encoding them + it('upcase/downcase do not strip or encode html', async () => { + const result = await render('{{ v | upcase }}', { context: { v: '' } }); + expect(result).toBe(''); + }); +}); + +describe('assign and capture: no re-evaluation', () => { + // Assigning a string that contains {{ }} stores it as a literal, not a template + it('assigned string containing {{ }} is treated as a literal', async () => { + const result = await render( + '{% assign evil = "{{ secret }}" %}{{ evil }}', + { context: { secret: 'LEAKED' }, ignoreUndefinedEnvVariable: true }, + ); + expect(result).toBe('{{ secret }}'); + }); + + // A captured block is rendered once at capture time; the stored string is output as-is + it('capture output is not re-rendered after storage', async () => { + const result = await render( + '{% capture block %}{{ secret }}{% endcapture %}{{ block }}', + { context: { secret: 'visible' } }, + ); + expect(result).toBe('visible'); + }); + + // HTML assembled by concatenating captures is verbatim — only dangerous with innerHTML + it('html assembled via capture is verbatim — dangerous only if used with innerHTML', async () => { + const result = await render( + '{% capture tag %}{% endcapture %}{{ tag }}alert(1){{ end }}', + { context: {} }, + ); + expect(result).toBe(''); + }); +}); + +describe('prototype pollution resistance', () => { + // Passing a context value must never modify Object.prototype + it('context key named __proto__ does not pollute Object prototype', async () => { + const before = ({} as any).polluted; + await Promise.resolve(render('{{ v }}', { context: { v: 'safe' }, ignoreUndefinedEnvVariable: true })).catch(() => {}); + expect(({} as any).polluted).toBe(before); + }); + + // Multi-level dot access into a prototype property must be blocked by ownPropertyOnly + it('deeply nested constructor access is blocked by ownPropertyOnly', async () => { + await expect(render('{{ obj.constructor.name }}', { context: { obj: {} } })).rejects.toBeDefined(); + }); + + // toString is inherited from Object.prototype and must not be reachable via dot notation + it('toString cannot be called via prototype traversal', async () => { + await expect(render('{{ obj.toString }}', { context: { obj: {} } })).rejects.toBeDefined(); + }); + + // hasOwnProperty is also an inherited method and must be blocked + it('hasOwnProperty is not reachable via template', async () => { + await expect(render('{{ obj.hasOwnProperty }}', { context: { obj: {} } })).rejects.toBeDefined(); + }); +}); + +describe('DoS resistance', () => { + // (1..11_000_000) exceeds the 10 MB memoryLimit tracked during range expansion + it('memoryLimit aborts enormous range expansions', async () => { + await expect( + render('{% for i in (1..11000000) %}{{ i }}{% endfor %}', { context: {} }), + ).rejects.toBeDefined(); + }); + + // 200 levels of nested if must parse and render without a stack overflow + it('deeply nested if blocks do not cause unbounded recursion', async () => { + const depth = 200; + const template = '{% if x %}'.repeat(depth) + 'deep' + '{% endif %}'.repeat(depth); + expect(await render(template, { context: { x: true } })).toBe('deep'); + }); + + // A 100-filter chain of no-ops must resolve in finite time without hanging + it('very long filter chain resolves without hanging', async () => { + const filters = Array.from({ length: 50 }, () => 'upcase | downcase').join(' | '); + expect(await render(`{{ v | ${filters} }}`, { context: { v: 'hello' } })).toBe('hello'); + }); +}); + +describe('unicode and special byte inputs', () => { + // Null bytes embedded in string values must be preserved, not stripped + it('null byte in a context value is preserved verbatim', async () => { + const nul = String.fromCodePoint(0); + expect(await render('{{ v }}', { context: { v: `before${nul}after` } })).toBe(`before${nul}after`); + }); + + // Zero-width joiners and non-joiners must pass through without being collapsed + it('zero-width characters pass through unchanged', async () => { + const zwsp = '​‌‍'; + expect(await render('{{ v }}', { context: { v: `hello${zwsp}world` } })).toBe(`hello${zwsp}world`); + }); + + // U+202E (right-to-left override) can make "U+202Etxt.exe" appear as "exe.txt" in some UIs; + // the engine must not strip it — callers are responsible for detecting it if needed. + it('right-to-left override character is not stripped', async () => { + const rtlo = String.fromCodePoint(8238); // U+202E RIGHT-TO-LEFT OVERRIDE + expect(await render('{{ v }}', { context: { v: `${rtlo}txt.exe` } })).toBe(`${rtlo}txt.exe`); + }); + + // Multi-byte emoji (surrogate pairs) must round-trip without corruption + it('emoji renders correctly', async () => { + expect(await render('{{ v }}', { context: { v: '🔥💧' } })).toBe('🔥💧'); + }); +}); diff --git a/packages/insomnia/src/templating/__tests__/liquid-user-scenarios.test.ts b/packages/insomnia/src/templating/__tests__/liquid-user-scenarios.test.ts new file mode 100644 index 0000000000..4a346452df --- /dev/null +++ b/packages/insomnia/src/templating/__tests__/liquid-user-scenarios.test.ts @@ -0,0 +1,197 @@ +// Regression tests for the exact templates users have pasted into the body editor while +// reporting parsing/rendering bugs. Each scenario asserts BOTH: +// 1. how the editor *interprets* the template (scanTemplateRegions / pairBlockTags / +// outermostBlockAt / tokenizeTag / fieldTagLabel), and +// 2. how the engine *renders* it (render()), +// so that a future change which fixes one scenario cannot silently break another. +// Whitespace-control output is compared with whitespace normalised, since LiquidJS's +// `{%- -%}` trimming makes byte-exact assertions brittle. +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('~/plugins'); + +import { + outermostBlockAt, + pairBlockTags, + scanTemplateRegions, +} from '~/ui/components/.client/codemirror/extensions/liquid-block-tags'; + +import { render } from '../index'; +import { fieldTagLabel, tokenizeTag } from '../utils'; + +const textOf = (template: string, region: { start: number; end: number }) => template.slice(region.start, region.end); +const noWhitespace = (s: string) => s.replace(/\s+/g, ''); + +describe('user scenario: multi-line {% liquid %} block with a blank line', () => { + const template = `{% liquid + assign product_name = "Coffee Mug" + assign stock_count = 15 + + if stock_count > 0 + echo product_name | append: " is available." + else + echo product_name | append: " is out of stock." + endif +%}`; + + it('is interpreted as a single self-contained tag region (not split by the blank line)', () => { + const regions = scanTemplateRegions(template); + expect(regions).toHaveLength(1); + expect(regions[0]).toEqual({ start: 0, end: template.length, kind: 'tag' }); + // `liquid` is not a paired block and is not a field tag. + expect(pairBlockTags(template)).toHaveLength(0); + expect(tokenizeTag(template).name).toBe('liquid'); + expect(fieldTagLabel(template)).toBeNull(); + }); + + it('renders the in-stock branch', async () => { + expect(await render(template, { context: {} })).toBe('Coffee Mug is available.'); + }); +}); + +describe('user scenario: {% for %} with whitespace-control {%- if/continue/else -%} delimiters', () => { + const template = `{% for i in (1..5) %} + {%- if i == 4 -%} + {%- continue -%} + {%- else -%} + {{ i }} + {%- endif -%} +{% endfor %}`; + + it('is interpreted as per-delimiter regions all grouped under the outermost for block', () => { + const regions = scanTemplateRegions(template); + expect(regions.map(r => r.kind)).toEqual(['tag', 'tag', 'tag', 'tag', 'variable', 'tag', 'tag']); + + const blocks = pairBlockTags(template); + // Two paired blocks: the outer for and the inner if. + expect(blocks).toHaveLength(2); + + // Clicking any inner construct (the {{ i }} variable, or the continue) resolves to the + // outermost (for) block — i.e. the whole statement is edited as one unit. + const innerVarIdx = template.indexOf('{{ i }}'); + const continueIdx = template.indexOf('{%- continue'); + expect(outermostBlockAt(blocks, innerVarIdx)?.start).toBe(0); + expect(outermostBlockAt(blocks, innerVarIdx)?.end).toBe(template.length); + expect(outermostBlockAt(blocks, continueIdx)?.start).toBe(0); + + // The whitespace-control delimiters tokenize to their real keyword names. + expect(tokenizeTag('{%- if i == 4 -%}').name).toBe('if'); + expect(tokenizeTag('{%- continue -%}').name).toBe('continue'); + expect(tokenizeTag('{%- endif -%}').name).toBe('endif'); + }); + + it('renders 1,2,3,5 and skips 4', async () => { + const out = (await render(template, { context: {} })) ?? ''; + expect(noWhitespace(out)).toBe('1235'); + }); +}); + +describe('user scenario: {% assign %} then {% echo … | append | capitalize %}', () => { + const template = `{% assign username = 'Bob' %} +{% echo username | append: ", welcome to LiquidJS!" | capitalize %}`; + + it('is interpreted as two single-line field tags labelled `name → variable`', () => { + const regions = scanTemplateRegions(template); + expect(regions.map(r => r.kind)).toEqual(['tag', 'tag']); + expect(fieldTagLabel(textOf(template, regions[0]))).toBe('assign → username'); + expect(fieldTagLabel(textOf(template, regions[1]))).toBe('echo → username'); + // Neither single tag is part of a paired block. + expect(pairBlockTags(template)).toHaveLength(0); + }); + + it('renders the appended, capitalized greeting', async () => { + const out = (await render(template, { context: {} })) ?? ''; + // capitalize upper-cases the first char and lower-cases the rest. + expect(out.trim()).toBe('Bob, welcome to liquidjs!'); + }); +}); + +describe('user scenario: {% assign %} preceding {% case %} / {% when %} / {% endcase %}', () => { + const assignTag = '{% assign handle = "cake" %}'; + const caseBlock = `{% case handle %} + {% when "cake" %} + This is a cake + {% when "cookie", "biscuit" %} + This is a cookie + {% else %} + This is neither a cake nor a cookie +{% endcase %}`; + const template = `${assignTag}\n${caseBlock}`; + + it('interprets assign as a standalone field tag and case/when/endcase as one paired block', () => { + const regions = scanTemplateRegions(template); + // assign, case, when×2, else, endcase + expect(regions.map(r => r.kind)).toEqual(['tag', 'tag', 'tag', 'tag', 'tag', 'tag']); + expect(fieldTagLabel(textOf(template, regions[0]))).toBe('assign → handle'); + expect(fieldTagLabel(textOf(template, regions[1]))).toBe('case → handle'); + + const blocks = pairBlockTags(template); + expect(blocks).toHaveLength(1); + expect(blocks[0].members.size).toBe(5); // case, when, when, else, endcase + + // assign is NOT inside any block + expect(outermostBlockAt(blocks, regions[0].start)).toBeUndefined(); + // case opener IS the outermost block + expect(outermostBlockAt(blocks, regions[1].start)).toBe(blocks[0]); + }); + + it('renders the matching when branch', async () => { + const out = (await render(template, { context: {} })) ?? ''; + expect(out.trim()).toContain('This is a cake'); + }); +}); + +describe('user scenario: aggregate — assign + case/when and assign + if/else in one document', () => { + const template = `{% assign handle = "cake" %} +{% case handle %} + {% when "cake" %} + This is a cake + {% when "cookie", "biscuit" %} + This is a cookie + {% else %} + This is neither a cake nor a cookie +{% endcase %} +{% assign product_name = "Coffee Mug" %} +{% assign stock_count = 15 %} +{% if stock_count > 0 %} +{{ product_name }} is available. +{% else %} +{{ product_name }} is out of stock. +{% endif %}`; + + it('parses all 13 regions (11 tags + 2 variables) without error', () => { + const regions = scanTemplateRegions(template); + expect(regions).toHaveLength(13); + expect(regions.every(r => ['tag', 'variable', 'comment'].includes(r.kind))).toBe(true); + }); + + it('correctly pairs two independent blocks (case and if)', () => { + const blocks = pairBlockTags(template); + expect(blocks).toHaveLength(2); + const names = blocks.map(b => template.slice(b.start, b.start + 10)); + expect(names.some(n => n.startsWith('{% case'))).toBe(true); + expect(names.some(n => n.startsWith('{% if'))).toBe(true); + }); + + it('renders both branches correctly from their preceding assigns', async () => { + const out = (await render(template, { context: {} })) ?? ''; + expect(out).toContain('This is a cake'); + expect(out).toContain('Coffee Mug is available.'); + }); +}); + +describe('user scenario: simple {% if %}…{% endif %} block', () => { + const template = '{% if x %}hello{% endif %}'; + + it('is interpreted as two delimiter regions forming one block', () => { + const regions = scanTemplateRegions(template); + expect(regions.map(r => r.kind)).toEqual(['tag', 'tag']); + const blocks = pairBlockTags(template); + expect(blocks).toHaveLength(1); + expect(outermostBlockAt(blocks, template.indexOf('{% endif'))?.start).toBe(0); + }); + + it('renders the body when the condition is truthy', async () => { + expect(await render(template, { context: { x: true } })).toBe('hello'); + }); +}); diff --git a/packages/insomnia/src/templating/__tests__/utils.test.ts b/packages/insomnia/src/templating/__tests__/utils.test.ts index 76348b429a..a112d73eb5 100644 --- a/packages/insomnia/src/templating/__tests__/utils.test.ts +++ b/packages/insomnia/src/templating/__tests__/utils.test.ts @@ -242,6 +242,37 @@ describe('tokenizeTag()', () => { }; expect(actual).toEqual(expected); }); + + it('parses the name through LiquidJS whitespace-control delimiters', () => { + expect(utils.tokenizeTag('{%- if i == 4 -%}').name).toBe('if'); + expect(utils.tokenizeTag('{%- continue -%}').name).toBe('continue'); + expect(utils.tokenizeTag('{%- endif -%}').name).toBe('endif'); + // A trailing-only or leading-only dash is still handled. + expect(utils.tokenizeTag('{% for i in (1..5) -%}').name).toBe('for'); + }); +}); + +describe('fieldTagLabel()', () => { + it('labels field tags as `name → variable`', () => { + expect(utils.fieldTagLabel("{% assign username = 'Bob' %}")).toBe('assign → username'); + expect(utils.fieldTagLabel('{% capture greeting %}')).toBe('capture → greeting'); + expect(utils.fieldTagLabel('{% case x %}')).toBe('case → x'); + expect(utils.fieldTagLabel('{% increment counter %}')).toBe('increment → counter'); + expect(utils.fieldTagLabel('{% decrement counter %}')).toBe('decrement → counter'); + expect(utils.fieldTagLabel('{% echo username | append: ", hi" | capitalize %}')).toBe('echo → username'); + }); + + it('tolerates whitespace-control delimiters', () => { + expect(utils.fieldTagLabel('{%- assign a = 1 -%}')).toBe('assign → a'); + }); + + it('returns null for non-field tags', () => { + expect(utils.fieldTagLabel("{% now 'iso-8601' %}")).toBeNull(); + expect(utils.fieldTagLabel('{% if x %}')).toBeNull(); + expect(utils.fieldTagLabel('{% endcapture %}')).toBeNull(); + // The `{% liquid %}` master tag is not a field tag (handled as multiline elsewhere). + expect(utils.fieldTagLabel('{% liquid\nassign x = 1\n%}')).toBeNull(); + }); }); describe('unTokenizeTag()', () => { diff --git a/packages/insomnia/src/templating/render-error.ts b/packages/insomnia/src/templating/render-error.ts index e84669b12a..24316d3b16 100644 --- a/packages/insomnia/src/templating/render-error.ts +++ b/packages/insomnia/src/templating/render-error.ts @@ -53,12 +53,57 @@ export function translateLiquidError( return newError; } +// Collect all variable names that are defined WITHIN the template itself via +// assign/capture/for/increment/decrement (including inside {% liquid %} blocks). +// These are template-scoped, not environment variables, and must not appear in +// the "missing environment variables" list. +function extractTemplateDefinedVars(text: string): Set { + const defined = new Set(); + let m: RegExpExecArray | null; + + const assignRe = /{%-?\s*assign\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g; + while ((m = assignRe.exec(text)) !== null) { defined.add(m[1]); } + + const captureRe = /{%-?\s*capture\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*-?%}/g; + while ((m = captureRe.exec(text)) !== null) { defined.add(m[1]); } + + // {% for varName in … %} also makes the `forloop` object available + const forRe = /{%-?\s*for\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s+in/g; + while ((m = forRe.exec(text)) !== null) { defined.add(m[1]); defined.add('forloop'); } + + // {% tablerow varName in … %} also makes `tablerowloop` available + const tablerowRe = /{%-?\s*tablerow\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s+in/g; + while ((m = tablerowRe.exec(text)) !== null) { defined.add(m[1]); defined.add('tablerowloop'); } + + const incrRe = /{%-?\s*(?:increment|decrement)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*-?%}/g; + while ((m = incrRe.exec(text)) !== null) { defined.add(m[1]); } + + // {% liquid %} blocks use newline-separated statements without per-line delimiters + const liquidBlockRe = /{%-?\s*liquid\s+([\s\S]*?)-?%}/g; + while ((m = liquidBlockRe.exec(text)) !== null) { + const block = m[1]; + let n: RegExpExecArray | null; + const lAssign = /^\s*assign\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/mg; + while ((n = lAssign.exec(block)) !== null) { defined.add(n[1]); } + const lCapture = /^\s*capture\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/mg; + while ((n = lCapture.exec(block)) !== null) { defined.add(n[1]); } + const lFor = /^\s*for\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s+in/mg; + while ((n = lFor.exec(block)) !== null) { defined.add(n[1]); defined.add('forloop'); } + const lIncr = /^\s*(?:increment|decrement)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/mg; + while ((n = lIncr.exec(block)) !== null) { defined.add(n[1]); } + } + + return defined; +} + // LiquidJS only reports the first undefined variable, so we regex-scan the // full template text to find all missing variables for the UI panel. export function extractUndefinedVariableKey(text = '', templatingContext: Record): string[] { // Strip Liquid filter expressions (| filter: args) so `{{ a | upper }}` reports `a` not `a | upper` const regexVariable = /{{\s*([^|}\s][^|}]*?)\s*(?:\|[^}]*)?\s*}}/g; + const templateDefined = extractTemplateDefinedVars(text); const missingVariables: string[] = []; + const seen = new Set(); let match; while ((match = regexVariable.exec(text)) !== null) { @@ -66,6 +111,13 @@ export function extractUndefinedVariableKey(text = '', templatingContext: Record if (variable.includes('_.')) { variable = variable.split('_.')[1]; } + // Skip duplicates and variables whose root is defined within the template + // (e.g. `forloop.index` is covered by `forloop` being in templateDefined). + const baseVar = variable.split('.')[0]; + if (seen.has(variable) || templateDefined.has(variable) || templateDefined.has(baseVar)) { + continue; + } + seen.add(variable); if (_get(templatingContext, variable) === undefined) { missingVariables.push(variable); } diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index 6eabd5ac1e..52fddb1c04 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -56,7 +56,9 @@ export function normalizeToDotAndBracketNotation(prefix: string) { * @param {string} tagStr - the template string for the tag */ export function tokenizeTag(tagStr: string) { - const withoutEnds = tagStr.trim().replace(/^{%/, '').replace(/%}$/, '').trim(); + // Strip the delimiters, allowing for LiquidJS whitespace-control dashes + // (`{%-` / `-%}`) so e.g. `{%- if i == 4 -%}` parses its name as `if`. + const withoutEnds = tagStr.trim().replace(/^{%-?/, '').replace(/-?%}$/, '').trim(); const nameMatch = withoutEnds.match(/^[a-zA-Z_$][0-9a-zA-Z_$]*/); const name = nameMatch ? nameMatch[0] : withoutEnds; const argsStr = withoutEnds.slice(name.length); @@ -68,6 +70,30 @@ export function tokenizeTag(tagStr: string) { return parsedTag; } +// LiquidJS tags whose primary purpose is to act on a single named variable. For these +// the editor shows a `name → variable` pill (e.g. `assign → username`) instead of just +// the keyword. `tokenizeArgs` is not reused here because it mis-splits `assign x = …` +// and `echo x | filter: ","`; a direct regex on the tag text is more reliable. +const FIELD_TAGS = new Set(['assign', 'capture', 'case', 'decrement', 'echo', 'increment']); + +/** + * For a "field" tag (assign/capture/case/decrement/echo/increment), return a + * `name → variable` label where the variable is the first identifier after the keyword; + * otherwise return null. Tolerates LiquidJS whitespace-control delimiters (`{%-` / `-%}`). + */ +export function fieldTagLabel(tagStr: string): string | null { + const inner = tagStr + .trim() + .replace(/^{%-?/, '') + .replace(/-?%}$/, '') + .trim(); + const match = inner.match(/^(\w+)\s+([a-zA-Z_$][0-9a-zA-Z_$]*)/); + if (!match || !FIELD_TAGS.has(match[1])) { + return null; + } + return `${match[1]} → ${match[2]}`; +} + /** Convert a tokenized tag back into a Liquid template string */ export function unTokenizeTag(tagData: NunjucksParsedTag) { const args: string[] = []; diff --git a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx index d2d6de5cc2..a6380fd665 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -364,7 +364,7 @@ export const CodeEditor = memo( const initialOptions: EditorConfiguration = { lineNumbers: showGuttersAndLineNumbers, placeholder: placeholder || '', - foldGutter: showGuttersAndLineNumbers, + foldGutter: false, autoRefresh: { delay: 2000 }, lineWrapping: settings.editorLineWrapping ?? true, scrollbarStyle: 'native', @@ -387,7 +387,7 @@ export const CodeEditor = memo( keyMap: !readOnly && settings.editorKeyMap ? settings.editorKeyMap : 'default', extraKeys: CodeMirror.normalizeKeyMap(extraKeys), gutters: showGuttersAndLineNumbers - ? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers', 'CodeMirror-foldgutter'] + ? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'] : [], foldOptions: { widget: (from: CodeMirror.Position, to: CodeMirror.Position) => widget(codeMirror.current, from, to), diff --git a/packages/insomnia/src/ui/components/.client/codemirror/extensions/liquid-block-tags.test.ts b/packages/insomnia/src/ui/components/.client/codemirror/extensions/liquid-block-tags.test.ts new file mode 100644 index 0000000000..3d099bb0de --- /dev/null +++ b/packages/insomnia/src/ui/components/.client/codemirror/extensions/liquid-block-tags.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { isBlockKeyword, outermostBlockAt, pairBlockTags, scanTemplateRegions } from './liquid-block-tags'; + +describe('isBlockKeyword', () => { + it('recognizes openers, closers and intermediates', () => { + expect(isBlockKeyword('if')).toBe(true); + expect(isBlockKeyword('endif')).toBe(true); + expect(isBlockKeyword('elsif')).toBe(true); + expect(isBlockKeyword('else')).toBe(true); + expect(isBlockKeyword('for')).toBe(true); + expect(isBlockKeyword('endfor')).toBe(true); + }); + + it('does not flag self-contained or unknown tags', () => { + expect(isBlockKeyword('liquid')).toBe(false); + expect(isBlockKeyword('assign')).toBe(false); + expect(isBlockKeyword('now')).toBe(false); + }); +}); + +describe('pairBlockTags', () => { + it('pairs a simple if/endif block and records every delimiter', () => { + const text = '{% if x %}a{% endif %}'; + const blocks = pairBlockTags(text); + expect(blocks).toHaveLength(1); + const [block] = blocks; + expect(block.start).toBe(0); + expect(block.end).toBe(text.length); + expect(block.members.has(text.indexOf('{% if'))).toBe(true); + expect(block.members.has(text.indexOf('{% endif'))).toBe(true); + }); + + it('includes elsif/else delimiters as members of the block', () => { + const text = '{% if x %}a{% elsif y %}b{% else %}c{% endif %}'; + const [block] = pairBlockTags(text); + expect(block.members.has(text.indexOf('{% elsif'))).toBe(true); + expect(block.members.has(text.indexOf('{% else'))).toBe(true); + expect(block.members.size).toBe(4); // if, elsif, else, endif + }); + + it('handles multi-line blocks', () => { + const text = '{% if x %}\n hello\n{% endif %}'; + const blocks = pairBlockTags(text); + expect(blocks).toHaveLength(1); + expect(blocks[0].end).toBe(text.length); + }); + + it('handles nested blocks of the same type', () => { + const text = '{% for a in x %}{% for b in y %}{{ b }}{% endfor %}{% endfor %}'; + const blocks = pairBlockTags(text); + expect(blocks).toHaveLength(2); + // Inner block closes first, outer spans the whole string. + const outer = blocks.find(b => b.start === 0); + expect(outer?.end).toBe(text.length); + }); + + it('does not pair tags inside a {% raw %} block', () => { + const text = '{% raw %}{% if x %}{% endif %}{% endraw %}'; + const blocks = pairBlockTags(text); + // Only the raw/endraw pair; the inner if/endif are literal text. + expect(blocks).toHaveLength(1); + expect(blocks[0].start).toBe(0); + expect(blocks[0].end).toBe(text.length); + }); + + it('ignores self-contained tags like {% liquid %} and {% now %}', () => { + const text = "{% liquid assign x = 'hi' %}{% now 'iso-8601' %}"; + expect(pairBlockTags(text)).toHaveLength(0); + }); +}); + +describe('scanTemplateRegions', () => { + it('captures a multi-line {% liquid %} block (with a blank line) as one region', () => { + const text = `{% liquid + assign x = "hello" + + echo x +%}`; + const regions = scanTemplateRegions(text); + expect(regions).toHaveLength(1); + expect(regions[0]).toEqual({ start: 0, end: text.length, kind: 'tag' }); + }); + + it('returns a separate region per delimiter of a block', () => { + const text = '{% for i in (1..5) %}{%- if i == 4 -%}{{ i }}{%- endif -%}{% endfor %}'; + const regions = scanTemplateRegions(text); + expect(regions.map(r => r.kind)).toEqual(['tag', 'tag', 'variable', 'tag', 'tag']); + expect(regions[2]).toEqual({ start: text.indexOf('{{ i }}'), end: text.indexOf('{{ i }}') + '{{ i }}'.length, kind: 'variable' }); + }); + + it('treats content inside a {% raw %} block as literal', () => { + const text = '{% raw %}{% if x %}{{ y }}{% endif %}{% endraw %}'; + const regions = scanTemplateRegions(text); + // Only the raw/endraw delimiters are real regions. + expect(regions).toHaveLength(2); + expect(regions[0].start).toBe(0); + expect(regions[1].end).toBe(text.length); + }); + + it('distinguishes variables and comments', () => { + const text = '{{ a }}{# c #}{% now %}'; + expect(scanTemplateRegions(text).map(r => r.kind)).toEqual(['variable', 'comment', 'tag']); + }); +}); + +describe('outermostBlockAt', () => { + it('returns the outermost block containing a nested index', () => { + const text = '{% for a in x %}{% if a %}{{ a }}{% endif %}{% endfor %}'; + const blocks = pairBlockTags(text); + const innerVarIdx = text.indexOf('{{ a }}'); + const outer = outermostBlockAt(blocks, innerVarIdx); + expect(outer?.start).toBe(0); // the `for` block, not the inner `if` + expect(outer?.end).toBe(text.length); + }); + + it('returns undefined when no block contains the index', () => { + const text = '{% assign x = 1 %}{{ x }}'; + const blocks = pairBlockTags(text); + expect(outermostBlockAt(blocks, text.indexOf('{{ x }}'))).toBeUndefined(); + }); +}); diff --git a/packages/insomnia/src/ui/components/.client/codemirror/extensions/liquid-block-tags.ts b/packages/insomnia/src/ui/components/.client/codemirror/extensions/liquid-block-tags.ts new file mode 100644 index 0000000000..15ed8b808e --- /dev/null +++ b/packages/insomnia/src/ui/components/.client/codemirror/extensions/liquid-block-tags.ts @@ -0,0 +1,144 @@ +// Helpers for recognizing LiquidJS block tags in editor text. +// +// LiquidJS block tags pair an opening delimiter with a closing one and may wrap +// content (and other tags) across many lines, e.g. +// {% if x %} … {% elsif y %} … {% else %} … {% endif %} +// These helpers let the editor associate any delimiter with the whole block it +// belongs to (for visual grouping and "edit the whole statement" behaviour). + +export const BLOCK_OPENER_TO_CLOSER: Record = { + if: 'endif', + unless: 'endunless', + for: 'endfor', + case: 'endcase', + capture: 'endcapture', + tablerow: 'endtablerow', + raw: 'endraw', + comment: 'endcomment', +}; + +export const BLOCK_CLOSERS = new Set(Object.values(BLOCK_OPENER_TO_CLOSER)); + +// Branch/intermediate delimiters that live inside a block but don't open/close it. +// `elif` is included so an (invalid in LiquidJS) Nunjucks-style branch is still +// grouped with its block rather than treated as a standalone tag. +export const BLOCK_INTERMEDIATES = new Set(['elsif', 'elif', 'else', 'when']); + +export const isBlockKeyword = (name: string): boolean => + name in BLOCK_OPENER_TO_CLOSER || BLOCK_CLOSERS.has(name) || BLOCK_INTERMEDIATES.has(name); + +export interface TagBlock { + /** Index of the opening `{%`. */ + start: number; + /** Index just past the closing `%}`. */ + end: number; + /** Start indices of every delimiter (opener, intermediates, closer) in the block. */ + members: Set; +} + +/** + * Return the outermost block whose `[start, end)` range contains `idx`, or undefined. + * "Outermost" (smallest `start`) so that clicking any construct inside a nested + * structure edits the whole top-level statement. + */ +export function outermostBlockAt(blocks: TagBlock[], idx: number): TagBlock | undefined { + let found: TagBlock | undefined; + for (const block of blocks) { + if (block.start <= idx && idx < block.end && (!found || block.start < found.start)) { + found = block; + } + } + return found; +} + +/** A single template construct found in the document text. */ +export interface TemplateRegion { + /** Index of the opening delimiter (`{{`, `{%`, `{#`). */ + start: number; + /** Index just past the closing delimiter. */ + end: number; + kind: 'tag' | 'variable' | 'comment'; +} + +// Matches one construct at a time. Each alternative is non-greedy so a construct +// stops at its own first closing delimiter; because `[\s\S]` includes newlines, a +// self-contained multi-line tag (e.g. the `{% liquid … %}` master tag, which has no +// internal `%}`) is captured as ONE region regardless of blank lines inside it. +const REGION_RE = /{{-?[\s\S]*?-?}}|{%-?[\s\S]*?-?%}|{#[\s\S]*?#}/g; +const TAG_NAME_RE = /^{%-?\s*(\w+)/; + +/** + * Scan the document for every template construct (`{{ … }}`, `{% … %}`, `{# … #}`), + * returning one region per construct in document order. Each block delimiter is its + * own region (so the editor can keep per-delimiter pills); whole-block grouping is + * handled separately by {@link pairBlockTags}. Tags inside a `{% raw %}` block are + * treated as literal text and not emitted. + */ +export function scanTemplateRegions(text: string): TemplateRegion[] { + const regions: TemplateRegion[] = []; + REGION_RE.lastIndex = 0; + let match: RegExpExecArray | null; + let inRaw = false; + + while ((match = REGION_RE.exec(text)) !== null) { + const raw = match[0]; + const start = match.index; + const end = REGION_RE.lastIndex; + const kind: TemplateRegion['kind'] = raw.startsWith('{{') ? 'variable' : raw.startsWith('{#') ? 'comment' : 'tag'; + const name = kind === 'tag' ? raw.match(TAG_NAME_RE)?.[1] : undefined; + + // Inside a {% raw %} block only {% endraw %} is meaningful; everything else is literal. + if (inRaw) { + if (name === 'endraw') { + inRaw = false; + regions.push({ start, end, kind }); + } + continue; + } + if (name === 'raw') { + inRaw = true; + } + regions.push({ start, end, kind }); + } + + return regions; +} + +/** + * Pair LiquidJS block tags (`{% if %}…{% endif %}`, `{% for %}…{% endfor %}`, …) + * so that any delimiter can be associated with the whole block it belongs to. + * Nesting-aware. `{% liquid %}` is self-contained and is intentionally not paired. + */ +export function pairBlockTags(text: string): TagBlock[] { + const tagRe = /{%-?\s*(\w+)[\s\S]*?-?%}/g; + const blocks: TagBlock[] = []; + const stack: { name: string; start: number; members: number[] }[] = []; + let match: RegExpExecArray | null; + + while ((match = tagRe.exec(text)) !== null) { + const name = match[1]; + const start = match.index; + const end = tagRe.lastIndex; + const top = stack[stack.length - 1]; + + // Inside a {% raw %} block everything except {% endraw %} is literal. + if (top && top.name === 'raw' && name !== 'endraw') { + continue; + } + if (name in BLOCK_OPENER_TO_CLOSER) { + stack.push({ name, start, members: [start] }); + continue; + } + if (top && name === BLOCK_OPENER_TO_CLOSER[top.name]) { + top.members.push(start); + blocks.push({ start: top.start, end, members: new Set(top.members) }); + stack.pop(); + continue; + } + if (top && BLOCK_INTERMEDIATES.has(name)) { + top.members.push(start); + } + } + + return blocks; +} diff --git a/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts b/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts index 088b4e978a..4f37198f87 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts @@ -1,12 +1,19 @@ -import CodeMirror, { type Token } from 'codemirror'; +import CodeMirror from 'codemirror'; import * as misc from '~/common/misc'; import { getTagDefinitions } from '~/templating/renderer-safe'; import type { HandleRender, RenderContextAndKeys } from '~/templating/types'; -import { tokenizeTag } from '~/templating/utils'; +import { fieldTagLabel, tokenizeTag } from '~/templating/utils'; import { showModal } from '~/ui/components/modals/index'; import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal'; +import { isBlockKeyword, outermostBlockAt, pairBlockTags, scanTemplateRegions, type TagBlock } from './liquid-block-tags'; + +// Tags that set variables but are not block constructs themselves. When one of these +// immediately precedes a block (assign before case, for example), clicking the block +// extends the edit selection to include them so the modal has the correct variable context. +const CONTEXT_SETTER_TAGS = new Set(['assign', 'increment', 'decrement']); + CodeMirror.defineExtension( 'enableNunjucksTags', function ( @@ -49,6 +56,46 @@ CodeMirror.defineExtension( }, ); +function _isCursorInRange( + cursor: CodeMirror.Position, + start: CodeMirror.Position, + end: CodeMirror.Position, +) { + const afterStart = cursor.line > start.line || (cursor.line === start.line && cursor.ch > start.ch); + const beforeEnd = cursor.line < end.line || (cursor.line === end.line && cursor.ch < end.ch); + return afterStart && beforeEnd; +} + +const BLOCK_LINE_CLASS = 'nunjucks-block-line'; + +/** Draw a connecting gutter bar across the lines spanned by each block tag. */ +function _decorateBlockLines( + cm: CodeMirror.Editor, + doc: CodeMirror.Doc, + vp: { from: number; to: number }, + blocks: TagBlock[], +) { + for (let lineNo = vp.from; lineNo < vp.to; lineNo++) { + cm.removeLineClass(lineNo, 'wrap', BLOCK_LINE_CLASS); + } + for (const block of blocks) { + const fromLine = doc.posFromIndex(block.start).line; + const toLine = doc.posFromIndex(block.end).line; + for (let lineNo = Math.max(fromLine, vp.from); lineNo <= Math.min(toLine, vp.to - 1); lineNo++) { + cm.addLineClass(lineNo, 'wrap', BLOCK_LINE_CLASS); + } + } +} + +interface TagSpan { + /** CSS suffix: 'nunjucks-variable' for `{{ }}`, otherwise 'nunjucks-tag'. */ + type: string; + start: CodeMirror.Position; + end: CodeMirror.Position; + /** True if this construct is part of a paired block (`{% if %}…{% endif %}`). */ + inBlock: boolean; +} + async function _highlightNunjucksTags( this: CodeMirror.Editor, render: HandleRender, @@ -64,169 +111,205 @@ async function _highlightNunjucksTags( const activeMarks: CodeMirror.TextMarker[] = []; const doc: CodeMirror.Doc = this.getDoc(); - // Only mark up Nunjucks tokens that are in the viewport + // Only mark up Liquid tokens that are in the viewport const vp = this.getViewport(); const readOnly = this.isReadOnly(); - for (let lineNo = vp.from; lineNo < vp.to; lineNo++) { - const line = this.getLineTokens(lineNo); - const tokens = line.filter(({ type }: any) => type?.indexOf('nunjucks') >= 0); + // Pair block tags across the whole document so a delimiter can open its block. + const blocks = pairBlockTags(doc.getValue()); + // Outermost block whose [start, end) contains idx — so clicking any pill inside a + // nested structure edits the whole top-level statement, and inner pieces are flagged + // as "in block" (and therefore not rendered in isolation). + const blockContaining = (idx: number) => outermostBlockAt(blocks, idx); - // Aggregate same tokens - const newTokens: Token[] = []; - let currTok: Token | null = null; + // Detect constructs by scanning the document text directly (rather than aggregating + // CodeMirror's per-line tokens). This deterministically keeps a multi-line tag such + // as `{% liquid … %}` — including blank lines inside it — as a single span. + const text0 = doc.getValue(); + const spans: TagSpan[] = scanTemplateRegions(text0).map(region => ({ + type: region.kind === 'variable' ? 'nunjucks-variable' : 'nunjucks-tag', + start: doc.posFromIndex(region.start), + end: doc.posFromIndex(region.end), + inBlock: !!blockContaining(region.start), + })); - for (const nextTok of tokens) { - if (currTok && currTok.type === nextTok.type && currTok.end === nextTok.start) { - currTok.end = nextTok.end; - currTok.string += nextTok.string; - } else if (currTok) { - newTokens.push(currTok); - currTok = null; - } + for (const span of spans) { + const { start, end } = span; + // Only mark constructs that intersect the viewport. + if (end.line < vp.from || start.line >= vp.to) { + continue; + } + const text = doc.getRange(start, end); + const cursor = doc.getCursor(); + const isFocused = this.hasFocus(); - if (!currTok) { - currTok = Object.assign({}, nextTok); - } + // Show the raw text again if the caret is inside the span (so it's editable). + if (isFocused && _isCursorInRange(cursor, start, end)) { + continue; } - // Push the last one if we're done - if (currTok) { - newTokens.push(currTok); - } + // See if we already have a mark for this + let hasOwnMark = false; - for (const tok of newTokens) { - const start = { - line: lineNo, - ch: tok.start, - }; - const end = { - line: lineNo, - ch: tok.end, - }; - const cursor = doc.getCursor(); - const isSameLine = cursor.line === lineNo; - const isCursorInToken = isSameLine && cursor.ch > tok.start && cursor.ch < tok.end; - const isFocused = this.hasFocus(); - - // Show the token again if we're not inside of it. - if (isFocused && isCursorInToken) { - continue; + for (const mark of doc.findMarks(start, end)) { + // Only check marks we created + // @ts-expect-error -- TSCONVERSION need to extend nunjucks + if (mark.__nunjucks) { + hasOwnMark = true; } - // See if we already have a mark for this - let hasOwnMark = false; - - for (const mark of doc.findMarks(start, end)) { - // Only check marks we created - // @ts-expect-error -- TSCONVERSION need to extend nunjucks - if (mark.__nunjucks) { - hasOwnMark = true; - } - - activeMarks.push(mark); - } - - // Already have a mark for this, so leave it alone - if (hasOwnMark) { - continue; - } - - const el = document.createElement('span'); - el.className = `nunjucks-tag ${tok.type}`; - el.setAttribute('draggable', 'true'); - el.dataset.error = 'off'; - el.dataset.template = tok.string; - el.replaceChildren(document.createElement('label'), document.createTextNode(tok.string)); - const mark = this.markText(start, end, { - // @ts-expect-error not a known property of TextMarkerOptions - __nunjucks: true, - // Mark that we created it - __template: tok.string, - handleMouseEvents: false, - replacedWith: el, - }); - - (async function () { - await _updateElementText(renderString, mark, tok.string, renderContextWithCacheKey, showVariableSourceAndValue); - })(); - - // Update it every mouseenter because it may generate a new value every time - el.addEventListener('mouseenter', async () => { - await _updateElementText(renderString, mark, tok.string, renderContextWithCacheKey, showVariableSourceAndValue); - }); activeMarks.push(mark); - el.addEventListener('click', async () => { - if (readOnly) return; - // Define the dialog HTML - showModal(NunjucksModal, { - // @ts-expect-error not a known property of TextMarkerOptions - template: mark.__template, - editorId, - onDone: (template: string | null) => { - const pos = mark.find(); - - if (pos) { - const { from, to } = pos; - // TODO: unsound non-null assertion - - this.replaceRange(template!, from, to); - } else { - console.warn('Tried to replace mark that did not exist', mark); - } - }, - }); - }); - // ~~~~~~~~~~~~~~~~~~~~~~~ // - // Setup Drag-n-Drop stuff // - // ~~~~~~~~~~~~~~~~~~~~~~~ // - let droppedInSameEditor = false; - - // Modify paste events so we can merge into them - const beforeChangeCb = (_cm: any, change: any) => { - if (change.origin === 'paste') { - change.origin = '+dnd'; - } - }; - - const dropCb = () => { - droppedInSameEditor = true; - }; - - // Set up the drag - el.addEventListener('dragstart', event => { - // Setup the drag contents - if (event.dataTransfer) { - event.dataTransfer.setData('text/plain', event.target as unknown as string); - event.dataTransfer.effectAllowed = 'copyMove'; - event.dataTransfer.dropEffect = 'move'; - } - // Add some listeners - this.on('beforeChange', beforeChangeCb); - this.on('drop', dropCb); - }); - el.addEventListener('dragend', () => { - // If dragged within same editor, delete the old reference - // TODO: Actually only use dropEffect for this logic. For some reason - // changing it doesn't seem to take affect in Chromium 56 (maybe bug?) - if (droppedInSameEditor) { - // TODO: unsound non-null assertion - - const { from, to } = mark.find()!; - this.replaceRange('', from, to, '+dnd'); - } - - // Remove listeners we added - this.off('beforeChange', beforeChangeCb); - this.off('drop', dropCb); - }); - // Don't allow dropping on itself - el.addEventListener('drop', event => { - event.stopPropagation(); - }); } + + // Already have a mark for this, so leave it alone + if (hasOwnMark) { + continue; + } + + const el = document.createElement('span'); + el.className = `nunjucks-tag ${span.type}`; + el.setAttribute('draggable', 'true'); + el.dataset.error = 'off'; + el.dataset.template = text; + // Compute a synchronous initial label so the pill never flashes raw template + // text while the async _updateElementText call is pending. This mirrors the + // labelling logic in _updateElementText using only synchronous operations. + const _str = text.replace(/\\/g, ''); + const _isTag = _str.trim().startsWith('{%'); + const _cleanedStr = _str.replace(/^{%-?/, '').replace(/-?%}$/, '').replace(/^{{-?/, '').replace(/-?}}$/, '').trim(); + const _syncLabel = _isTag + ? (fieldTagLabel(_str) ?? (text.includes('\n') ? 'template → multiline' : tokenizeTag(_str).name || _cleanedStr)) + : _cleanedStr; + el.replaceChildren(document.createElement('label'), document.createTextNode(_syncLabel)); + // Set data-ignore synchronously so block pills render grey from the first frame, + // not the default blue that they'd briefly show before _updateElementText resolves. + if (span.inBlock) { + el.dataset.ignore = 'on'; + } + + const mark = this.markText(start, end, { + // @ts-expect-error not a known property of TextMarkerOptions + __nunjucks: true, + // Mark that we created it + __template: text, + handleMouseEvents: false, + replacedWith: el, + }); + + (async function () { + await _updateElementText(renderString, mark, text, renderContextWithCacheKey, showVariableSourceAndValue, span.inBlock); + })(); + + // Update it every mouseenter because it may generate a new value every time + el.addEventListener('mouseenter', async () => { + await _updateElementText(renderString, mark, text, renderContextWithCacheKey, showVariableSourceAndValue, span.inBlock); + }); + activeMarks.push(mark); + el.addEventListener('click', async () => { + if (readOnly) return; + const pos = mark.find(); + if (!pos) { + console.warn('Tried to replace mark that did not exist', mark); + return; + } + // If this construct belongs to a block (`{% if %}…{% endif %}`), edit the whole + // (outermost) block as one statement instead of just this delimiter/piece. + const block = blockContaining(doc.indexFromPos(pos.from)); + let replaceFrom = block ? doc.posFromIndex(block.start) : pos.from; + const replaceTo = block ? doc.posFromIndex(block.end) : pos.to; + + // If this pill belongs to a block, scan backwards to find standalone context-setter + // tags (assign, increment, decrement) that appear between the previous block's end + // and this block's start. Include them in the edit region so the modal renders the + // block with correct variable context (e.g. `{% assign handle = "cake" %}` before + // `{% case handle %}…{% endcase %}`). + if (block) { + const docText = doc.getValue(); + const freshBlocks = pairBlockTags(docText); + const blockStartIdx = doc.indexFromPos(replaceFrom); + const prevBlockEnd = freshBlocks + .filter(b => b.end <= blockStartIdx) + .reduce((max, b) => Math.max(max, b.end), 0); + + let earliestContextStart = blockStartIdx; + for (const region of scanTemplateRegions(docText)) { + if (region.start < prevBlockEnd) continue; + if (region.start >= blockStartIdx) break; + if (region.kind !== 'tag') continue; + if (outermostBlockAt(freshBlocks, region.start)) continue; + const name = tokenizeTag(docText.slice(region.start, region.end)).name; + if (CONTEXT_SETTER_TAGS.has(name)) { + earliestContextStart = Math.min(earliestContextStart, region.start); + } + } + if (earliestContextStart < blockStartIdx) { + replaceFrom = doc.posFromIndex(earliestContextStart); + } + } + + const template = doc.getRange(replaceFrom, replaceTo); + + showModal(NunjucksModal, { + template, + editorId, + onDone: (newTemplate: string | null) => { + if (newTemplate !== null) { + this.replaceRange(newTemplate, replaceFrom, replaceTo); + } + }, + }); + }); + // ~~~~~~~~~~~~~~~~~~~~~~~ // + // Setup Drag-n-Drop stuff // + // ~~~~~~~~~~~~~~~~~~~~~~~ // + let droppedInSameEditor = false; + + // Modify paste events so we can merge into them + const beforeChangeCb = (_cm: any, change: any) => { + if (change.origin === 'paste') { + change.origin = '+dnd'; + } + }; + + const dropCb = () => { + droppedInSameEditor = true; + }; + + // Set up the drag + el.addEventListener('dragstart', event => { + // Setup the drag contents + if (event.dataTransfer) { + event.dataTransfer.setData('text/plain', event.target as unknown as string); + event.dataTransfer.effectAllowed = 'copyMove'; + event.dataTransfer.dropEffect = 'move'; + } + // Add some listeners + this.on('beforeChange', beforeChangeCb); + this.on('drop', dropCb); + }); + el.addEventListener('dragend', () => { + // If dragged within same editor, delete the old reference + // TODO: Actually only use dropEffect for this logic. For some reason + // changing it doesn't seem to take affect in Chromium 56 (maybe bug?) + if (droppedInSameEditor) { + // TODO: unsound non-null assertion + + const { from, to } = mark.find()!; + this.replaceRange('', from, to, '+dnd'); + } + + // Remove listeners we added + this.off('beforeChange', beforeChangeCb); + this.off('drop', dropCb); + }); + // Don't allow dropping on itself + el.addEventListener('drop', event => { + event.stopPropagation(); + }); } + _decorateBlockLines(this, doc, vp, blocks); + // Clear all the marks that we didn't just modify/add // For example, adding a {% raw %} tag would need to clear everything it wrapped const marksInViewport = doc.findMarks( @@ -268,6 +351,7 @@ async function _updateElementText( text: string, renderContext: (contextCacheKey?: string) => Promise, showVariableSourceAndValue: boolean, + isInBlock = false, ) { const el = mark.replacedWith!; let innerHTML = text; @@ -275,11 +359,49 @@ async function _updateElementText( let dataIgnore = ''; let dataError = ''; const str = text.replace(/\\/g, ''); - const tagMatch = str.match(/{% *([^ ]+) *.*%}/); - const cleanedStr = str.replace(/^{%/, '').replace(/%}$/, '').replace(/^{{/, '').replace(/}}$/, '').trim(); + // A tag may span multiple lines (e.g. the `{% liquid … %}` master tag), so detect + // by the opening delimiter rather than a single-line regex. + const isTag = str.trim().startsWith('{%'); + // Strip delimiters, allowing for LiquidJS whitespace-control dashes (`{%-` / `-%}`). + const cleanedStr = str + .replace(/^{%-?/, '') + .replace(/-?%}$/, '') + .replace(/^{{-?/, '') + .replace(/-?}}$/, '') + .trim(); + + // "Field" tags (assign/capture/case/decrement/echo/increment) are labelled + // `name → variable` wherever they appear (in a block, standalone, or built-in). + const field = isTag ? fieldTagLabel(str) : null; try { - if (tagMatch) { + if (isInBlock) { + // Part of a paired block (e.g. {% for %}…{% endfor %}). Rendering this piece on + // its own would fail or be misleading (it depends on the surrounding block's + // context), so label it and let the click handler open the whole block to edit. + if (isTag) { + const tagData = tokenizeTag(str); + const tagDef = (await getTagDefinitions()).find(d => d.name === tagData.name); + if (tagDef) { + const firstArg = tagDef.args[0]; + if (firstArg && firstArg.type === 'enum') { + const argData = tagData.args[0]; + // @ts-expect-error -- TSCONVERSION + const foundOption = firstArg.options.find(d => d.value === argData?.value); + const option = foundOption || firstArg.options[0]; + innerHTML = `${tagDef.displayName} ⇒ ${option.displayName}`; + } else { + innerHTML = tagDef.displayName || (field ?? tagData.name); + } + } else { + innerHTML = field ?? tagData.name; + } + } else { + innerHTML = cleanedStr; + } + title = 'Part of a block statement — click to edit the whole block'; + dataIgnore = 'on'; + } else if (isTag) { const tagData = tokenizeTag(str); const tagDefinition = (await getTagDefinitions()).find(d => d.name === tagData.name); @@ -302,10 +424,30 @@ async function _updateElementText( const preview = await render(text); title = tagDefinition.disablePreview(tagData.args) ? preview.replace(/./g, '*') : preview; - } else { - innerHTML = cleanedStr; - title = 'Unrecognized tag'; + } else if (isBlockKeyword(tagData.name)) { + // A block delimiter (e.g. {% if %}, {% else %}, {% endif %}, or a standalone + // {% case %}/{% capture %} opener). Rendering it on its own would error + // ("unclosed"); label it (field tags as `name → variable`) and let the click + // handler open the whole block for editing. + innerHTML = field ?? tagData.name; + title = 'Part of a block statement — click to edit the whole block'; dataIgnore = 'on'; + } else { + // Not an Insomnia tag, but may be a valid LiquidJS built-in (e.g. liquid, + // assign, echo). Try to render it so self-contained tags preview correctly. + // Field tags show `name → variable`; a construct spanning multiple lines (e.g. + // the `{% liquid … %}` master tag) is labelled "multiline"; anything else by keyword. + const label = field ?? (text.includes('\n') ? 'template → multiline' : tagData.name || cleanedStr); + try { + const preview = await render(text); + innerHTML = label; + title = preview; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + innerHTML = label; + title = errorMessage.replace(/\[.+,.+]\s*/, ''); + dataError = 'on'; + } } } else { // Render if it's a variable @@ -337,7 +479,7 @@ async function _updateElementText( const icon = document.createElement('i'); icon.className = 'fa fa-exclamation-triangle'; label.append(icon); - el.replaceChildren(label, document.createTextNode(cleanedStr)); + el.replaceChildren(label, document.createTextNode(innerHTML)); } else { el.replaceChildren(document.createElement('label'), document.createTextNode(innerHTML)); } diff --git a/packages/insomnia/src/ui/components/.client/codemirror/modes/nunjucks.ts b/packages/insomnia/src/ui/components/.client/codemirror/modes/nunjucks.ts index 09453f6083..26ad2df4ea 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/modes/nunjucks.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/modes/nunjucks.ts @@ -17,62 +17,156 @@ CodeMirror.defineMode('nunjucks', (config, parserConfig) => { }); function _nunjucksMode() { - const regexVariable = /^{{\s*([^ }]+)\s*[^}]*\s*}}/; - const regexTag = /^{%\s*([^ }]+)\s*[^%]*\s*%}/; - const regexComment = /^{#\s*[^#]+\s*#}/; + // Complete (single-line) constructs. The optional `-?` after the opening + // delimiter matches LiquidJS whitespace-control delimiters (`{%-`, `{{-`, `{#-`); + // the trailing `-%}` / `-}}` / `-#}` are consumed by the existing inner classes. + const regexVariable = /^{{-?\s*([^ }]+)\s*[^}]*\s*}}/; + const regexTag = /^{%-?\s*([^ }]+)\s*[^%]*\s*%}/; + const regexComment = /^{#-?\s*[^#]+\s*#}/; + // Opening delimiters, used when a construct is not closed on the same line + // (e.g. the multi-line LiquidJS `{% liquid … %}` master tag, or a `{% if … %}` + // whose expression wraps across lines). + const openVariable = /^{{/; + const openTag = /^{%/; + const openComment = /^{#/; + // Consume from the current position up to and including the closing delimiter. + const closeVariable = /^[\s\S]*?}}/; + const closeTag = /^[\s\S]*?%}/; + const closeComment = /^[\s\S]*?#}/; + // Flipped on every new construct so adjacent constructs get distinct token + // types. A single multi-line construct keeps one ticker (`state.tagTicker`) + // across all of its lines so the marker extension can stitch it into one pill. let ticker = 1; + return { startState() { return { inRaw: false, + inTag: false, + inVariable: false, + inComment: false, + tagTicker: 1, }; }, token(stream: any, state: any) { - let m; - // This makes sure that adjacent tags still have unique types - ticker *= -1; - m = stream.match(regexTag, true); + // Continue a multi-line construct opened on a previous line. + if (state.inTag) { + if (stream.match(closeTag, true)) { + state.inTag = false; + } else { + stream.skipToEnd(); + } + return state.inRaw ? null : `nunjucks-tag ${state.tagTicker}`; + } + if (state.inVariable) { + if (stream.match(closeVariable, true)) { + state.inVariable = false; + } else { + stream.skipToEnd(); + } + return `nunjucks-variable ${state.tagTicker}`; + } + if (state.inComment) { + if (stream.match(closeComment, true)) { + state.inComment = false; + } else { + stream.skipToEnd(); + } + return `nunjucks-comment ${state.tagTicker}`; + } - if (m) { - const name = m[1]; - - if (state.inRaw && name === 'endraw') { - state.inRaw = false; - } else if (!state.inRaw && name === 'raw') { - state.inRaw = true; - } else if (state.inRaw) { - // Inside raw tag so do nothing + // Inside a `{% raw %}` block only `{% endraw %}` is meaningful; everything + // else (including `{{ }}`) is literal text. + if (state.inRaw) { + const m = stream.match(regexTag, true); + if (m) { + ticker *= -1; + if (m[1] === 'endraw') { + state.inRaw = false; + state.tagTicker = ticker; + return `nunjucks-tag ${ticker}`; + } + // Some other tag inside raw — render it as literal text. return null; } + while (stream.next() != null) { + if (stream.match(regexTag, false)) { + break; + } + } + return null; + } + // Complete single-line tag. + let m = stream.match(regexTag, true); + if (m) { + ticker *= -1; + state.tagTicker = ticker; + if (m[1] === 'raw') { + state.inRaw = true; + } + return `nunjucks-tag ${ticker}`; + } + // Tag opener with no `%}` on this line → multi-line tag. + if (stream.match(openTag, true)) { + ticker *= -1; + state.tagTicker = ticker; + if (stream.match(closeTag, true)) { + // Closed later on the same line (the strict regex can miss e.g. a + // modulo `%` in the expression); treat as a normal single-line tag. + return `nunjucks-tag ${ticker}`; + } + state.inTag = true; + stream.skipToEnd(); return `nunjucks-tag ${ticker}`; } - if (!state.inRaw) { - m = stream.match(regexVariable, true); - - if (m) { + // Complete single-line variable. + m = stream.match(regexVariable, true); + if (m) { + ticker *= -1; + state.tagTicker = ticker; + return `nunjucks-variable ${ticker}`; + } + // Variable opener with no `}}` on this line → multi-line variable. + if (stream.match(openVariable, true)) { + ticker *= -1; + state.tagTicker = ticker; + if (stream.match(closeVariable, true)) { return `nunjucks-variable ${ticker}`; } + state.inVariable = true; + stream.skipToEnd(); + return `nunjucks-variable ${ticker}`; } - if (!state.inRaw) { - m = stream.match(regexComment, true); - - if (m) { + // Complete single-line comment. + m = stream.match(regexComment, true); + if (m) { + ticker *= -1; + state.tagTicker = ticker; + return `nunjucks-comment ${ticker}`; + } + // Comment opener with no `#}` on this line → multi-line comment. + if (stream.match(openComment, true)) { + ticker *= -1; + state.tagTicker = ticker; + if (stream.match(closeComment, true)) { return `nunjucks-comment ${ticker}`; } + state.inComment = true; + stream.skipToEnd(); + return `nunjucks-comment ${ticker}`; } + // Advance to the next delimiter. while (stream.next() != null) { - if (stream.match(regexVariable, false)) { - break; - } - if (stream.match(regexTag, false)) { - break; - } - if (stream.match(regexComment, false)) { + if ( + stream.match(openTag, false) || + stream.match(openVariable, false) || + stream.match(openComment, false) + ) { break; } } diff --git a/packages/insomnia/src/ui/components/panes/request-pane.tsx b/packages/insomnia/src/ui/components/panes/request-pane.tsx index 81932400fc..537e8fccf6 100644 --- a/packages/insomnia/src/ui/components/panes/request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/request-pane.tsx @@ -80,6 +80,9 @@ export const RequestPane: FC = ({ environmentId, settings, onPaste }) => const { activeEnvironment, vcsVersion } = useWorkspaceLoaderData()!; // Force re-render when we switch requests, the environment gets modified, or the (Git|Sync)VCS version changes const uniqueKey = `${activeEnvironment?.modified}::${requestId}::${gitVersion}::${vcsVersion}::${activeRequestMeta?.activeResponseId}`; + // The body editor must not remount on every response (that would flash all template + // tag pills). Its content is the raw request body — independent of the active response. + const bodyEditorKey = `${activeEnvironment?.modified}::${requestId}::${gitVersion}::${vcsVersion}`; if (!activeRequest) { return ; @@ -292,7 +295,7 @@ export const RequestPane: FC = ({ environmentId, settings, onPaste }) => - + diff --git a/packages/insomnia/src/ui/components/templating/tag-editor.tsx b/packages/insomnia/src/ui/components/templating/tag-editor.tsx index 210ba2d988..3808789db4 100644 --- a/packages/insomnia/src/ui/components/templating/tag-editor.tsx +++ b/packages/insomnia/src/ui/components/templating/tag-editor.tsx @@ -289,7 +289,7 @@ export const TagEditor: FC = props => { const tagDefinition = tagDefinitions.find(d => d.name === name) || null; update(state.tagDefinitions, tagDefinition, null, false); }} - value={activeTagDefinition ? activeTagDefinition.name : ''} + value={activeTagDefinition ? activeTagDefinition.name : 'custom'} > {state.tagDefinitions.map(tagDefinition => (