mirror of
https://github.com/Kong/insomnia.git
synced 2026-08-04 03:42:20 -04:00
feat(templating): liquid block editor polish
This commit is contained in:
3
packages/insomnia/src/plugins/__mocks__/index.ts
Normal file
3
packages/insomnia/src/plugins/__mocks__/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const getTemplateTags = vi.fn(async () => []);
|
||||
@@ -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 = '<script>alert("xss")</script>';
|
||||
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 = '<svg onload="alert(1)">';
|
||||
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', '<script>' | replace: 'CLOSE', '</script>' }}",
|
||||
{ context: { v: 'OPENalert(1)CLOSE' } },
|
||||
);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
// 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: '<Script>alert(1)</Script>' } });
|
||||
expect(result).toBe('<SCRIPT>ALERT(1)</SCRIPT>');
|
||||
});
|
||||
});
|
||||
|
||||
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 %}<script>{% endcapture %}{% capture end %}</script>{% endcapture %}{{ tag }}alert(1){{ end }}',
|
||||
{ context: {} },
|
||||
);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
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('🔥💧');
|
||||
});
|
||||
});
|
||||
@@ -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 = '<script>alert("xss")</script>';
|
||||
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 = '<svg onload="alert(1)">';
|
||||
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', '<script>' | replace: 'CLOSE', '</script>' }}",
|
||||
{ context: { v: 'OPENalert(1)CLOSE' } },
|
||||
);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
// 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: '<Script>alert(1)</Script>' } });
|
||||
expect(result).toBe('<SCRIPT>ALERT(1)</SCRIPT>');
|
||||
});
|
||||
});
|
||||
|
||||
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 %}<script>{% endcapture %}{% capture end %}</script>{% endcapture %}{{ tag }}alert(1){{ end }}',
|
||||
{ context: {} },
|
||||
);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
|
||||
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('🔥💧');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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()', () => {
|
||||
|
||||
@@ -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<string> {
|
||||
const defined = new Set<string>();
|
||||
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, any>): 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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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<RenderContextAndKeys>,
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ export const RequestPane: FC<Props> = ({ 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 <PlaceholderRequestPane />;
|
||||
@@ -292,7 +295,7 @@ export const RequestPane: FC<Props> = ({ environmentId, settings, onPaste }) =>
|
||||
</PanelGroup>
|
||||
</TabPanel>
|
||||
<TabPanel className="flex w-full flex-1 flex-col" id="content-type">
|
||||
<BodyEditor key={uniqueKey} request={activeRequest} environmentId={environmentId} />
|
||||
<BodyEditor key={bodyEditorKey} request={activeRequest} environmentId={environmentId} />
|
||||
</TabPanel>
|
||||
<TabPanel className="flex w-full flex-1 flex-col overflow-hidden" id="auth">
|
||||
<ErrorBoundary key={uniqueKey} errorClassName="font-error pad text-center">
|
||||
|
||||
@@ -289,7 +289,7 @@ export const TagEditor: FC<Props> = 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 => (
|
||||
<option key={tagDefinition.name} value={tagDefinition.name}>
|
||||
@@ -592,8 +592,11 @@ export const TagEditor: FC<Props> = props => {
|
||||
<div className="form-control form-control--outlined">
|
||||
<label>
|
||||
Custom
|
||||
<input
|
||||
type="text"
|
||||
{/* A textarea (not a single-line input) so multi-line LiquidJS such as
|
||||
the `{% liquid … %}` master tag and `{% if %}…{% endif %}` blocks
|
||||
can be authored and edited with newlines. */}
|
||||
<textarea
|
||||
rows={5}
|
||||
defaultValue={activeTagData.rawValue}
|
||||
onChange={event => {
|
||||
const { tagDefinitions, activeTagData, activeTagDefinition } = state;
|
||||
|
||||
@@ -1848,6 +1848,13 @@ html {
|
||||
background: var(--hl-xl);
|
||||
color: bar(--color-font);
|
||||
}
|
||||
/* Connector bar drawn down the left of the lines spanned by a block tag
|
||||
({% if %}…{% endif %}, {% for %}…{% endfor %}, …) so the delimiters read as
|
||||
one statement. Applied via addLineClass(..., 'wrap', 'nunjucks-block-line'). */
|
||||
.editor .nunjucks-block-line {
|
||||
box-shadow: inset 2px 0 0 var(--color-info);
|
||||
background: var(--hl-xxs);
|
||||
}
|
||||
.app {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user