diff --git a/packages/@glimmer/syntax/lib/parser.ts b/packages/@glimmer/syntax/lib/parser.ts index 1463f967d..2724b9fbc 100644 --- a/packages/@glimmer/syntax/lib/parser.ts +++ b/packages/@glimmer/syntax/lib/parser.ts @@ -76,6 +76,8 @@ export abstract class Parser { // node.loc = node.loc.withEnd(end); } + abstract parse(node: HBS.Program, locals: string[]): ASTv1.Template; + abstract Program(node: HBS.Program): HBS.Output<'Program'>; abstract MustacheStatement(node: HBS.MustacheStatement): HBS.Output<'MustacheStatement'>; abstract Decorator(node: HBS.Decorator): HBS.Output<'Decorator'>; @@ -149,14 +151,6 @@ export abstract class Parser { return node; } - acceptTemplate(node: HBS.Program): ASTv1.Template { - let result = this.Program(node); - assert(result.type === 'Template', 'expected a template'); - return result; - } - - acceptNode(node: HBS.Program): ASTv1.Block | ASTv1.Template; - acceptNode(node: HBS.Node): U; acceptNode(node: HBS.Node): HBS.Output { return (this[node.type as T] as (node: HBS.Node) => HBS.Output)(node); } diff --git a/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts b/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts index 8719cb116..b23dceeb0 100644 --- a/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts +++ b/packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts @@ -1,8 +1,9 @@ import type { Nullable, Recast } from '@glimmer/interfaces'; import type { TokenizerState } from 'simple-html-tokenizer'; -import { getLast, isPresentArray, unwrap } from '@glimmer/util'; +import { assert, getLast, isPresentArray, unwrap } from '@glimmer/util'; import type { ParserNodeBuilder, Tag } from '../parser'; +import type { SourceSpan } from '../source/span'; import type * as ASTv1 from '../v1/api'; import type * as HBS from '../v1/handlebars-ast'; @@ -24,46 +25,62 @@ export abstract class HandlebarsNodeVisitors extends Parser { return this.elementStack.length === 0; } - Program(program: HBS.Program): ASTv1.Block; - Program(program: HBS.Program): ASTv1.Template; - Program(program: HBS.Program): ASTv1.Template | ASTv1.Block; - Program(program: HBS.Program): ASTv1.Block | ASTv1.Template { - const body: ASTv1.Statement[] = []; - let node; - - if (this.isTopLevel) { - node = b.template({ - body, - loc: this.source.spanFor(program.loc), - }); - } else { - node = b.blockItself({ - body, - blockParams: program.blockParams, - chained: program.chained, - loc: this.source.spanFor(program.loc), - }); - } + parse(program: HBS.Program, locals: string[]): ASTv1.Template { + let node = b.template({ + body: [], + locals, + loc: this.source.spanFor(program.loc), + }); - let i, - l = program.body.length; + return this.parseProgram(node, program); + } - this.elementStack.push(node); + Program(program: HBS.Program, blockParams?: ASTv1.VarHead[]): ASTv1.Block { + // The abstract signature doesn't have the blockParams argument, but in + // practice we can only come from this.BlockStatement() which adds the + // extra argument for us + assert( + Array.isArray(blockParams), + '[BUG] Program in parser unexpectedly called without block params' + ); - if (l === 0) { - return this.elementStack.pop() as ASTv1.Block | ASTv1.Template; + let node = b.blockItself({ + body: [], + params: blockParams, + chained: program.chained, + loc: this.source.spanFor(program.loc), + }); + + return this.parseProgram(node, program); + } + + private parseProgram(node: T, program: HBS.Program): T { + if (program.body.length === 0) { + return node; } - for (i = 0; i < l; i++) { - this.acceptNode(unwrap(program.body[i])); + let poppedNode; + + try { + this.elementStack.push(node); + + for (let child of program.body) { + this.acceptNode(child); + } + } finally { + poppedNode = this.elementStack.pop(); } // Ensure that that the element stack is balanced properly. - const poppedNode = this.elementStack.pop(); - if (poppedNode !== node) { - const elementNode = poppedNode as ASTv1.ElementNode; - - throw generateSyntaxError(`Unclosed element \`${elementNode.tag}\``, elementNode.loc); + if (node !== poppedNode) { + if (poppedNode?.type === 'ElementNode') { + throw generateSyntaxError(`Unclosed element \`${poppedNode.tag}\``, poppedNode.loc); + } else { + // If the stack is not balanced, then it is likely our own bug, because + // any unclosed Handlebars blocks should already been caught by now + assert(poppedNode !== undefined, '[BUG] empty parser elementStack'); + assert(false, `[BUG] mismatched parser elementStack node: ${node.type}`); + } } return node; @@ -83,6 +100,65 @@ export abstract class HandlebarsNodeVisitors extends Parser { } const { path, params, hash } = acceptCallNodes(this, block); + const loc = this.source.spanFor(block.loc); + + // Backfill block params loc for the default block + let blockParams: ASTv1.VarHead[] = []; + + if (block.program.blockParams?.length) { + // Start from right after the hash + let span = hash.loc.collapse('end'); + + // Extend till the beginning of the block + if (block.program.loc) { + span = span.withEnd(this.source.spanFor(block.program.loc).getStart()); + } else if (block.program.body[0]) { + span = span.withEnd(this.source.spanFor(block.program.body[0].loc).getStart()); + } else { + // ...or if all else fail, use the end of the block statement + // this can only happen if the block statement is empty anyway + span = span.withEnd(loc.getEnd()); + } + + // Now we have a span for something like this: + // + // {{#foo bar baz=bat as |wow wat|}} + // ~~~~~~~~~~~~~~~ + // + // Or, if we are unlucky: + // + // {{#foo bar baz=bat as |wow wat|}}{{/foo}} + // ~~~~~~~~~~~~~~~~~~~~~~~ + // + // Either way, within this span, there should be exactly two pipes + // fencing our block params, neatly whitespace separated and with + // legal identifiers only + const content = span.asString(); + let skipStart = content.indexOf('|') + 1; + const limit = content.indexOf('|', skipStart); + + for (const name of block.program.blockParams) { + let nameStart: number; + let loc: SourceSpan; + + if (skipStart >= limit) { + nameStart = -1; + } else { + nameStart = content.indexOf(name, skipStart); + } + + if (nameStart === -1 || nameStart + name.length > limit) { + skipStart = limit; + loc = this.source.spanFor(NON_EXISTENT_LOCATION); + } else { + skipStart = nameStart; + loc = span.sliceStartChars({ skipStart, chars: name.length }); + skipStart += name.length; + } + + blockParams.push(b.var({ name, loc })); + } + } // These are bugs in Handlebars upstream if (!block.program.loc) { @@ -93,8 +169,8 @@ export abstract class HandlebarsNodeVisitors extends Parser { block.inverse.loc = NON_EXISTENT_LOCATION; } - const program = this.Program(block.program); - const inverse = block.inverse ? this.Program(block.inverse) : null; + const program = this.Program(block.program, blockParams); + const inverse = block.inverse ? this.Program(block.inverse, []) : null; const node = b.block({ path, @@ -126,7 +202,7 @@ export abstract class HandlebarsNodeVisitors extends Parser { if (isHBSLiteral(rawMustache.path)) { mustache = b.mustache({ - path: this.acceptNode(rawMustache.path), + path: this.acceptNode<(typeof rawMustache.path)['type']>(rawMustache.path), params: [], hash: b.hash({ pairs: [], loc: this.source.spanFor(rawMustache.path.loc).collapse('end') }), trusting: !escaped, @@ -388,7 +464,7 @@ export abstract class HandlebarsNodeVisitors extends Parser { const pairs = hash.pairs.map((pair) => b.pair({ key: pair.key, - value: this.acceptNode(pair.value), + value: this.acceptNode(pair.value), loc: this.source.spanFor(pair.loc), }) ); @@ -536,7 +612,7 @@ function acceptCallNodes( } const params = node.params - ? node.params.map((e) => compiler.acceptNode(e)) + ? node.params.map((e) => compiler.acceptNode(e)) : []; // if there is no hash, position it as a collapsed node immediately after the last param (or the diff --git a/packages/@glimmer/syntax/lib/parser/tokenizer-event-handlers.ts b/packages/@glimmer/syntax/lib/parser/tokenizer-event-handlers.ts index bc3453a92..c1ed5e258 100644 --- a/packages/@glimmer/syntax/lib/parser/tokenizer-event-handlers.ts +++ b/packages/@glimmer/syntax/lib/parser/tokenizer-event-handlers.ts @@ -441,7 +441,10 @@ export function preprocess( end: offsets.endPosition, }; - let template = new TokenizerEventHandlers(source, entityParser, mode).acceptTemplate(ast); + let template = new TokenizerEventHandlers(source, entityParser, mode).parse( + ast, + options.locals ?? [] + ); if (options.strictMode && options.locals?.length) { template = b.template({ ...template, locals: options.locals }); diff --git a/packages/@glimmer/syntax/lib/source/loc/span.ts b/packages/@glimmer/syntax/lib/source/loc/span.ts index 73c50a0db..627de87a7 100644 --- a/packages/@glimmer/syntax/lib/source/loc/span.ts +++ b/packages/@glimmer/syntax/lib/source/loc/span.ts @@ -190,7 +190,7 @@ export class SourceSpan implements SourceLocation { /** * Create a new span with the current span's beginning and a new ending. */ - withEnd(this: SourceSpan, other: SourceOffset): SourceSpan { + withEnd(other: SourceOffset): SourceSpan { return span(this.data.getStart(), other.data); } diff --git a/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts b/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts index 9a70737fd..371503f7b 100644 --- a/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts +++ b/packages/@glimmer/syntax/lib/v1/handlebars-ast.ts @@ -13,7 +13,7 @@ export interface CommonNode { } export interface NodeMap { - Program: { input: Program; output: ASTv1.Template | ASTv1.Block }; + Program: { input: Program; output: ASTv1.Block }; MustacheStatement: { input: MustacheStatement; output: ASTv1.MustacheStatement | void }; Decorator: { input: Decorator; output: never }; BlockStatement: { input: BlockStatement; output: ASTv1.BlockStatement | void }; @@ -50,7 +50,7 @@ export interface Position { export interface Program extends CommonNode { type: 'Program'; body: Statement[]; - blockParams: string[]; + blockParams?: string[]; chained?: boolean; } diff --git a/packages/@glimmer/syntax/lib/v1/nodes-v1.ts b/packages/@glimmer/syntax/lib/v1/nodes-v1.ts index 7f805a78a..de73fc9ca 100644 --- a/packages/@glimmer/syntax/lib/v1/nodes-v1.ts +++ b/packages/@glimmer/syntax/lib/v1/nodes-v1.ts @@ -16,8 +16,13 @@ export interface CommonProgram extends BaseNode { export interface Block extends CommonProgram { type: 'Block'; - blockParams: string[]; + params: VarHead[]; chained?: boolean; + + /** + * string accessor for params.name + */ + blockParams: string[]; } export type EntityEncodingState = 'transformed' | 'raw'; @@ -302,6 +307,34 @@ export type Nodes = { export type NodeType = keyof Nodes; export type Node = Nodes[NodeType]; +// These "sub-node" cannot appear standalone, they are only used inside another +// "real" AST node to provide richer information. The distinction mostly exists +// for backwards compatibility reason. These nodes are not traversed and do not +// have visitor keys for them, so it won't break existing AST consumers (e.g. +// those that implemented an `All` visitor may not be expecting these new types +// of nodes). +// +// Conceptually, the idea of "sub-node" does make sense, and you can say source +// locations are another kind of these things. However, in these cases, they +// actually fully implement the `BaseNode` interface, and only not extending +// `BaseNode` because the `type` field is not `keyof Nodes` (which is circular +// reasoning). If these are not "real" nodes because they can only appear in +// very limited context, then the same reasoning probably applies for, say, +// HashPair. +// +// If we do eventually make some kind of breaking change here, perhaps with +// some kind of opt-in, then we can consider upgrading these into "real" nodes, +// but for now, this is where they go, and it isn't a huge problem in practice +// because there are little utility in traversing these kind of nodes anyway. +export type SubNodes = { + ThisHead: ThisHead; + AtHead: AtHead; + VarHead: VarHead; +}; + +export type SubNodeType = keyof SubNodes; +export type SubNode = SubNodes[SubNodeType]; + export type Statement = Nodes[StatementName]; export type Statements = Pick; export type Literal = Nodes[LiteralName]; diff --git a/packages/@glimmer/syntax/lib/v1/parser-builders.ts b/packages/@glimmer/syntax/lib/v1/parser-builders.ts index d37392002..a63d86e8b 100644 --- a/packages/@glimmer/syntax/lib/v1/parser-builders.ts +++ b/packages/@glimmer/syntax/lib/v1/parser-builders.ts @@ -1,10 +1,9 @@ -import type { Dict, Nullable, Optional, PresentArray } from '@glimmer/interfaces'; +import type { Nullable, Optional, PresentArray } from '@glimmer/interfaces'; import { assert } from '@glimmer/util'; -import type { SourceLocation } from '../source/location'; -import type { SourceSpan } from '../source/span'; import type * as ASTv1 from './api'; +import { SourceSpan } from '../source/span'; import { buildLegacyLiteral, buildLegacyMustache, @@ -32,32 +31,40 @@ class Builders { } blockItself({ - body = [], - blockParams = [], + body, + params, chained = false, loc, }: { - body?: ASTv1.Statement[] | undefined; - blockParams?: string[] | undefined; - chained?: boolean | undefined; + body: ASTv1.Statement[]; + params: ASTv1.VarHead[]; + chained?: Optional; loc: SourceSpan; }): ASTv1.Block { return { type: 'Block', - body: body, - blockParams: blockParams, + body, + params, + get blockParams() { + return this.params.map((p) => p.name); + }, + set blockParams(params: string[]) { + this.params = params.map((name) => { + return b.var({ name, loc: SourceSpan.broken() }); + }); + }, chained, loc, }; } template({ - body = [], - locals = [], + body, + locals, loc, }: { - body?: ASTv1.Statement[]; - locals?: string[]; + body: ASTv1.Statement[]; + locals: string[]; loc: SourceSpan; }): ASTv1.Template { return buildLegacyTemplate({ @@ -293,7 +300,7 @@ class Builders { }; } - atName({ name, loc }: { name: string; loc: SourceSpan }): ASTv1.PathHead { + atName({ name, loc }: { name: string; loc: SourceSpan }): ASTv1.AtHead { let _name = ''; const node = { @@ -324,7 +331,7 @@ class Builders { return node; } - var({ name, loc }: { name: string; loc: SourceSpan }): ASTv1.PathHead { + var({ name, loc }: { name: string; loc: SourceSpan }): ASTv1.VarHead { let _name = ''; const node = { @@ -400,34 +407,6 @@ class Builders { } } -// Nodes - -export type ElementParts = - | ['attrs', ...AttrSexp[]] - | ['modifiers', ...ModifierSexp[]] - | ['body', ...ASTv1.Statement[]] - | ['comments', ...ASTv1.MustacheCommentStatement[]] - | ['as', ...string[]] - | ['loc', SourceLocation]; - -export type PathSexp = string | ['path', string, LocSexp?]; - -export type ModifierSexp = - | string - | [PathSexp, LocSexp?] - | [PathSexp, ASTv1.Expression[], LocSexp?] - | [PathSexp, ASTv1.Expression[], Dict, LocSexp?]; - -export type AttrSexp = [string, ASTv1.AttrNode['value'] | string, LocSexp?]; - -export type LocSexp = ['loc', SourceLocation]; - -export type SexpValue = - | string - | ASTv1.Expression[] - | Dict - | LocSexp - | PathSexp - | undefined; +const b = new Builders(); -export default new Builders(); +export default b; diff --git a/packages/@glimmer/syntax/lib/v1/public-builders.ts b/packages/@glimmer/syntax/lib/v1/public-builders.ts index f8e04dc29..3fa935f8b 100644 --- a/packages/@glimmer/syntax/lib/v1/public-builders.ts +++ b/packages/@glimmer/syntax/lib/v1/public-builders.ts @@ -63,7 +63,7 @@ function buildBlock( if (_defaultBlock.type === 'Template') { deprecate(`b.program is deprecated. Use b.blockItself instead.`); defaultBlock = b.blockItself({ - blockParams: [..._defaultBlock.locals], + params: buildBlockParams(_defaultBlock.locals), body: _defaultBlock.body, loc: _defaultBlock.loc, }); @@ -73,9 +73,10 @@ function buildBlock( if (_elseBlock !== undefined && _elseBlock !== null && _elseBlock.type === 'Template') { deprecate(`b.program is deprecated. Use b.blockItself instead.`); + assert(_elseBlock.locals.length === 0, '{{else}} block cannot have block params'); elseBlock = b.blockItself({ - blockParams: [..._elseBlock.locals], + params: [], body: _elseBlock.body, loc: _elseBlock.loc, }); @@ -347,15 +348,21 @@ function buildProgram( } } +function buildBlockParams(params: ReadonlyArray): ASTv1.VarHead[] { + return params.map((p) => + typeof p === 'string' ? b.var({ name: p, loc: SourceSpan.broken() }) : p + ); +} + function buildBlockItself( body: ASTv1.Statement[] = [], - blockParams: string[] = [], + params: Array = [], chained = false, loc?: SourceLocation ): ASTv1.Block { return b.blockItself({ body, - blockParams, + params: buildBlockParams(params), chained, loc: buildLoc(loc || null), }); diff --git a/packages/@glimmer/syntax/test/loc-node-test.ts b/packages/@glimmer/syntax/test/loc-node-test.ts index c41a43194..d3eec8896 100644 --- a/packages/@glimmer/syntax/test/loc-node-test.ts +++ b/packages/@glimmer/syntax/test/loc-node-test.ts @@ -4,24 +4,28 @@ import { guardArray } from '@glimmer-workspace/test-utils'; QUnit.module('[glimmer-syntax] Parser - Location Info'); -function assertNodeType( - node: AST.Node | null | undefined, +function assertNodeType(node: unknown, type: T): node is AST.Nodes[T]; +function assertNodeType( + node: unknown, type: T -): node is AST.Nodes[T] { - let nodeType = node && node.type; - QUnit.assert.pushResult({ - result: nodeType === type, - actual: nodeType, - expected: type, - message: `expected node type to be ${type} but was ${String(nodeType)}`, - }); +): node is AST.SubNodes[T]; +function assertNodeType(node: unknown, type: string): boolean { + let nodeType: unknown = undefined; + + try { + nodeType = (node as { type?: unknown } | null | undefined)?.type; + } catch { + // no-op + } + + QUnit.assert.strictEqual(nodeType, type, `expected node type to be ${type}`); return nodeType === type; } const { test } = QUnit; function locEqual( - node: AST.Node | null | undefined, + node: AST.Node | AST.SubNode | null | undefined, startLine: number, startColumn: number, endLine: number, @@ -201,6 +205,116 @@ test('mustache + newline + element ', () => { locEqual(p, 3, 4, 3, 14, 'p element'); }); +test('block with block params', () => { + let ast = parse(` + {{#foo as |bar bat baz|}} + {{bar}} {{bat}} {{baz}} + {{/foo}} + `); + + let statement = ast.body[1]; + if (assertNodeType(statement, 'BlockStatement')) { + let block = statement.program; + + if (assertNodeType(block.params[0], 'VarHead')) { + locEqual(block.params[0], 2, 15, 2, 18, 'bar'); + } + + if (assertNodeType(block.params[1], 'VarHead')) { + locEqual(block.params[1], 2, 19, 2, 22, 'bat'); + } + + if (assertNodeType(block.params[2], 'VarHead')) { + locEqual(block.params[2], 2, 23, 2, 26, 'baz'); + } + } +}); + +test('block with block params edge case: multiline', () => { + let ast = parse(` + {{#foo as +|bar bat + b +a + z|}} + {{bar}} {{bat}} {{baz}} + {{/foo}} + `); + + let statement = ast.body[1]; + if (assertNodeType(statement, 'BlockStatement')) { + let block = statement.program; + + if (assertNodeType(block.params[0], 'VarHead')) { + locEqual(block.params[0], 3, 1, 3, 4, 'bar'); + } + + if (assertNodeType(block.params[1], 'VarHead')) { + locEqual(block.params[1], 3, 5, 3, 8, 'bat'); + } + + if (assertNodeType(block.params[2], 'VarHead')) { + locEqual(block.params[2], 4, 6, 4, 7, 'b'); + } + + if (assertNodeType(block.params[3], 'VarHead')) { + locEqual(block.params[3], 5, 0, 5, 1, 'a'); + } + + if (assertNodeType(block.params[4], 'VarHead')) { + locEqual(block.params[4], 6, 6, 6, 7, 'z'); + } + } +}); + +test('block with block params edge case: block-params like params', () => { + let ast = parse(` + {{#foo "as |bar bat baz|" as |bar bat baz|}} + {{bar}} {{bat}} {{baz}} + {{/foo}} + `); + + let statement = ast.body[1]; + if (assertNodeType(statement, 'BlockStatement')) { + let block = statement.program; + + if (assertNodeType(block.params[0], 'VarHead')) { + locEqual(block.params[0], 2, 34, 2, 37, 'bar'); + } + + if (assertNodeType(block.params[1], 'VarHead')) { + locEqual(block.params[1], 2, 38, 2, 41, 'bat'); + } + + if (assertNodeType(block.params[2], 'VarHead')) { + locEqual(block.params[2], 2, 42, 2, 45, 'baz'); + } + } +}); + +test('block with block params edge case: block-params like content', () => { + let ast = parse(` + {{#foo as |bar bat baz|}}as |bar bat baz|{{/foo}} + `); + + let statement = ast.body[1]; + if (assertNodeType(statement, 'BlockStatement')) { + let block = statement.program; + + if (assertNodeType(block.params[0], 'VarHead')) { + locEqual(block.params[0], 2, 15, 2, 18, 'bar'); + } + + if (assertNodeType(block.params[1], 'VarHead')) { + locEqual(block.params[1], 2, 19, 2, 22, 'bat'); + } + + if (assertNodeType(block.params[2], 'VarHead')) { + locEqual(block.params[2], 2, 23, 2, 26, 'baz'); + } + } +}); + test('blocks with nested html elements', () => { let ast = parse(` {{#foo-bar}}
Foo
{{/foo-bar}}

Hi!

diff --git a/packages/@glimmer/syntax/test/parser-node-test.ts b/packages/@glimmer/syntax/test/parser-node-test.ts index 1269e570f..1b1644a33 100644 --- a/packages/@glimmer/syntax/test/parser-node-test.ts +++ b/packages/@glimmer/syntax/test/parser-node-test.ts @@ -251,6 +251,83 @@ test('Involved block helper', () => { ); }); +test('block with block params', () => { + let t = `{{#foo as |bar bat baz|}}{{bar}} {{bat}} {{baz}}{{/foo}}`; + + astEqual( + t, + b.template([ + b.block( + b.path('foo'), + null, + null, + b.blockItself( + [b.mustache('bar'), b.text(' '), b.mustache('bat'), b.text(' '), b.mustache('baz')], + ['bar', 'bat', 'baz'] + ) + ), + ]) + ); +}); + +test('block with block params edge case: multiline', () => { + let t = `{{#foo as +|bar bat + b +a + z|}}{{bar}} {{bat}} {{baz}}{{/foo}}`; + + astEqual( + t, + b.template([ + b.block( + b.path('foo'), + null, + null, + b.blockItself( + [b.mustache('bar'), b.text(' '), b.mustache('bat'), b.text(' '), b.mustache('baz')], + ['bar', 'bat', 'b', 'a', 'z'] + ) + ), + ]) + ); +}); + +test('block with block params edge case: block-params like params', () => { + let t = `{{#foo "as |a b c|" as |bar bat baz|}}{{bar}} {{bat}} {{baz}}{{/foo}}`; + + astEqual( + t, + b.template([ + b.block( + b.path('foo'), + [b.string('as |a b c|')], + null, + b.blockItself( + [b.mustache('bar'), b.text(' '), b.mustache('bat'), b.text(' '), b.mustache('baz')], + ['bar', 'bat', 'baz'] + ) + ), + ]) + ); +}); + +test('block with block params edge case: block-params like content', () => { + let t = `{{#foo as |bar bat baz|}}as |a b c|{{/foo}}`; + + astEqual( + t, + b.template([ + b.block( + b.path('foo'), + null, + null, + b.blockItself([b.text('as |a b c|')], ['bar', 'bat', 'baz']) + ), + ]) + ); +}); + test('Element modifiers', () => { let t = "

Some content

"; astEqual(