@mkdevofficial/liquid-compiler 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -4
- package/src/analyzer.js +93 -0
- package/src/codegen.js +581 -0
- package/src/css.js +151 -0
- package/src/errors.js +52 -0
- package/src/index.js +53 -0
- package/src/parser.js +428 -0
- package/src/sourcemap.js +90 -0
- package/src/tokenizer.js +509 -0
- package/README.md +0 -3
package/src/css.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped CSS Transformer for Liquid components.
|
|
3
|
+
* Appends [data-l-xxxx] to component selectors, preserving :global() and @global {}.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function transformScopedCSS(css, scopeAttr) {
|
|
7
|
+
if (!css || !css.trim()) return '';
|
|
8
|
+
|
|
9
|
+
let output = '';
|
|
10
|
+
let i = 0;
|
|
11
|
+
const len = css.length;
|
|
12
|
+
|
|
13
|
+
while (i < len) {
|
|
14
|
+
// Skip whitespace
|
|
15
|
+
while (i < len && /\s/.test(css[i])) {
|
|
16
|
+
output += css[i];
|
|
17
|
+
i++;
|
|
18
|
+
}
|
|
19
|
+
if (i >= len) break;
|
|
20
|
+
|
|
21
|
+
// Check comments
|
|
22
|
+
if (css[i] === '/' && css[i + 1] === '*') {
|
|
23
|
+
const endComment = css.indexOf('*/', i + 2);
|
|
24
|
+
if (endComment === -1) {
|
|
25
|
+
output += css.slice(i);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
output += css.slice(i, endComment + 2);
|
|
29
|
+
i = endComment + 2;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check for @global { ... }
|
|
34
|
+
if (css.slice(i).startsWith('@global')) {
|
|
35
|
+
const openBrace = css.indexOf('{', i);
|
|
36
|
+
if (openBrace !== -1) {
|
|
37
|
+
const matchingClose = findMatchingBrace(css, openBrace);
|
|
38
|
+
if (matchingClose !== -1) {
|
|
39
|
+
const innerCss = css.slice(openBrace + 1, matchingClose);
|
|
40
|
+
output += innerCss;
|
|
41
|
+
i = matchingClose + 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check for @keyframes or other at-rules
|
|
48
|
+
if (css[i] === '@') {
|
|
49
|
+
const atRuleMatch = css.slice(i).match(/^@([a-zA-Z0-9_-]+)/);
|
|
50
|
+
if (atRuleMatch) {
|
|
51
|
+
const atRuleName = atRuleMatch[1].toLowerCase();
|
|
52
|
+
const openBrace = css.indexOf('{', i);
|
|
53
|
+
if (openBrace !== -1) {
|
|
54
|
+
const header = css.slice(i, openBrace + 1);
|
|
55
|
+
const closeBrace = findMatchingBrace(css, openBrace);
|
|
56
|
+
if (closeBrace !== -1) {
|
|
57
|
+
const body = css.slice(openBrace + 1, closeBrace);
|
|
58
|
+
if (atRuleName === 'media' || atRuleName === 'supports') {
|
|
59
|
+
output += header + transformScopedCSS(body, scopeAttr) + '}';
|
|
60
|
+
} else {
|
|
61
|
+
// Keyframes, font-face, etc. output untouched
|
|
62
|
+
output += header + body + '}';
|
|
63
|
+
}
|
|
64
|
+
i = closeBrace + 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Read selector until '{'
|
|
72
|
+
const openBrace = css.indexOf('{', i);
|
|
73
|
+
if (openBrace === -1) {
|
|
74
|
+
output += css.slice(i);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rawSelector = css.slice(i, openBrace).trim();
|
|
79
|
+
const closeBrace = findMatchingBrace(css, openBrace);
|
|
80
|
+
if (closeBrace === -1) {
|
|
81
|
+
output += css.slice(i);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const decls = css.slice(openBrace, closeBrace + 1);
|
|
86
|
+
const scopedSelector = scopeSelectorList(rawSelector, scopeAttr);
|
|
87
|
+
output += scopedSelector + ' ' + decls + '\n';
|
|
88
|
+
i = closeBrace + 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return output.trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findMatchingBrace(str, openIdx) {
|
|
95
|
+
let depth = 1;
|
|
96
|
+
for (let j = openIdx + 1; j < str.length; j++) {
|
|
97
|
+
if (str[j] === '{') depth++;
|
|
98
|
+
else if (str[j] === '}') {
|
|
99
|
+
depth--;
|
|
100
|
+
if (depth === 0) return j;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return -1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Scope a comma-separated selector list: e.g. "h1, p.lead, :global(.nav)"
|
|
108
|
+
*/
|
|
109
|
+
export function scopeSelectorList(selectorList, scopeAttr) {
|
|
110
|
+
const parts = selectorList.split(',');
|
|
111
|
+
const attrSelector = `[${scopeAttr}]`;
|
|
112
|
+
|
|
113
|
+
return parts
|
|
114
|
+
.map(sel => sel.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map(sel => {
|
|
117
|
+
// Check if :global(innerSelector)
|
|
118
|
+
if (sel.includes(':global(')) {
|
|
119
|
+
return sel.replace(/:global\((.*?)\)/g, '$1');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// If selector is :root, html, or body, keep as is
|
|
123
|
+
if (/^(:root|html|body)$/.test(sel)) {
|
|
124
|
+
return sel;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Split by combinators: spaces, >, +, ~
|
|
128
|
+
const tokens = sel.split(/(\s*[>+~]\s*|\s+)/);
|
|
129
|
+
const scopedTokens = tokens.map(token => {
|
|
130
|
+
const trimmed = token.trim();
|
|
131
|
+
// If it's a combinator, leave as is
|
|
132
|
+
if (!trimmed || trimmed === '>' || trimmed === '+' || trimmed === '~') {
|
|
133
|
+
return token;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Insert [data-l-xxxx] before pseudo-classes or pseudo-elements
|
|
137
|
+
const pseudoMatch = trimmed.match(/::?[a-zA-Z0-9_-]+/);
|
|
138
|
+
if (pseudoMatch) {
|
|
139
|
+
const pseudoIdx = pseudoMatch.index;
|
|
140
|
+
const base = trimmed.slice(0, pseudoIdx);
|
|
141
|
+
const rest = trimmed.slice(pseudoIdx);
|
|
142
|
+
return (base ? `${base}${attrSelector}` : attrSelector) + rest;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `${trimmed}${attrSelector}`;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return scopedTokens.join('');
|
|
149
|
+
})
|
|
150
|
+
.join(', ');
|
|
151
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liquid Syntax Error with line/column/caret excerpts.
|
|
3
|
+
*/
|
|
4
|
+
export class LiquidSyntaxError extends Error {
|
|
5
|
+
constructor(message, { file = 'anonymous.liq', line = 1, column = 1, source = '' } = {}) {
|
|
6
|
+
const formatted = formatExcerpt({ message, file, line, column, source });
|
|
7
|
+
super(formatted);
|
|
8
|
+
this.name = 'LiquidSyntaxError';
|
|
9
|
+
this.rawMessage = message;
|
|
10
|
+
this.file = file;
|
|
11
|
+
this.line = line;
|
|
12
|
+
this.column = column;
|
|
13
|
+
this.source = source;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatExcerpt({ message, file = 'anonymous.liq', line = 1, column = 1, source = '' }) {
|
|
18
|
+
const lines = source.split(/\r?\n/);
|
|
19
|
+
const targetLineIdx = line - 1;
|
|
20
|
+
|
|
21
|
+
const startIdx = Math.max(0, targetLineIdx - 2);
|
|
22
|
+
const endIdx = Math.min(lines.length - 1, targetLineIdx + 2);
|
|
23
|
+
|
|
24
|
+
const maxLineNumWidth = String(endIdx + 1).length;
|
|
25
|
+
|
|
26
|
+
const header = `\n Liquid Syntax Error: ${message}\n --> ${file}:${line}:${column}\n`;
|
|
27
|
+
const gutterPad = ' '.repeat(maxLineNumWidth);
|
|
28
|
+
|
|
29
|
+
const excerptLines = [];
|
|
30
|
+
excerptLines.push(`${gutterPad} |`);
|
|
31
|
+
|
|
32
|
+
for (let i = startIdx; i <= endIdx; i++) {
|
|
33
|
+
const lineNum = String(i + 1).padStart(maxLineNumWidth, ' ');
|
|
34
|
+
const rawContent = lines[i] ?? '';
|
|
35
|
+
// Display tabs visibly if present
|
|
36
|
+
const displayContent = rawContent.replace(/\t/g, '→ ');
|
|
37
|
+
excerptLines.push(`${lineNum} | ${displayContent}`);
|
|
38
|
+
|
|
39
|
+
if (i === targetLineIdx) {
|
|
40
|
+
// Calculate caret position considering expanded tabs
|
|
41
|
+
let caretCol = 0;
|
|
42
|
+
for (let c = 0; c < Math.min(column - 1, rawContent.length); c++) {
|
|
43
|
+
caretCol += rawContent[c] === '\t' ? 4 : 1;
|
|
44
|
+
}
|
|
45
|
+
const caret = ' '.repeat(caretCol) + '^';
|
|
46
|
+
excerptLines.push(`${gutterPad} | ${caret}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
excerptLines.push(`${gutterPad} |`);
|
|
51
|
+
return header + excerptLines.join('\n') + '\n';
|
|
52
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { tokenize } from './tokenizer.js';
|
|
2
|
+
import { parse } from './parser.js';
|
|
3
|
+
import { analyze } from './analyzer.js';
|
|
4
|
+
import { transformScopedCSS } from './css.js';
|
|
5
|
+
import { generate } from './codegen.js';
|
|
6
|
+
import { LiquidSyntaxError, formatExcerpt } from './errors.js';
|
|
7
|
+
|
|
8
|
+
export { tokenize } from './tokenizer.js';
|
|
9
|
+
export { parse } from './parser.js';
|
|
10
|
+
export { analyze } from './analyzer.js';
|
|
11
|
+
export { transformScopedCSS } from './css.js';
|
|
12
|
+
export { generate } from './codegen.js';
|
|
13
|
+
export { LiquidSyntaxError, formatExcerpt } from './errors.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Standalone Liquid compiler API.
|
|
17
|
+
* Compiles a .liq source string into readable ES module JS, scoped CSS, source map, and warnings.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} source
|
|
20
|
+
* @param {object} [options={}]
|
|
21
|
+
* @returns {{ code: string, map: object, css: string, warnings: Array<object>, ast: object }}
|
|
22
|
+
*/
|
|
23
|
+
export function compile(source, options = {}) {
|
|
24
|
+
const file = options.file || 'Component.liq';
|
|
25
|
+
|
|
26
|
+
// 1. Tokenize
|
|
27
|
+
const tokens = tokenize(source, { file, ...options });
|
|
28
|
+
|
|
29
|
+
// 2. Parse
|
|
30
|
+
const ast = parse(tokens, { file, source, ...options });
|
|
31
|
+
|
|
32
|
+
// 3. Analyze
|
|
33
|
+
const analysis = analyze(ast, { file, ...options });
|
|
34
|
+
|
|
35
|
+
// 4. Scoped CSS
|
|
36
|
+
const scopedCSS = transformScopedCSS(ast.styles.content, analysis.scopeAttr);
|
|
37
|
+
|
|
38
|
+
// 5. Code generation
|
|
39
|
+
const genResult = generate(ast, analysis, { file, ...options });
|
|
40
|
+
|
|
41
|
+
ast.scopeId = analysis.scopeId;
|
|
42
|
+
ast.scopeAttr = analysis.scopeAttr;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
code: genResult.code,
|
|
46
|
+
map: genResult.map,
|
|
47
|
+
css: scopedCSS,
|
|
48
|
+
warnings: analysis.warnings,
|
|
49
|
+
ast,
|
|
50
|
+
scopeId: analysis.scopeId,
|
|
51
|
+
scopeAttr: analysis.scopeAttr
|
|
52
|
+
};
|
|
53
|
+
}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { TokenType } from './tokenizer.js';
|
|
2
|
+
import { LiquidSyntaxError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse an interpolated string into raw text chunks and JS expressions.
|
|
6
|
+
* e.g. "Hello, {name}!" -> [{ type: 'raw', value: 'Hello, ' }, { type: 'expr', value: 'name' }, { type: 'raw', value: '!' }]
|
|
7
|
+
*/
|
|
8
|
+
export function parseInterpolatedText(str) {
|
|
9
|
+
const parts = [];
|
|
10
|
+
let i = 0;
|
|
11
|
+
const len = str.length;
|
|
12
|
+
let currentRaw = '';
|
|
13
|
+
|
|
14
|
+
while (i < len) {
|
|
15
|
+
if (str[i] === '\\' && str[i + 1] === '{') {
|
|
16
|
+
currentRaw += '{';
|
|
17
|
+
i += 2;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (str[i] === '{') {
|
|
22
|
+
if (currentRaw) {
|
|
23
|
+
parts.push({ type: 'raw', value: currentRaw });
|
|
24
|
+
currentRaw = '';
|
|
25
|
+
}
|
|
26
|
+
i++; // skip '{'
|
|
27
|
+
let depth = 1;
|
|
28
|
+
let expr = '';
|
|
29
|
+
let inQuote = null;
|
|
30
|
+
let escaped = false;
|
|
31
|
+
|
|
32
|
+
while (i < len && depth > 0) {
|
|
33
|
+
const c = str[i];
|
|
34
|
+
if (inQuote) {
|
|
35
|
+
expr += c;
|
|
36
|
+
if (escaped) {
|
|
37
|
+
escaped = false;
|
|
38
|
+
} else if (c === '\\') {
|
|
39
|
+
escaped = true;
|
|
40
|
+
} else if (c === inQuote) {
|
|
41
|
+
inQuote = null;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
45
|
+
inQuote = c;
|
|
46
|
+
expr += c;
|
|
47
|
+
} else if (c === '{') {
|
|
48
|
+
depth++;
|
|
49
|
+
expr += c;
|
|
50
|
+
} else if (c === '}') {
|
|
51
|
+
depth--;
|
|
52
|
+
if (depth > 0) expr += c;
|
|
53
|
+
} else {
|
|
54
|
+
expr += c;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
parts.push({ type: 'expr', value: expr.trim() });
|
|
60
|
+
} else {
|
|
61
|
+
currentRaw += str[i];
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (currentRaw) {
|
|
67
|
+
parts.push({ type: 'raw', value: currentRaw });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return parts;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse an 'each' statement:
|
|
75
|
+
* "items as item (item.id)" -> { collection: "items", item: "item", index: null, key: "item.id" }
|
|
76
|
+
* "items as item, idx (item.id)" -> { collection: "items", item: "item", index: "idx", key: "item.id" }
|
|
77
|
+
*/
|
|
78
|
+
export function parseEachArgs(argsString, loc, file, source) {
|
|
79
|
+
let trimmed = argsString.trim();
|
|
80
|
+
|
|
81
|
+
// Strip wrapping outer parentheses if developer wrapped the entire each statement in ( ... )
|
|
82
|
+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let closedEarly = false;
|
|
85
|
+
for (let i = 0; i < trimmed.length - 1; i++) {
|
|
86
|
+
if (trimmed[i] === '(') depth++;
|
|
87
|
+
else if (trimmed[i] === ')') depth--;
|
|
88
|
+
if (depth === 0) {
|
|
89
|
+
closedEarly = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!closedEarly) {
|
|
94
|
+
trimmed = trimmed.slice(1, -1).trim();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Match: <collection> as <item> [, <index>] [ (<key>) ]
|
|
99
|
+
const match = trimmed.match(/^(.+?)\s+as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*))?(?:\s*\((.+)\))?$/);
|
|
100
|
+
|
|
101
|
+
if (!match) {
|
|
102
|
+
throw new LiquidSyntaxError(
|
|
103
|
+
`Malformed 'each' statement: '${argsString}'. Expected 'each <collection> as <item> [, <index>] [(<key>)]'`,
|
|
104
|
+
{ file, line: loc.line, column: loc.column, source }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const collection = match[1].trim();
|
|
109
|
+
const item = match[2].trim();
|
|
110
|
+
const index = match[3] ? match[3].trim() : null;
|
|
111
|
+
const key = match[4] ? match[4].trim() : null;
|
|
112
|
+
|
|
113
|
+
return { collection, item, index, key };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse a flat list of tokens into a Program AST.
|
|
118
|
+
*/
|
|
119
|
+
export function parse(tokens, options = {}) {
|
|
120
|
+
const file = options.file || 'component.liq';
|
|
121
|
+
const source = options.source || '';
|
|
122
|
+
|
|
123
|
+
let logic = { content: '', line: 1, column: 1 };
|
|
124
|
+
let styles = { content: '', line: 1, column: 1 };
|
|
125
|
+
const template = [];
|
|
126
|
+
|
|
127
|
+
let pos = 0;
|
|
128
|
+
const total = tokens.length;
|
|
129
|
+
|
|
130
|
+
function peek() {
|
|
131
|
+
return tokens[pos] || { type: TokenType.EOF };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function advance() {
|
|
135
|
+
return tokens[pos++];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// First pass: extract sections or parse UI
|
|
139
|
+
while (pos < total) {
|
|
140
|
+
const token = peek();
|
|
141
|
+
|
|
142
|
+
if (token.type === TokenType.EOF) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (token.type === TokenType.SECTION) {
|
|
147
|
+
advance();
|
|
148
|
+
if (token.name === 'logic') {
|
|
149
|
+
logic = { content: token.content, line: token.line, column: token.column };
|
|
150
|
+
} else if (token.name === 'style') {
|
|
151
|
+
styles = { content: token.content, line: token.line, column: token.column };
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Must be UI tokens
|
|
157
|
+
const node = parseUINode();
|
|
158
|
+
if (node) {
|
|
159
|
+
template.push(node);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseUINode() {
|
|
164
|
+
const token = peek();
|
|
165
|
+
|
|
166
|
+
if (token.type === TokenType.EOF || token.type === TokenType.DEDENT) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (token.type === TokenType.TAG) {
|
|
171
|
+
return parseElement();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (token.type === TokenType.KEYWORD) {
|
|
175
|
+
return parseKeyword();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (token.type === TokenType.TEXT) {
|
|
179
|
+
advance();
|
|
180
|
+
return {
|
|
181
|
+
type: 'Text',
|
|
182
|
+
parts: parseInterpolatedText(token.value),
|
|
183
|
+
loc: { line: token.line, column: token.column }
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Skip stray tokens
|
|
188
|
+
advance();
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseElement() {
|
|
193
|
+
const tagToken = advance();
|
|
194
|
+
const attributes = [];
|
|
195
|
+
const events = [];
|
|
196
|
+
const bindings = [];
|
|
197
|
+
const children = [];
|
|
198
|
+
|
|
199
|
+
// Collect any attributes/directives that belong to this element
|
|
200
|
+
while (pos < total) {
|
|
201
|
+
const next = peek();
|
|
202
|
+
if (next.type === TokenType.ATTR) {
|
|
203
|
+
advance();
|
|
204
|
+
attributes.push({
|
|
205
|
+
name: next.name,
|
|
206
|
+
value: next.value,
|
|
207
|
+
isDynamic: next.isDynamic,
|
|
208
|
+
isBoolean: next.isBoolean
|
|
209
|
+
});
|
|
210
|
+
} else if (next.type === TokenType.DIRECTIVE_EVENT) {
|
|
211
|
+
advance();
|
|
212
|
+
events.push({
|
|
213
|
+
name: next.name,
|
|
214
|
+
modifiers: next.modifiers,
|
|
215
|
+
handler: next.handler
|
|
216
|
+
});
|
|
217
|
+
} else if (next.type === TokenType.DIRECTIVE_BIND) {
|
|
218
|
+
advance();
|
|
219
|
+
bindings.push({
|
|
220
|
+
prop: next.prop,
|
|
221
|
+
target: next.target
|
|
222
|
+
});
|
|
223
|
+
} else if (next.type === TokenType.TEXT && next.isInline) {
|
|
224
|
+
advance();
|
|
225
|
+
children.push({
|
|
226
|
+
type: 'Text',
|
|
227
|
+
parts: parseInterpolatedText(next.value),
|
|
228
|
+
loc: { line: next.line, column: next.column }
|
|
229
|
+
});
|
|
230
|
+
break; // inline text is at the end of the line
|
|
231
|
+
} else {
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check for child elements indented under this tag
|
|
237
|
+
if (peek().type === TokenType.INDENT) {
|
|
238
|
+
advance(); // consume INDENT
|
|
239
|
+
while (pos < total && peek().type !== TokenType.DEDENT && peek().type !== TokenType.EOF) {
|
|
240
|
+
const child = parseUINode();
|
|
241
|
+
if (child) children.push(child);
|
|
242
|
+
}
|
|
243
|
+
if (peek().type === TokenType.DEDENT) {
|
|
244
|
+
advance(); // consume DEDENT
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
type: 'Element',
|
|
250
|
+
tag: tagToken.name,
|
|
251
|
+
isComponent: tagToken.isComponent,
|
|
252
|
+
id: tagToken.id,
|
|
253
|
+
classes: tagToken.classes,
|
|
254
|
+
attributes,
|
|
255
|
+
events,
|
|
256
|
+
bindings,
|
|
257
|
+
children,
|
|
258
|
+
loc: { line: tagToken.line, column: tagToken.column }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseKeyword() {
|
|
263
|
+
const kwToken = advance();
|
|
264
|
+
const kw = kwToken.keyword;
|
|
265
|
+
const loc = { line: kwToken.line, column: kwToken.column };
|
|
266
|
+
|
|
267
|
+
if (kw === 'if') {
|
|
268
|
+
const test = kwToken.args.trim();
|
|
269
|
+
const consequent = [];
|
|
270
|
+
let alternate = null;
|
|
271
|
+
|
|
272
|
+
if (peek().type === TokenType.INDENT) {
|
|
273
|
+
advance(); // consume INDENT
|
|
274
|
+
while (pos < total && peek().type !== TokenType.DEDENT && peek().type !== TokenType.EOF) {
|
|
275
|
+
const child = parseUINode();
|
|
276
|
+
if (child) consequent.push(child);
|
|
277
|
+
}
|
|
278
|
+
if (peek().type === TokenType.DEDENT) {
|
|
279
|
+
advance(); // consume DEDENT
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Check for elseif / else siblings
|
|
284
|
+
if (peek().type === TokenType.KEYWORD) {
|
|
285
|
+
const nextKw = peek().keyword;
|
|
286
|
+
if (nextKw === 'elseif') {
|
|
287
|
+
alternate = parseKeyword();
|
|
288
|
+
} else if (nextKw === 'else') {
|
|
289
|
+
advance(); // consume 'else'
|
|
290
|
+
const elseBody = [];
|
|
291
|
+
if (peek().type === TokenType.INDENT) {
|
|
292
|
+
advance();
|
|
293
|
+
while (pos < total && peek().type !== TokenType.DEDENT && peek().type !== TokenType.EOF) {
|
|
294
|
+
const child = parseUINode();
|
|
295
|
+
if (child) elseBody.push(child);
|
|
296
|
+
}
|
|
297
|
+
if (peek().type === TokenType.DEDENT) {
|
|
298
|
+
advance();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
alternate = elseBody;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
type: 'IfBlock',
|
|
307
|
+
test,
|
|
308
|
+
consequent,
|
|
309
|
+
alternate,
|
|
310
|
+
loc
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (kw === 'elseif') {
|
|
315
|
+
// Used internally when chaining from `if`
|
|
316
|
+
const test = kwToken.args.trim();
|
|
317
|
+
const consequent = [];
|
|
318
|
+
let alternate = null;
|
|
319
|
+
|
|
320
|
+
if (peek().type === TokenType.INDENT) {
|
|
321
|
+
advance();
|
|
322
|
+
while (pos < total && peek().type !== TokenType.DEDENT && peek().type !== TokenType.EOF) {
|
|
323
|
+
const child = parseUINode();
|
|
324
|
+
if (child) consequent.push(child);
|
|
325
|
+
}
|
|
326
|
+
if (peek().type === TokenType.DEDENT) {
|
|
327
|
+
advance();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (peek().type === TokenType.KEYWORD) {
|
|
332
|
+
const nextKw = peek().keyword;
|
|
333
|
+
if (nextKw === 'elseif') {
|
|
334
|
+
alternate = parseKeyword();
|
|
335
|
+
} else if (nextKw === 'else') {
|
|
336
|
+
advance();
|
|
337
|
+
const elseBody = [];
|
|
338
|
+
if (peek().type === TokenType.INDENT) {
|
|
339
|
+
advance();
|
|
340
|
+
while (pos < total && peek().type !== TokenType.DEDENT && peek().type !== TokenType.EOF) {
|
|
341
|
+
const child = parseUINode();
|
|
342
|
+
if (child) elseBody.push(child);
|
|
343
|
+
}
|
|
344
|
+
if (peek().type === TokenType.DEDENT) {
|
|
345
|
+
advance();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
alternate = elseBody;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
type: 'IfBlock',
|
|
354
|
+
test,
|
|
355
|
+
consequent,
|
|
356
|
+
alternate,
|
|
357
|
+
loc
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (kw === 'each') {
|
|
362
|
+
const parsed = parseEachArgs(kwToken.args, loc, file, source);
|
|
363
|
+
const children = [];
|
|
364
|
+
|
|
365
|
+
if (peek().type === TokenType.INDENT) {
|
|
366
|
+
advance();
|
|
367
|
+
while (pos < total && peek().type !== TokenType.DEDENT && peek().type !== TokenType.EOF) {
|
|
368
|
+
const child = parseUINode();
|
|
369
|
+
if (child) children.push(child);
|
|
370
|
+
}
|
|
371
|
+
if (peek().type === TokenType.DEDENT) {
|
|
372
|
+
advance();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
type: 'EachBlock',
|
|
378
|
+
collection: parsed.collection,
|
|
379
|
+
item: parsed.item,
|
|
380
|
+
index: parsed.index,
|
|
381
|
+
key: parsed.key,
|
|
382
|
+
children,
|
|
383
|
+
loc
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (kw === 'slot') {
|
|
388
|
+
let name = null;
|
|
389
|
+
if (kwToken.args) {
|
|
390
|
+
const nameMatch = kwToken.args.match(/name=["']?([a-zA-Z0-9_-]+)["']?/);
|
|
391
|
+
if (nameMatch) {
|
|
392
|
+
name = nameMatch[1];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const fallback = [];
|
|
396
|
+
|
|
397
|
+
if (peek().type === TokenType.INDENT) {
|
|
398
|
+
advance();
|
|
399
|
+
while (pos < total && peek().type !== TokenType.DEDENT && peek().type !== TokenType.EOF) {
|
|
400
|
+
const child = parseUINode();
|
|
401
|
+
if (child) fallback.push(child);
|
|
402
|
+
}
|
|
403
|
+
if (peek().type === TokenType.DEDENT) {
|
|
404
|
+
advance();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
type: 'Slot',
|
|
410
|
+
name,
|
|
411
|
+
fallback,
|
|
412
|
+
loc
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
throw new LiquidSyntaxError(
|
|
417
|
+
`Unexpected keyword '${kw}' outside supported structure.`,
|
|
418
|
+
{ file, line: loc.line, column: loc.column, source }
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
type: 'Program',
|
|
424
|
+
logic,
|
|
425
|
+
styles,
|
|
426
|
+
template
|
|
427
|
+
};
|
|
428
|
+
}
|