feat(templating): liquid block editor polish

This commit is contained in:
Kyle
2026-06-14 14:28:52 -04:00
parent b7774b0173
commit bc22984e74
15 changed files with 1279 additions and 415 deletions

View File

@@ -0,0 +1,3 @@
import { vi } from 'vitest';
export const getTemplateTags = vi.fn(async () => []);

View File

@@ -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 — &lt; stays &lt;, never becomes <
it('html-encoded payload is not double-decoded', async () => {
const encoded = '&lt;script&gt;alert(1)&lt;/script&gt;';
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('🔥💧');
});
});

View File

@@ -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 — &lt; stays &lt;, never becomes <
it('html-encoded payload is not double-decoded', async () => {
const encoded = '&lt;script&gt;alert(1)&lt;/script&gt;';
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('🔥💧');
});
});

View File

@@ -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');
});
});

View File

@@ -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()', () => {

View File

@@ -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);
}

View File

@@ -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[] = [];

View File

@@ -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),

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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));
}

View File

@@ -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;
}
}

View File

@@ -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">

View File

@@ -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;

View File

@@ -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%;