@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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Pure JavaScript Base64 VLQ Source Map Generator (v3)
3
+ */
4
+
5
+ const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
6
+
7
+ export function encodeVLQ(num) {
8
+ let result = '';
9
+ let vlq = num < 0 ? (-num << 1) | 1 : num << 1;
10
+ do {
11
+ let digit = vlq & 31;
12
+ vlq >>>= 5;
13
+ if (vlq > 0) {
14
+ digit |= 32;
15
+ }
16
+ result += BASE64_CHARS[digit];
17
+ } while (vlq > 0);
18
+ return result;
19
+ }
20
+
21
+ export class SourceMapGenerator {
22
+ constructor({ file = 'output.js', sourceRoot = '', sourceFile = 'input.liq' } = {}) {
23
+ this.file = file;
24
+ this.sourceRoot = sourceRoot;
25
+ this.sourceFile = sourceFile;
26
+ this.mappings = []; // list of mappings per generated line
27
+ this.sources = [sourceFile];
28
+ this.names = [];
29
+ }
30
+
31
+ addMapping({ generatedLine, generatedColumn, originalLine, originalColumn }) {
32
+ this.mappings.push({
33
+ genLine: generatedLine,
34
+ genCol: generatedColumn,
35
+ origLine: originalLine,
36
+ origCol: originalColumn
37
+ });
38
+ }
39
+
40
+ generate() {
41
+ // Sort mappings by generated line and column
42
+ this.mappings.sort((a, b) => a.genLine - b.genLine || a.genCol - b.genCol);
43
+
44
+ let prevGenLine = 1;
45
+ let prevGenCol = 0;
46
+ let prevOrigLine = 0;
47
+ let prevOrigCol = 0;
48
+ let prevSource = 0;
49
+
50
+ const lines = [];
51
+ let currentLineMappings = [];
52
+
53
+ for (const m of this.mappings) {
54
+ while (m.genLine > prevGenLine) {
55
+ lines.push(currentLineMappings.join(','));
56
+ currentLineMappings = [];
57
+ prevGenLine++;
58
+ prevGenCol = 0;
59
+ }
60
+
61
+ // Encode segment
62
+ const genColDiff = m.genCol - prevGenCol;
63
+ const sourceDiff = 0 - prevSource;
64
+ const origLineDiff = (m.origLine - 1) - prevOrigLine;
65
+ const origColDiff = (m.origCol - 1) - prevOrigCol;
66
+
67
+ const segment =
68
+ encodeVLQ(genColDiff) +
69
+ encodeVLQ(sourceDiff) +
70
+ encodeVLQ(origLineDiff) +
71
+ encodeVLQ(origColDiff);
72
+
73
+ currentLineMappings.push(segment);
74
+
75
+ prevGenCol = m.genCol;
76
+ prevOrigLine = m.origLine - 1;
77
+ prevOrigCol = m.origCol - 1;
78
+ prevSource = 0;
79
+ }
80
+
81
+ lines.push(currentLineMappings.join(','));
82
+
83
+ return {
84
+ version: 3,
85
+ file: this.file,
86
+ sources: this.sources,
87
+ mappings: lines.join(';')
88
+ };
89
+ }
90
+ }
@@ -0,0 +1,509 @@
1
+ import { LiquidSyntaxError } from './errors.js';
2
+
3
+ export const TokenType = {
4
+ SECTION: 'SECTION',
5
+ INDENT: 'INDENT',
6
+ DEDENT: 'DEDENT',
7
+ TAG: 'TAG',
8
+ KEYWORD: 'KEYWORD',
9
+ DIRECTIVE_EVENT: 'DIRECTIVE_EVENT',
10
+ DIRECTIVE_BIND: 'DIRECTIVE_BIND',
11
+ ATTR: 'ATTR',
12
+ TEXT: 'TEXT',
13
+ INTERPOLATION: 'INTERPOLATION',
14
+ NEWLINE: 'NEWLINE',
15
+ EOF: 'EOF'
16
+ };
17
+
18
+ const KEYWORDS = new Set(['if', 'elseif', 'else if', 'else', 'each', 'slot']);
19
+
20
+ const COMMON_BOOLEAN_ATTRS = new Set([
21
+ 'disabled', 'checked', 'required', 'readonly', 'hidden', 'autofocus',
22
+ 'selected', 'multiple', 'novalidate', 'async', 'defer', 'open', 'reversed',
23
+ 'loop', 'autoplay', 'controls', 'muted', 'default', 'inert'
24
+ ]);
25
+
26
+ /**
27
+ * Tokenize a .liq source string into an array of tokens.
28
+ *
29
+ * @param {string} source
30
+ * @param {object} options
31
+ * @returns {Array<object>}
32
+ */
33
+ export function tokenize(source, options = {}) {
34
+ const file = options.file || 'component.liq';
35
+ const tokens = [];
36
+
37
+ const rawLines = source.split(/\r?\n/);
38
+ const indentStack = [0];
39
+ let currentSection = null; // null means top-level, defaults to UI unless section header seen
40
+ let sectionContentLines = [];
41
+ let sectionStartLoc = { line: 1, column: 1 };
42
+
43
+ function flushSection() {
44
+ if (currentSection && (currentSection === 'logic' || currentSection === 'style')) {
45
+ tokens.push({
46
+ type: TokenType.SECTION,
47
+ name: currentSection,
48
+ content: sectionContentLines.join('\n'),
49
+ line: sectionStartLoc.line,
50
+ column: sectionStartLoc.column
51
+ });
52
+ sectionContentLines = [];
53
+ }
54
+ }
55
+
56
+ for (let lineIndex = 0; lineIndex < rawLines.length; lineIndex++) {
57
+ const lineNumber = lineIndex + 1;
58
+ const rawLine = rawLines[lineIndex];
59
+
60
+ // Check for section header: e.g. "--- logic", "--- js", "--- style", "--- css", "--- ui"
61
+ const sectionMatch = rawLine.match(/^---\s*([a-zA-Z0-9_-]*)\s*$/);
62
+ if (sectionMatch) {
63
+ flushSection();
64
+
65
+ // If switching out of UI section, close any open UI indents
66
+ if (currentSection === 'ui' || currentSection === null) {
67
+ while (indentStack.length > 1) {
68
+ indentStack.pop();
69
+ tokens.push({ type: TokenType.DEDENT, line: lineNumber, column: 1 });
70
+ }
71
+ }
72
+
73
+ const rawName = sectionMatch[1].toLowerCase();
74
+ let sectionName = 'ui';
75
+ if (rawName === 'logic' || rawName === 'js' || rawName === 'script') {
76
+ sectionName = 'logic';
77
+ } else if (rawName === 'style' || rawName === 'styles' || rawName === 'css') {
78
+ sectionName = 'style';
79
+ } else if (rawName === 'ui' || rawName === 'template' || rawName === '') {
80
+ sectionName = 'ui';
81
+ } else {
82
+ throw new LiquidSyntaxError(
83
+ `Unknown section header '--- ${rawName}'. Valid sections are '--- logic', '--- style', or '--- ui'.`,
84
+ { file, line: lineNumber, column: 1, source }
85
+ );
86
+ }
87
+
88
+ currentSection = sectionName;
89
+ sectionStartLoc = { line: lineNumber + 1, column: 1 };
90
+ sectionContentLines = [];
91
+ continue;
92
+ }
93
+
94
+ // If inside logic or style section, collect lines verbatim
95
+ if (currentSection === 'logic' || currentSection === 'style') {
96
+ sectionContentLines.push(rawLine);
97
+ continue;
98
+ }
99
+
100
+ // Now we are in UI section (either explicit "--- ui" or default top-level UI)
101
+ currentSection = 'ui';
102
+
103
+ // Blank line or comment-only line?
104
+ const trimmed = rawLine.trim();
105
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) {
106
+ continue;
107
+ }
108
+
109
+ // Check indentation of the current line
110
+ const leadingWhitespaceMatch = rawLine.match(/^[ \t]*/);
111
+ const leadingWhitespace = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : '';
112
+ const hasTabs = leadingWhitespace.includes('\t');
113
+ const hasSpaces = leadingWhitespace.includes(' ');
114
+
115
+ if (hasTabs && hasSpaces) {
116
+ const firstTab = leadingWhitespace.indexOf('\t') + 1;
117
+ throw new LiquidSyntaxError(
118
+ 'Mixed tabs and spaces in indentation are not allowed. Please use spaces only (2 spaces per indent level).',
119
+ { file, line: lineNumber, column: firstTab, source }
120
+ );
121
+ }
122
+
123
+ if (hasTabs) {
124
+ throw new LiquidSyntaxError(
125
+ 'Tab characters in indentation are not allowed. Please use 2 spaces per indentation level.',
126
+ { file, line: lineNumber, column: 1, source }
127
+ );
128
+ }
129
+
130
+ const indentWidth = leadingWhitespace.length;
131
+ const currentIndent = indentStack[indentStack.length - 1];
132
+
133
+ if (indentWidth > currentIndent) {
134
+ if (indentWidth !== currentIndent + 2) {
135
+ throw new LiquidSyntaxError(
136
+ `Invalid indentation (${indentWidth} spaces). Expected ${currentIndent + 2} spaces (2 spaces per level).`,
137
+ { file, line: lineNumber, column: currentIndent + 1, source }
138
+ );
139
+ }
140
+ indentStack.push(indentWidth);
141
+ tokens.push({
142
+ type: TokenType.INDENT,
143
+ level: indentWidth,
144
+ line: lineNumber,
145
+ column: 1
146
+ });
147
+ } else if (indentWidth < currentIndent) {
148
+ while (indentStack.length > 1 && indentStack[indentStack.length - 1] > indentWidth) {
149
+ indentStack.pop();
150
+ tokens.push({
151
+ type: TokenType.DEDENT,
152
+ level: indentStack[indentStack.length - 1],
153
+ line: lineNumber,
154
+ column: 1
155
+ });
156
+ }
157
+ if (indentStack[indentStack.length - 1] !== indentWidth) {
158
+ throw new LiquidSyntaxError(
159
+ `Indentation level of ${indentWidth} spaces does not match any outer indentation level.`,
160
+ { file, line: lineNumber, column: 1, source }
161
+ );
162
+ }
163
+ }
164
+
165
+ // Tokenize the line content starting at column `indentWidth + 1`
166
+ const lineContent = rawLine.slice(indentWidth);
167
+ const colOffset = indentWidth + 1;
168
+ tokenizeUILine(lineContent, lineNumber, colOffset, tokens, file, source);
169
+ }
170
+
171
+ // Flush any trailing section
172
+ flushSection();
173
+
174
+ // If in UI, emit dedents for remaining open indents
175
+ while (indentStack.length > 1) {
176
+ indentStack.pop();
177
+ tokens.push({
178
+ type: TokenType.DEDENT,
179
+ level: indentStack[indentStack.length - 1],
180
+ line: rawLines.length,
181
+ column: 1
182
+ });
183
+ }
184
+
185
+ tokens.push({
186
+ type: TokenType.EOF,
187
+ line: rawLines.length,
188
+ column: 1
189
+ });
190
+
191
+ return tokens;
192
+ }
193
+
194
+ /**
195
+ * Tokenize a single non-empty UI line.
196
+ */
197
+ function tokenizeUILine(lineContent, line, colOffset, tokens, file, source) {
198
+ // 1. Explicit text line: | text
199
+ if (lineContent.startsWith('| ') || lineContent === '|') {
200
+ const text = lineContent.startsWith('| ') ? lineContent.slice(2) : '';
201
+ tokens.push({
202
+ type: TokenType.TEXT,
203
+ value: text,
204
+ isExplicitText: true,
205
+ line,
206
+ column: colOffset
207
+ });
208
+ return;
209
+ }
210
+
211
+ // Check if line starts with a keyword: if, elseif, else if, else, each, slot
212
+ for (const kw of ['else if', 'elseif', 'else', 'if', 'each', 'slot']) {
213
+ if (lineContent === kw || lineContent.startsWith(kw + ' ') || lineContent.startsWith(kw + '(')) {
214
+ const rest = lineContent.slice(kw.length).trim();
215
+ tokens.push({
216
+ type: TokenType.KEYWORD,
217
+ keyword: kw === 'else if' ? 'elseif' : kw,
218
+ args: rest,
219
+ line,
220
+ column: colOffset
221
+ });
222
+ return;
223
+ }
224
+ }
225
+
226
+ // Check for HTML comments
227
+ if (lineContent.startsWith('<!--')) {
228
+ return; // ignore comments
229
+ }
230
+
231
+ // Otherwise, it's an element, component, or child text line
232
+ // Let's scan the first word (tag + shorthands)
233
+ let pos = 0;
234
+ const len = lineContent.length;
235
+
236
+ function skipSpaces() {
237
+ while (pos < len && (lineContent[pos] === ' ' || lineContent[pos] === '\t')) {
238
+ pos++;
239
+ }
240
+ }
241
+
242
+ skipSpaces();
243
+ const tagStart = pos;
244
+
245
+ // Read tag name (supports letters, numbers, hyphens, and unicode characters for RTL/Arabic tags)
246
+ while (pos < len && !/[\s.#$@(=]/.test(lineContent[pos])) {
247
+ pos++;
248
+ }
249
+
250
+ const rawTagName = lineContent.slice(tagStart, pos);
251
+
252
+ // If no tag name and line doesn't start with '#' or '.', it's a plain text child line!
253
+ if (!rawTagName && lineContent[pos] !== '#' && lineContent[pos] !== '.') {
254
+ tokens.push({
255
+ type: TokenType.TEXT,
256
+ value: lineContent,
257
+ isExplicitText: false,
258
+ line,
259
+ column: colOffset
260
+ });
261
+ return;
262
+ }
263
+
264
+ const tagName = rawTagName || 'div'; // #id or .class defaults to div
265
+ let id = null;
266
+ const classes = [];
267
+
268
+ // Parse shorthand #id and .class
269
+ while (pos < len && (lineContent[pos] === '#' || lineContent[pos] === '.')) {
270
+ const prefix = lineContent[pos];
271
+ pos++;
272
+ const segStart = pos;
273
+ while (pos < len && !/[\s.#$@(=]/.test(lineContent[pos])) {
274
+ pos++;
275
+ }
276
+ const seg = lineContent.slice(segStart, pos);
277
+ if (!seg) {
278
+ throw new LiquidSyntaxError(
279
+ `Expected identifier after '${prefix}' in shorthand.`,
280
+ { file, line, column: colOffset + segStart, source }
281
+ );
282
+ }
283
+ if (prefix === '#') {
284
+ id = seg;
285
+ } else {
286
+ classes.push(seg);
287
+ }
288
+ }
289
+
290
+ tokens.push({
291
+ type: TokenType.TAG,
292
+ name: tagName,
293
+ id,
294
+ classes,
295
+ isComponent: /^[A-Z]/.test(tagName),
296
+ line,
297
+ column: colOffset + tagStart
298
+ });
299
+
300
+ skipSpaces();
301
+
302
+ // Now parse attributes, directives, and optional trailing inline text
303
+ while (pos < len) {
304
+ skipSpaces();
305
+ if (pos >= len) break;
306
+
307
+ const char = lineContent[pos];
308
+
309
+ // Event directive: @event or @event.modifier=handler
310
+ if (char === '@') {
311
+ pos++;
312
+ const evStart = pos;
313
+ while (pos < len && !/[\s=]/.test(lineContent[pos])) {
314
+ pos++;
315
+ }
316
+ const rawEvent = lineContent.slice(evStart, pos);
317
+ skipSpaces();
318
+ let handler = '';
319
+ if (pos < len && lineContent[pos] === '=') {
320
+ pos++;
321
+ skipSpaces();
322
+ const hObj = readAttrValue();
323
+ handler = hObj.value;
324
+ }
325
+ const [name, ...modifiers] = rawEvent.split('.');
326
+ tokens.push({
327
+ type: TokenType.DIRECTIVE_EVENT,
328
+ name,
329
+ modifiers,
330
+ handler,
331
+ line,
332
+ column: colOffset + evStart - 1
333
+ });
334
+ continue;
335
+ }
336
+
337
+ // Two-way binding: $prop={sig} or $={sig}
338
+ if (char === '$') {
339
+ pos++;
340
+ const bindStart = pos;
341
+ while (pos < len && !/[\s=]/.test(lineContent[pos])) {
342
+ pos++;
343
+ }
344
+ const prop = lineContent.slice(bindStart, pos) || 'value';
345
+ skipSpaces();
346
+ let target = '';
347
+ if (pos < len && lineContent[pos] === '=') {
348
+ pos++;
349
+ skipSpaces();
350
+ const tObj = readAttrValue();
351
+ target = tObj.value;
352
+ }
353
+ tokens.push({
354
+ type: TokenType.DIRECTIVE_BIND,
355
+ prop,
356
+ target,
357
+ line,
358
+ column: colOffset + bindStart - 1
359
+ });
360
+ continue;
361
+ }
362
+
363
+ // Check if this looks like an attribute: name="value", name={expr}, or boolean attr
364
+ // An attribute name matches [a-zA-Z0-9_-:]
365
+ const attrMatch = lineContent.slice(pos).match(/^([a-zA-Z0-9_\-:]+)(?:=|\s|$)/);
366
+ if (attrMatch) {
367
+ const attrName = attrMatch[1];
368
+ const afterNamePos = pos + attrName.length;
369
+ let nextChar = lineContent[afterNamePos];
370
+
371
+ // If followed immediately or after spaces by '='
372
+ let lookAheadPos = afterNamePos;
373
+ while (lookAheadPos < len && (lineContent[lookAheadPos] === ' ' || lineContent[lookAheadPos] === '\t')) {
374
+ lookAheadPos++;
375
+ }
376
+
377
+ if (lineContent[lookAheadPos] === '=') {
378
+ // Definitely an attribute with value
379
+ pos = lookAheadPos + 1;
380
+ skipSpaces();
381
+ const attrVal = readAttrValue();
382
+ tokens.push({
383
+ type: TokenType.ATTR,
384
+ name: attrName,
385
+ value: attrVal.value,
386
+ isDynamic: attrVal.isDynamic,
387
+ line,
388
+ column: colOffset + (pos - attrName.length)
389
+ });
390
+ continue;
391
+ }
392
+
393
+ // Check if it's a known boolean attribute
394
+ if (COMMON_BOOLEAN_ATTRS.has(attrName.toLowerCase())) {
395
+ pos = afterNamePos;
396
+ tokens.push({
397
+ type: TokenType.ATTR,
398
+ name: attrName,
399
+ value: 'true',
400
+ isDynamic: false,
401
+ isBoolean: true,
402
+ line,
403
+ column: colOffset + pos
404
+ });
405
+ continue;
406
+ }
407
+ }
408
+
409
+ // If it does not match an attribute or directive, the rest of the line is INLINE TEXT!
410
+ const inlineText = lineContent.slice(pos);
411
+ tokens.push({
412
+ type: TokenType.TEXT,
413
+ value: inlineText,
414
+ isInline: true,
415
+ line,
416
+ column: colOffset + pos
417
+ });
418
+ break;
419
+ }
420
+
421
+ function readAttrValue() {
422
+ skipSpaces();
423
+ if (pos >= len) return { value: '', isDynamic: false };
424
+
425
+ const firstChar = lineContent[pos];
426
+
427
+ // Expression: {expr}
428
+ if (firstChar === '{') {
429
+ let depth = 1;
430
+ pos++;
431
+ const exprStart = pos;
432
+ while (pos < len && depth > 0) {
433
+ if (lineContent[pos] === '{') depth++;
434
+ else if (lineContent[pos] === '}') depth--;
435
+ if (depth > 0) pos++;
436
+ }
437
+ const expr = lineContent.slice(exprStart, pos);
438
+ if (lineContent[pos] === '}') pos++;
439
+ return { value: expr.trim(), isDynamic: true };
440
+ }
441
+
442
+ // Quoted string: "..." or '...'
443
+ if (firstChar === '"' || firstChar === "'") {
444
+ const quote = firstChar;
445
+ pos++;
446
+ const strStart = pos;
447
+ let strVal = '';
448
+ let escaped = false;
449
+ while (pos < len) {
450
+ const c = lineContent[pos];
451
+ if (escaped) {
452
+ strVal += c;
453
+ escaped = false;
454
+ } else if (c === '\\') {
455
+ escaped = true;
456
+ } else if (c === quote) {
457
+ pos++;
458
+ break;
459
+ } else {
460
+ strVal += c;
461
+ }
462
+ pos++;
463
+ }
464
+
465
+ // Check for interpolation inside quotes: e.g. "{expr}" or "text {expr}"
466
+ if (strVal.includes('{')) {
467
+ if (strVal.startsWith('{') && strVal.endsWith('}') && strVal.indexOf('{', 1) === -1) {
468
+ return { value: strVal.slice(1, -1).trim(), isDynamic: true };
469
+ }
470
+ const parts = [];
471
+ let cur = 0;
472
+ let inExpr = false;
473
+ let buf = '';
474
+ while (cur < strVal.length) {
475
+ if (!inExpr && strVal[cur] === '{') {
476
+ if (buf) { parts.push(JSON.stringify(buf)); buf = ''; }
477
+ inExpr = true;
478
+ cur++;
479
+ } else if (inExpr && strVal[cur] === '}') {
480
+ if (buf) { parts.push(`(${buf.trim()})`); buf = ''; }
481
+ inExpr = false;
482
+ cur++;
483
+ } else {
484
+ buf += strVal[cur++];
485
+ }
486
+ }
487
+ if (buf) parts.push(JSON.stringify(buf));
488
+ return { value: parts.join(' + '), isDynamic: true };
489
+ }
490
+
491
+ return { value: strVal, isDynamic: false };
492
+ }
493
+
494
+ // Unquoted expression or identifier, tracking balanced parentheses/brackets
495
+ const valStart = pos;
496
+ let parenDepth = 0;
497
+ while (pos < len) {
498
+ const c = lineContent[pos];
499
+ if (parenDepth === 0 && (c === ' ' || c === '\t')) {
500
+ break;
501
+ }
502
+ if (c === '(' || c === '[' || c === '{') parenDepth++;
503
+ else if (c === ')' || c === ']' || c === '}') parenDepth--;
504
+ pos++;
505
+ }
506
+ const unquoted = lineContent.slice(valStart, pos);
507
+ return { value: unquoted, isDynamic: false };
508
+ }
509
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.