@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/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
+ }