@flux-lang/core 0.1.2 → 0.1.4-canary.078fd1eea

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/dist/parser.js CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  /**
2
4
  * Token types for the Flux lexer.
3
5
  * We keep keywords mostly as identifiers, except for a few special literals.
@@ -506,8 +508,10 @@ class Lexer {
506
508
  class Parser {
507
509
  tokens;
508
510
  current = 0;
509
- constructor(tokens) {
511
+ allowBodyFragments = false;
512
+ constructor(tokens, options = {}) {
510
513
  this.tokens = tokens;
514
+ this.allowBodyFragments = options.allowBodyFragments ?? false;
511
515
  }
512
516
  parseDocument() {
513
517
  // document { ... }
@@ -519,6 +523,12 @@ class Parser {
519
523
  const grids = [];
520
524
  const rules = [];
521
525
  let runtime;
526
+ let materials;
527
+ let assets;
528
+ let tokens;
529
+ let styles;
530
+ const themes = [];
531
+ let body;
522
532
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
523
533
  if (this.checkIdentifier("meta")) {
524
534
  const blockMeta = this.parseMetaBlock();
@@ -540,6 +550,24 @@ class Parser {
540
550
  else if (this.checkIdentifier("runtime")) {
541
551
  runtime = this.parseRuntimeBlock();
542
552
  }
553
+ else if (this.checkIdentifier("assets")) {
554
+ assets = this.parseAssetsBlock();
555
+ }
556
+ else if (this.checkIdentifier("materials")) {
557
+ materials = this.parseMaterialsBlock();
558
+ }
559
+ else if (this.checkIdentifier("tokens")) {
560
+ tokens = this.parseTokensBlock();
561
+ }
562
+ else if (this.checkIdentifier("styles")) {
563
+ styles = this.parseStylesBlock();
564
+ }
565
+ else if (this.checkIdentifier("theme")) {
566
+ themes.push(this.parseThemeBlock());
567
+ }
568
+ else if (this.checkIdentifier("body")) {
569
+ body = this.parseBodyBlock();
570
+ }
543
571
  else {
544
572
  const tok = this.peek();
545
573
  throw this.errorAtToken(tok, `Unexpected top-level construct '${tok.lexeme}'`);
@@ -553,6 +581,12 @@ class Parser {
553
581
  grids,
554
582
  rules,
555
583
  runtime,
584
+ materials,
585
+ assets,
586
+ tokens,
587
+ styles,
588
+ themes,
589
+ body,
556
590
  };
557
591
  return doc;
558
592
  }
@@ -562,8 +596,7 @@ class Parser {
562
596
  this.consume(TokenType.LBrace, "Expected '{' after 'meta'");
563
597
  const meta = { version: "0.1.0" };
564
598
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
565
- const keyTok = this.consume(TokenType.Identifier, "Expected meta field name");
566
- const key = String(keyTok.value);
599
+ const key = this.parseKeyPath("Expected meta field name");
567
600
  this.consume(TokenType.Equals, "Expected '=' after meta field name");
568
601
  const valueTok = this.consume(TokenType.String, "Expected string value for meta field");
569
602
  meta[key] = String(valueTok.value);
@@ -813,6 +846,818 @@ class Parser {
813
846
  this.consume(TokenType.RBrace, "Expected '}' after cell block");
814
847
  return cell;
815
848
  }
849
+ // --- Materials ---
850
+ parseMaterialsBlock() {
851
+ this.expectIdentifier("materials", "Expected 'materials'");
852
+ this.consume(TokenType.LBrace, "Expected '{' after 'materials'");
853
+ const materials = [];
854
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
855
+ if (this.checkIdentifier("material")) {
856
+ materials.push(this.parseMaterialDecl());
857
+ }
858
+ else {
859
+ this.skipStatement();
860
+ }
861
+ }
862
+ this.consume(TokenType.RBrace, "Expected '}' after materials block");
863
+ return { materials };
864
+ }
865
+ parseMaterialDecl() {
866
+ this.expectIdentifier("material", "Expected 'material'");
867
+ const nameTok = this.consume(TokenType.Identifier, "Expected material name");
868
+ const name = String(nameTok.value);
869
+ this.consume(TokenType.LBrace, "Expected '{' after material name");
870
+ const material = { name, tags: [] };
871
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
872
+ if (this.checkIdentifier("tags")) {
873
+ material.tags = this.parseIdentifierList();
874
+ }
875
+ else if (this.checkIdentifier("label")) {
876
+ this.advance();
877
+ this.consume(TokenType.Equals, "Expected '=' after 'label'");
878
+ const tok = this.consume(TokenType.String, "Expected string for label");
879
+ material.label = String(tok.value);
880
+ this.consumeOptional(TokenType.Semicolon);
881
+ }
882
+ else if (this.checkIdentifier("description")) {
883
+ this.advance();
884
+ this.consume(TokenType.Equals, "Expected '=' after 'description'");
885
+ const tok = this.consume(TokenType.String, "Expected string for description");
886
+ material.description = String(tok.value);
887
+ this.consumeOptional(TokenType.Semicolon);
888
+ }
889
+ else if (this.checkIdentifier("color")) {
890
+ this.advance();
891
+ this.consume(TokenType.Equals, "Expected '=' after 'color'");
892
+ const tok = this.consume(TokenType.String, "Expected string for color");
893
+ material.color = String(tok.value);
894
+ this.consumeOptional(TokenType.Semicolon);
895
+ }
896
+ else if (this.checkIdentifier("score")) {
897
+ material.score = this.parseMaterialScoreBlock();
898
+ }
899
+ else if (this.checkIdentifier("midi")) {
900
+ material.midi = this.parseMaterialMidiBlock();
901
+ }
902
+ else if (this.checkIdentifier("video")) {
903
+ material.video = this.parseMaterialVideoBlock();
904
+ }
905
+ else {
906
+ this.skipStatement();
907
+ }
908
+ }
909
+ this.consume(TokenType.RBrace, "Expected '}' after material block");
910
+ return material;
911
+ }
912
+ parseMaterialScoreBlock() {
913
+ this.expectIdentifier("score", "Expected 'score'");
914
+ this.consume(TokenType.LBrace, "Expected '{' after 'score'");
915
+ const score = {};
916
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
917
+ if (this.checkIdentifier("text")) {
918
+ this.advance();
919
+ this.consume(TokenType.Equals, "Expected '=' after 'text'");
920
+ const tok = this.consume(TokenType.String, "Expected string for text");
921
+ score.text = String(tok.value);
922
+ this.consumeOptional(TokenType.Semicolon);
923
+ }
924
+ else if (this.checkIdentifier("staff")) {
925
+ this.advance();
926
+ this.consume(TokenType.Equals, "Expected '=' after 'staff'");
927
+ const tok = this.consume(TokenType.String, "Expected string for staff");
928
+ score.staff = String(tok.value);
929
+ this.consumeOptional(TokenType.Semicolon);
930
+ }
931
+ else if (this.checkIdentifier("clef")) {
932
+ this.advance();
933
+ this.consume(TokenType.Equals, "Expected '=' after 'clef'");
934
+ const tok = this.consume(TokenType.String, "Expected string for clef");
935
+ score.clef = String(tok.value);
936
+ this.consumeOptional(TokenType.Semicolon);
937
+ }
938
+ else {
939
+ this.skipStatement();
940
+ }
941
+ }
942
+ this.consume(TokenType.RBrace, "Expected '}' after score block");
943
+ return score;
944
+ }
945
+ parseMaterialMidiBlock() {
946
+ this.expectIdentifier("midi", "Expected 'midi'");
947
+ this.consume(TokenType.LBrace, "Expected '{' after 'midi'");
948
+ const midi = {};
949
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
950
+ if (this.checkIdentifier("channel")) {
951
+ this.advance();
952
+ this.consume(TokenType.Equals, "Expected '=' after 'channel'");
953
+ const tok = this.consumeNumber("Expected numeric channel");
954
+ midi.channel = Number(tok.value);
955
+ this.consumeOptional(TokenType.Semicolon);
956
+ }
957
+ else if (this.checkIdentifier("pitch")) {
958
+ this.advance();
959
+ this.consume(TokenType.Equals, "Expected '=' after 'pitch'");
960
+ const tok = this.consumeNumber("Expected numeric pitch");
961
+ midi.pitch = Number(tok.value);
962
+ this.consumeOptional(TokenType.Semicolon);
963
+ }
964
+ else if (this.checkIdentifier("velocity")) {
965
+ this.advance();
966
+ this.consume(TokenType.Equals, "Expected '=' after 'velocity'");
967
+ const tok = this.consumeNumber("Expected numeric velocity");
968
+ midi.velocity = Number(tok.value);
969
+ this.consumeOptional(TokenType.Semicolon);
970
+ }
971
+ else if (this.checkIdentifier("durationSeconds")) {
972
+ this.advance();
973
+ this.consume(TokenType.Equals, "Expected '=' after 'durationSeconds'");
974
+ const tok = this.consumeNumber("Expected numeric durationSeconds");
975
+ midi.durationSeconds = Number(tok.value);
976
+ this.consumeOptional(TokenType.Semicolon);
977
+ }
978
+ else {
979
+ this.skipStatement();
980
+ }
981
+ }
982
+ this.consume(TokenType.RBrace, "Expected '}' after midi block");
983
+ return midi;
984
+ }
985
+ parseMaterialVideoBlock() {
986
+ this.expectIdentifier("video", "Expected 'video'");
987
+ this.consume(TokenType.LBrace, "Expected '{' after 'video'");
988
+ const video = { clip: "" };
989
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
990
+ if (this.checkIdentifier("clip")) {
991
+ this.advance();
992
+ this.consume(TokenType.Equals, "Expected '=' after 'clip'");
993
+ const tok = this.consume(TokenType.String, "Expected string for clip");
994
+ video.clip = String(tok.value);
995
+ this.consumeOptional(TokenType.Semicolon);
996
+ }
997
+ else if (this.checkIdentifier("inSeconds")) {
998
+ this.advance();
999
+ this.consume(TokenType.Equals, "Expected '=' after 'inSeconds'");
1000
+ const tok = this.consumeNumber("Expected numeric inSeconds");
1001
+ video.inSeconds = Number(tok.value);
1002
+ this.consumeOptional(TokenType.Semicolon);
1003
+ }
1004
+ else if (this.checkIdentifier("outSeconds")) {
1005
+ this.advance();
1006
+ this.consume(TokenType.Equals, "Expected '=' after 'outSeconds'");
1007
+ const tok = this.consumeNumber("Expected numeric outSeconds");
1008
+ video.outSeconds = Number(tok.value);
1009
+ this.consumeOptional(TokenType.Semicolon);
1010
+ }
1011
+ else if (this.checkIdentifier("layer")) {
1012
+ this.advance();
1013
+ this.consume(TokenType.Equals, "Expected '=' after 'layer'");
1014
+ const tok = this.consume(TokenType.String, "Expected string for layer");
1015
+ video.layer = String(tok.value);
1016
+ this.consumeOptional(TokenType.Semicolon);
1017
+ }
1018
+ else {
1019
+ this.skipStatement();
1020
+ }
1021
+ }
1022
+ this.consume(TokenType.RBrace, "Expected '}' after video block");
1023
+ return video;
1024
+ }
1025
+ // --- Assets (v0.2) ---
1026
+ parseAssetsBlock() {
1027
+ this.expectIdentifier("assets", "Expected 'assets'");
1028
+ this.consume(TokenType.LBrace, "Expected '{' after 'assets'");
1029
+ const assets = [];
1030
+ const banks = [];
1031
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1032
+ if (this.checkIdentifier("asset")) {
1033
+ assets.push(this.parseAssetDecl());
1034
+ }
1035
+ else if (this.checkIdentifier("bank")) {
1036
+ banks.push(this.parseAssetBankDecl());
1037
+ }
1038
+ else {
1039
+ this.skipStatement();
1040
+ }
1041
+ }
1042
+ this.consume(TokenType.RBrace, "Expected '}' after assets block");
1043
+ return { assets, banks };
1044
+ }
1045
+ parseAssetDecl() {
1046
+ this.expectIdentifier("asset", "Expected 'asset'");
1047
+ const nameTok = this.consume(TokenType.Identifier, "Expected asset name");
1048
+ const name = String(nameTok.value);
1049
+ this.consume(TokenType.LBrace, "Expected '{' after asset name");
1050
+ const asset = {
1051
+ name,
1052
+ kind: "",
1053
+ path: "",
1054
+ tags: [],
1055
+ };
1056
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1057
+ if (this.checkIdentifier("kind")) {
1058
+ this.advance();
1059
+ this.consume(TokenType.Equals, "Expected '=' after 'kind'");
1060
+ const tok = this.peek();
1061
+ if (tok.type === TokenType.String || tok.type === TokenType.Identifier) {
1062
+ this.advance();
1063
+ asset.kind = String(tok.value ?? tok.lexeme);
1064
+ }
1065
+ else {
1066
+ throw this.errorAtToken(tok, "Expected kind identifier or string");
1067
+ }
1068
+ this.consumeOptional(TokenType.Semicolon);
1069
+ }
1070
+ else if (this.checkIdentifier("path")) {
1071
+ this.advance();
1072
+ this.consume(TokenType.Equals, "Expected '=' after 'path'");
1073
+ const tok = this.consume(TokenType.String, "Expected string for path");
1074
+ asset.path = String(tok.value);
1075
+ this.consumeOptional(TokenType.Semicolon);
1076
+ }
1077
+ else if (this.checkIdentifier("tags")) {
1078
+ asset.tags = this.parseIdentifierList();
1079
+ }
1080
+ else if (this.checkIdentifier("weight")) {
1081
+ this.advance();
1082
+ this.consume(TokenType.Equals, "Expected '=' after 'weight'");
1083
+ const tok = this.consumeNumber("Expected numeric weight");
1084
+ asset.weight = Number(tok.value);
1085
+ this.consumeOptional(TokenType.Semicolon);
1086
+ }
1087
+ else if (this.checkIdentifier("meta")) {
1088
+ asset.meta = this.parseMetaMapBlock();
1089
+ }
1090
+ else {
1091
+ this.skipStatement();
1092
+ }
1093
+ }
1094
+ this.consume(TokenType.RBrace, "Expected '}' after asset block");
1095
+ return asset;
1096
+ }
1097
+ parseAssetBankDecl() {
1098
+ this.expectIdentifier("bank", "Expected 'bank'");
1099
+ const nameTok = this.consume(TokenType.Identifier, "Expected bank name");
1100
+ const name = String(nameTok.value);
1101
+ this.consume(TokenType.LBrace, "Expected '{' after bank name");
1102
+ const bank = {
1103
+ name,
1104
+ kind: "",
1105
+ root: "",
1106
+ include: "",
1107
+ tags: [],
1108
+ };
1109
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1110
+ if (this.checkIdentifier("kind")) {
1111
+ this.advance();
1112
+ this.consume(TokenType.Equals, "Expected '=' after 'kind'");
1113
+ const tok = this.peek();
1114
+ if (tok.type === TokenType.String || tok.type === TokenType.Identifier) {
1115
+ this.advance();
1116
+ bank.kind = String(tok.value ?? tok.lexeme);
1117
+ }
1118
+ else {
1119
+ throw this.errorAtToken(tok, "Expected kind identifier or string");
1120
+ }
1121
+ this.consumeOptional(TokenType.Semicolon);
1122
+ }
1123
+ else if (this.checkIdentifier("root")) {
1124
+ this.advance();
1125
+ this.consume(TokenType.Equals, "Expected '=' after 'root'");
1126
+ const tok = this.consume(TokenType.String, "Expected string for root");
1127
+ bank.root = String(tok.value);
1128
+ this.consumeOptional(TokenType.Semicolon);
1129
+ }
1130
+ else if (this.checkIdentifier("include")) {
1131
+ this.advance();
1132
+ this.consume(TokenType.Equals, "Expected '=' after 'include'");
1133
+ const tok = this.consume(TokenType.String, "Expected string for include");
1134
+ bank.include = String(tok.value);
1135
+ this.consumeOptional(TokenType.Semicolon);
1136
+ }
1137
+ else if (this.checkIdentifier("tags")) {
1138
+ bank.tags = this.parseIdentifierList();
1139
+ }
1140
+ else if (this.checkIdentifier("strategy")) {
1141
+ this.advance();
1142
+ this.consume(TokenType.Equals, "Expected '=' after 'strategy'");
1143
+ const tok = this.peek();
1144
+ if (tok.type !== TokenType.Identifier && tok.type !== TokenType.String) {
1145
+ throw this.errorAtToken(tok, "Expected strategy identifier or string");
1146
+ }
1147
+ this.advance();
1148
+ const raw = String(tok.value ?? tok.lexeme);
1149
+ if (raw !== "weighted" && raw !== "uniform") {
1150
+ throw this.errorAtToken(tok, `Unknown asset strategy '${raw}'`);
1151
+ }
1152
+ bank.strategy = raw;
1153
+ this.consumeOptional(TokenType.Semicolon);
1154
+ }
1155
+ else {
1156
+ this.skipStatement();
1157
+ }
1158
+ }
1159
+ this.consume(TokenType.RBrace, "Expected '}' after bank block");
1160
+ return bank;
1161
+ }
1162
+ parseMetaMapBlock() {
1163
+ this.expectIdentifier("meta", "Expected 'meta'");
1164
+ this.consume(TokenType.LBrace, "Expected '{' after 'meta'");
1165
+ const meta = {};
1166
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1167
+ const key = this.parseKeyPath("Expected meta field name");
1168
+ this.consume(TokenType.Equals, "Expected '=' after meta field name");
1169
+ const value = this.parseValueLiteral();
1170
+ meta[key] = value;
1171
+ this.consumeOptional(TokenType.Semicolon);
1172
+ }
1173
+ this.consume(TokenType.RBrace, "Expected '}' after meta block");
1174
+ return meta;
1175
+ }
1176
+ // --- Body (v0.2) ---
1177
+ parseBodyBlock() {
1178
+ this.expectIdentifier("body", "Expected 'body'");
1179
+ this.consume(TokenType.LBrace, "Expected '{' after 'body'");
1180
+ const nodes = [];
1181
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1182
+ const node = this.parseDocumentNode();
1183
+ if (!this.allowBodyFragments && node.kind !== "page") {
1184
+ throw this.errorAtToken(this.peek(), "Body block must contain page nodes at the top level");
1185
+ }
1186
+ nodes.push(node);
1187
+ }
1188
+ this.consume(TokenType.RBrace, "Expected '}' after body block");
1189
+ return { nodes };
1190
+ }
1191
+ parseDocumentNode() {
1192
+ const kindTok = this.consume(TokenType.Identifier, "Expected node kind");
1193
+ const kind = String(kindTok.value);
1194
+ const idTok = this.consume(TokenType.Identifier, "Expected node id");
1195
+ const id = String(idTok.value);
1196
+ this.consume(TokenType.LBrace, "Expected '{' after node id");
1197
+ const props = {};
1198
+ const children = [];
1199
+ let refresh;
1200
+ let transition;
1201
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1202
+ if (this.checkIdentifier("refresh")) {
1203
+ refresh = this.parseRefreshPolicy();
1204
+ continue;
1205
+ }
1206
+ if (this.checkIdentifier("transition")) {
1207
+ transition = this.parseTransitionSpec();
1208
+ continue;
1209
+ }
1210
+ if (this.looksLikeNodeDecl()) {
1211
+ children.push(this.parseDocumentNode());
1212
+ continue;
1213
+ }
1214
+ if (this.check(TokenType.Identifier)) {
1215
+ const key = this.parseKeyPath("Expected property name");
1216
+ this.consume(TokenType.Equals, "Expected '=' after property name");
1217
+ if (this.match(TokenType.At)) {
1218
+ const expr = this.parseExpr();
1219
+ props[key] = { kind: "DynamicValue", expr };
1220
+ }
1221
+ else {
1222
+ const value = this.parseValueLiteral();
1223
+ props[key] = { kind: "LiteralValue", value };
1224
+ }
1225
+ this.consumeOptional(TokenType.Semicolon);
1226
+ continue;
1227
+ }
1228
+ this.skipStatement();
1229
+ }
1230
+ const endTok = this.consume(TokenType.RBrace, "Expected '}' after node block");
1231
+ return {
1232
+ id,
1233
+ kind,
1234
+ props,
1235
+ children,
1236
+ refresh,
1237
+ transition,
1238
+ loc: { line: kindTok.line, column: kindTok.column, endLine: endTok.line, endColumn: endTok.column },
1239
+ };
1240
+ }
1241
+ parseTokensBlock() {
1242
+ this.expectIdentifier("tokens", "Expected 'tokens'");
1243
+ this.consume(TokenType.LBrace, "Expected '{' after 'tokens'");
1244
+ const tokens = {};
1245
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1246
+ const key = this.parseKeyPath("Expected token name");
1247
+ this.consume(TokenType.Equals, "Expected '=' after token name");
1248
+ const value = this.parseValueLiteral();
1249
+ tokens[key] = value;
1250
+ this.consumeOptional(TokenType.Semicolon);
1251
+ }
1252
+ this.consume(TokenType.RBrace, "Expected '}' after tokens block");
1253
+ return { tokens };
1254
+ }
1255
+ parseStylesBlock() {
1256
+ this.expectIdentifier("styles", "Expected 'styles'");
1257
+ this.consume(TokenType.LBrace, "Expected '{' after 'styles'");
1258
+ const styles = [];
1259
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1260
+ const nameTok = this.consume(TokenType.Identifier, "Expected style name");
1261
+ const name = String(nameTok.value);
1262
+ let extendsName;
1263
+ if (this.match(TokenType.Colon)) {
1264
+ const baseTok = this.consume(TokenType.Identifier, "Expected base style name");
1265
+ extendsName = String(baseTok.value);
1266
+ }
1267
+ this.consume(TokenType.LBrace, "Expected '{' after style name");
1268
+ const props = this.parsePropertyMap();
1269
+ this.consume(TokenType.RBrace, "Expected '}' after style block");
1270
+ styles.push({ name, extends: extendsName, props });
1271
+ }
1272
+ this.consume(TokenType.RBrace, "Expected '}' after styles block");
1273
+ return { styles };
1274
+ }
1275
+ parseThemeBlock() {
1276
+ this.expectIdentifier("theme", "Expected 'theme'");
1277
+ let name;
1278
+ if (this.check(TokenType.String)) {
1279
+ name = String(this.advance().value);
1280
+ }
1281
+ else if (this.check(TokenType.Identifier)) {
1282
+ name = String(this.advance().value);
1283
+ }
1284
+ else {
1285
+ throw this.errorAtToken(this.peek(), "Expected theme name");
1286
+ }
1287
+ this.consume(TokenType.LBrace, "Expected '{' after theme name");
1288
+ let tokens;
1289
+ let styles;
1290
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1291
+ if (this.checkIdentifier("tokens")) {
1292
+ tokens = this.parseTokensBlock();
1293
+ }
1294
+ else if (this.checkIdentifier("styles")) {
1295
+ styles = this.parseStylesBlock();
1296
+ }
1297
+ else {
1298
+ const tok = this.peek();
1299
+ throw this.errorAtToken(tok, `Unexpected theme field '${tok.lexeme}'`);
1300
+ }
1301
+ }
1302
+ this.consume(TokenType.RBrace, "Expected '}' after theme block");
1303
+ return { name, tokens, styles };
1304
+ }
1305
+ parseRefreshPolicy() {
1306
+ this.expectIdentifier("refresh", "Expected 'refresh'");
1307
+ this.consume(TokenType.Equals, "Expected '=' after 'refresh'");
1308
+ if (this.checkIdentifier("never")) {
1309
+ this.advance();
1310
+ this.consumeOptional(TokenType.Semicolon);
1311
+ return { kind: "never" };
1312
+ }
1313
+ if (this.checkIdentifier("docstep") || this.checkIdentifier("onDocstep")) {
1314
+ this.advance();
1315
+ this.consumeOptional(TokenType.Semicolon);
1316
+ return { kind: "docstep" };
1317
+ }
1318
+ if (this.checkIdentifier("onLoad")) {
1319
+ this.advance();
1320
+ this.consumeOptional(TokenType.Semicolon);
1321
+ return { kind: "never" };
1322
+ }
1323
+ if (this.checkIdentifier("every")) {
1324
+ this.advance();
1325
+ this.consume(TokenType.LParen, "Expected '(' after 'every'");
1326
+ const intervalSec = this.parseTimeSeconds("Expected refresh interval");
1327
+ let phaseSec = 0;
1328
+ if (this.match(TokenType.Comma)) {
1329
+ if (!this.checkIdentifier("phase")) {
1330
+ throw this.errorAtToken(this.peek(), "Expected 'phase' in every(...)");
1331
+ }
1332
+ this.advance();
1333
+ this.consume(TokenType.Equals, "Expected '=' after 'phase'");
1334
+ phaseSec = this.parseTimeSeconds("Expected phase duration", true);
1335
+ }
1336
+ this.consume(TokenType.RParen, "Expected ')' after every(...)");
1337
+ this.consumeOptional(TokenType.Semicolon);
1338
+ return { kind: "every", intervalSec, phaseSec };
1339
+ }
1340
+ if (this.checkIdentifier("atEach")) {
1341
+ this.advance();
1342
+ this.consume(TokenType.LParen, "Expected '(' after 'atEach'");
1343
+ this.consume(TokenType.LBracket, "Expected '[' to start atEach list");
1344
+ const timesSec = [];
1345
+ if (!this.check(TokenType.RBracket)) {
1346
+ timesSec.push(this.parseTimeSeconds("Expected atEach time", true));
1347
+ while (this.match(TokenType.Comma)) {
1348
+ timesSec.push(this.parseTimeSeconds("Expected atEach time", true));
1349
+ }
1350
+ }
1351
+ this.consume(TokenType.RBracket, "Expected ']' after atEach list");
1352
+ this.consume(TokenType.RParen, "Expected ')' after atEach(...)");
1353
+ this.consumeOptional(TokenType.Semicolon);
1354
+ return { kind: "atEach", timesSec };
1355
+ }
1356
+ if (this.checkIdentifier("at")) {
1357
+ this.advance();
1358
+ this.consume(TokenType.LParen, "Expected '(' after 'at'");
1359
+ const timeSec = this.parseTimeSeconds("Expected at(...) time", true);
1360
+ this.consume(TokenType.RParen, "Expected ')' after at(...)");
1361
+ this.consumeOptional(TokenType.Semicolon);
1362
+ return { kind: "at", timeSec };
1363
+ }
1364
+ if (this.checkIdentifier("poisson")) {
1365
+ this.advance();
1366
+ this.consume(TokenType.LParen, "Expected '(' after 'poisson'");
1367
+ let ratePerSec = null;
1368
+ while (!this.check(TokenType.RParen) && !this.isAtEnd()) {
1369
+ if (this.check(TokenType.Identifier) && this.peek(1).type === TokenType.Equals) {
1370
+ const nameTok = this.advance();
1371
+ this.consume(TokenType.Equals, "Expected '=' after argument name");
1372
+ if (nameTok.lexeme !== "ratePerSec") {
1373
+ throw this.errorAtToken(nameTok, `Unknown poisson argument '${nameTok.lexeme}'`);
1374
+ }
1375
+ const valueTok = this.consumeNumber("Expected numeric ratePerSec");
1376
+ ratePerSec = Number(valueTok.value);
1377
+ }
1378
+ else {
1379
+ const valueTok = this.consumeNumber("Expected numeric ratePerSec");
1380
+ ratePerSec = Number(valueTok.value);
1381
+ }
1382
+ if (!this.match(TokenType.Comma))
1383
+ break;
1384
+ }
1385
+ this.consume(TokenType.RParen, "Expected ')' after poisson(...)");
1386
+ this.consumeOptional(TokenType.Semicolon);
1387
+ if (ratePerSec == null) {
1388
+ throw this.errorAtToken(this.peek(), "poisson(ratePerSec=...) requires ratePerSec");
1389
+ }
1390
+ return { kind: "poisson", ratePerSec };
1391
+ }
1392
+ if (this.checkIdentifier("chance")) {
1393
+ this.advance();
1394
+ this.consume(TokenType.LParen, "Expected '(' after 'chance'");
1395
+ let p = null;
1396
+ let every = null;
1397
+ while (!this.check(TokenType.RParen) && !this.isAtEnd()) {
1398
+ if (this.check(TokenType.Identifier) && this.peek(1).type === TokenType.Equals) {
1399
+ const nameTok = this.advance();
1400
+ this.consume(TokenType.Equals, "Expected '=' after argument name");
1401
+ if (nameTok.lexeme === "p") {
1402
+ const valueTok = this.consumeNumber("Expected numeric p");
1403
+ p = Number(valueTok.value);
1404
+ }
1405
+ else if (nameTok.lexeme === "every") {
1406
+ every = this.parseChanceEvery();
1407
+ }
1408
+ else {
1409
+ throw this.errorAtToken(nameTok, `Unknown chance argument '${nameTok.lexeme}'`);
1410
+ }
1411
+ }
1412
+ else if (p == null) {
1413
+ const valueTok = this.consumeNumber("Expected numeric p");
1414
+ p = Number(valueTok.value);
1415
+ }
1416
+ else {
1417
+ throw this.errorAtToken(this.peek(), "Unexpected chance(...) argument");
1418
+ }
1419
+ if (!this.match(TokenType.Comma))
1420
+ break;
1421
+ }
1422
+ this.consume(TokenType.RParen, "Expected ')' after chance(...)");
1423
+ this.consumeOptional(TokenType.Semicolon);
1424
+ if (p == null) {
1425
+ throw this.errorAtToken(this.peek(), "chance(p=..., every=...) requires p");
1426
+ }
1427
+ if (!every) {
1428
+ every = { kind: "docstep" };
1429
+ }
1430
+ return { kind: "chance", p, every };
1431
+ }
1432
+ if (this.checkIdentifier("beat")) {
1433
+ throw this.errorAtToken(this.peek(), "beat(...) refresh is not supported yet");
1434
+ }
1435
+ throw this.errorAtToken(this.peek(), "Invalid refresh policy");
1436
+ }
1437
+ parseTransitionSpec() {
1438
+ this.expectIdentifier("transition", "Expected 'transition'");
1439
+ this.consume(TokenType.Equals, "Expected '=' after 'transition'");
1440
+ if (this.checkIdentifier("none")) {
1441
+ this.advance();
1442
+ this.consumeOptional(TokenType.Semicolon);
1443
+ return { kind: "none" };
1444
+ }
1445
+ if (this.checkIdentifier("appear")) {
1446
+ this.advance();
1447
+ if (this.match(TokenType.LParen)) {
1448
+ this.consume(TokenType.RParen, "Expected ')' after appear(");
1449
+ }
1450
+ this.consumeOptional(TokenType.Semicolon);
1451
+ return { kind: "appear" };
1452
+ }
1453
+ if (this.checkIdentifier("fade")) {
1454
+ this.advance();
1455
+ const args = this.parseTransitionArgs(["duration", "ease"]);
1456
+ this.consumeOptional(TokenType.Semicolon);
1457
+ return { kind: "fade", durationMs: args.durationMs, ease: args.ease };
1458
+ }
1459
+ if (this.checkIdentifier("wipe")) {
1460
+ this.advance();
1461
+ const args = this.parseTransitionArgs(["duration", "ease", "direction"]);
1462
+ this.consumeOptional(TokenType.Semicolon);
1463
+ return { kind: "wipe", durationMs: args.durationMs, ease: args.ease, direction: args.direction };
1464
+ }
1465
+ if (this.checkIdentifier("flash")) {
1466
+ this.advance();
1467
+ const args = this.parseTransitionArgs(["duration"]);
1468
+ this.consumeOptional(TokenType.Semicolon);
1469
+ return { kind: "flash", durationMs: args.durationMs };
1470
+ }
1471
+ throw this.errorAtToken(this.peek(), "Invalid transition spec");
1472
+ }
1473
+ parseTransitionArgs(allowed) {
1474
+ const args = {};
1475
+ this.consume(TokenType.LParen, "Expected '(' after transition");
1476
+ while (!this.check(TokenType.RParen) && !this.isAtEnd()) {
1477
+ if (this.check(TokenType.Identifier) && this.peek(1).type === TokenType.Equals) {
1478
+ const nameTok = this.advance();
1479
+ const name = nameTok.lexeme;
1480
+ this.consume(TokenType.Equals, "Expected '=' after argument name");
1481
+ if (!allowed.includes(name)) {
1482
+ throw this.errorAtToken(nameTok, `Unknown transition argument '${name}'`);
1483
+ }
1484
+ if (name === "duration") {
1485
+ args.durationMs = this.parseDurationMs("Expected transition duration");
1486
+ }
1487
+ else if (name === "ease") {
1488
+ const ease = this.parseTransitionString("Expected transition ease");
1489
+ if (!isTransitionEase(ease)) {
1490
+ throw this.errorAtToken(nameTok, `Unknown transition ease '${ease}'`);
1491
+ }
1492
+ args.ease = ease;
1493
+ }
1494
+ else if (name === "direction") {
1495
+ const direction = this.parseTransitionString("Expected wipe direction");
1496
+ if (!isTransitionDirection(direction)) {
1497
+ throw this.errorAtToken(nameTok, `Unknown wipe direction '${direction}'`);
1498
+ }
1499
+ args.direction = direction;
1500
+ }
1501
+ }
1502
+ else {
1503
+ throw this.errorAtToken(this.peek(), "Expected named transition argument");
1504
+ }
1505
+ if (!this.match(TokenType.Comma))
1506
+ break;
1507
+ }
1508
+ this.consume(TokenType.RParen, "Expected ')' after transition arguments");
1509
+ return args;
1510
+ }
1511
+ parseTransitionString(message) {
1512
+ const tok = this.peek();
1513
+ if (tok.type === TokenType.String || tok.type === TokenType.Identifier) {
1514
+ this.advance();
1515
+ return String(tok.value ?? tok.lexeme);
1516
+ }
1517
+ throw this.errorAtToken(tok, message);
1518
+ }
1519
+ parseChanceEvery() {
1520
+ if (this.check(TokenType.Identifier)) {
1521
+ const tok = this.peek();
1522
+ if (tok.lexeme === "docstep") {
1523
+ this.advance();
1524
+ return { kind: "docstep" };
1525
+ }
1526
+ }
1527
+ if (this.check(TokenType.String)) {
1528
+ const tok = this.advance();
1529
+ const raw = String(tok.value ?? tok.lexeme);
1530
+ if (raw === "docstep") {
1531
+ return { kind: "docstep" };
1532
+ }
1533
+ const seconds = parseTimeString(raw);
1534
+ if (seconds == null) {
1535
+ throw this.errorAtToken(tok, `Invalid duration '${raw}'`);
1536
+ }
1537
+ if (seconds <= 0) {
1538
+ throw this.errorAtToken(tok, "Duration must be positive");
1539
+ }
1540
+ return { kind: "time", intervalSec: seconds };
1541
+ }
1542
+ if (this.check(TokenType.Int) || this.check(TokenType.Float)) {
1543
+ const seconds = this.parseTimeSeconds("Expected duration");
1544
+ return { kind: "time", intervalSec: seconds };
1545
+ }
1546
+ throw this.errorAtToken(this.peek(), "Expected 'docstep' or duration for chance every");
1547
+ }
1548
+ parseTimeSeconds(message, allowZero = false) {
1549
+ const tok = this.peek();
1550
+ if (tok.type === TokenType.String) {
1551
+ this.advance();
1552
+ const raw = String(tok.value ?? tok.lexeme);
1553
+ const seconds = parseTimeString(raw);
1554
+ if (seconds == null) {
1555
+ throw this.errorAtToken(tok, `Invalid duration '${raw}'`);
1556
+ }
1557
+ if (seconds < 0 || (!allowZero && seconds === 0)) {
1558
+ throw this.errorAtToken(tok, allowZero ? "Duration must be non-negative" : "Duration must be positive");
1559
+ }
1560
+ return seconds;
1561
+ }
1562
+ if (tok.type === TokenType.Int || tok.type === TokenType.Float) {
1563
+ const { amount, unit } = this.parseDurationSpec();
1564
+ const seconds = durationToSeconds(amount, unit);
1565
+ if (seconds == null) {
1566
+ throw this.errorAtToken(tok, `Unsupported duration unit '${unit}'`);
1567
+ }
1568
+ if (seconds < 0 || (!allowZero && seconds === 0)) {
1569
+ throw this.errorAtToken(tok, allowZero ? "Duration must be non-negative" : "Duration must be positive");
1570
+ }
1571
+ return seconds;
1572
+ }
1573
+ throw this.errorAtToken(tok, message);
1574
+ }
1575
+ parseDurationMs(message) {
1576
+ const tok = this.peek();
1577
+ if (tok.type === TokenType.String) {
1578
+ this.advance();
1579
+ const raw = String(tok.value ?? tok.lexeme);
1580
+ const seconds = parseTimeString(raw);
1581
+ if (seconds == null) {
1582
+ throw this.errorAtToken(tok, `Invalid duration '${raw}'`);
1583
+ }
1584
+ if (seconds < 0) {
1585
+ throw this.errorAtToken(tok, "Duration must be non-negative");
1586
+ }
1587
+ return seconds * 1000;
1588
+ }
1589
+ if (tok.type === TokenType.Int || tok.type === TokenType.Float) {
1590
+ if (this.peek(1).type === TokenType.Identifier) {
1591
+ const { amount, unit } = this.parseDurationSpec();
1592
+ const seconds = durationToSeconds(amount, unit);
1593
+ if (seconds == null) {
1594
+ throw this.errorAtToken(tok, `Unsupported duration unit '${unit}'`);
1595
+ }
1596
+ if (seconds < 0) {
1597
+ throw this.errorAtToken(tok, "Duration must be non-negative");
1598
+ }
1599
+ return seconds * 1000;
1600
+ }
1601
+ this.advance();
1602
+ const amount = Number(tok.value);
1603
+ if (!Number.isFinite(amount)) {
1604
+ throw this.errorAtToken(tok, message);
1605
+ }
1606
+ if (amount < 0) {
1607
+ throw this.errorAtToken(tok, "Duration must be non-negative");
1608
+ }
1609
+ return amount;
1610
+ }
1611
+ throw this.errorAtToken(tok, message);
1612
+ }
1613
+ looksLikeNodeDecl() {
1614
+ return (this.check(TokenType.Identifier) &&
1615
+ this.peek(1).type === TokenType.Identifier &&
1616
+ this.peek(2).type === TokenType.LBrace);
1617
+ }
1618
+ parseIdentifierList() {
1619
+ this.advance(); // identifier keyword already checked
1620
+ this.consume(TokenType.Equals, "Expected '=' after identifier list key");
1621
+ this.consume(TokenType.LBracket, "Expected '[' to start identifier list");
1622
+ const values = [];
1623
+ if (!this.check(TokenType.RBracket)) {
1624
+ const first = this.consume(TokenType.Identifier, "Expected identifier");
1625
+ values.push(String(first.value));
1626
+ while (this.match(TokenType.Comma)) {
1627
+ const t = this.consume(TokenType.Identifier, "Expected identifier");
1628
+ values.push(String(t.value));
1629
+ }
1630
+ }
1631
+ this.consume(TokenType.RBracket, "Expected ']' after identifier list");
1632
+ this.consumeOptional(TokenType.Semicolon);
1633
+ return values;
1634
+ }
1635
+ parseKeyPath(message) {
1636
+ const first = this.consume(TokenType.Identifier, message);
1637
+ const parts = [String(first.value)];
1638
+ while (this.match(TokenType.Dot)) {
1639
+ const next = this.consume(TokenType.Identifier, "Expected identifier after '.'");
1640
+ parts.push(String(next.value));
1641
+ }
1642
+ return parts.join(".");
1643
+ }
1644
+ parsePropertyMap() {
1645
+ const props = {};
1646
+ while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1647
+ const key = this.parseKeyPath("Expected property name");
1648
+ this.consume(TokenType.Equals, "Expected '=' after property name");
1649
+ if (this.match(TokenType.At)) {
1650
+ const expr = this.parseExpr();
1651
+ props[key] = { kind: "DynamicValue", expr };
1652
+ }
1653
+ else {
1654
+ const value = this.parseValueLiteral();
1655
+ props[key] = { kind: "LiteralValue", value };
1656
+ }
1657
+ this.consumeOptional(TokenType.Semicolon);
1658
+ }
1659
+ return props;
1660
+ }
816
1661
  // --- Expressions ---
817
1662
  parseExpr() {
818
1663
  return this.parseOr();
@@ -935,6 +1780,7 @@ class Parser {
935
1780
  kind: "UnaryExpression",
936
1781
  op,
937
1782
  argument,
1783
+ loc: argument?.loc,
938
1784
  };
939
1785
  }
940
1786
  if (this.match(TokenType.Minus)) {
@@ -944,6 +1790,7 @@ class Parser {
944
1790
  kind: "UnaryExpression",
945
1791
  op,
946
1792
  argument,
1793
+ loc: argument?.loc,
947
1794
  };
948
1795
  }
949
1796
  return this.parsePostfix();
@@ -958,11 +1805,14 @@ class Parser {
958
1805
  kind: "MemberExpression",
959
1806
  object: expr,
960
1807
  property,
1808
+ loc: expr?.loc,
961
1809
  };
962
1810
  }
963
1811
  else if (this.match(TokenType.LParen)) {
964
1812
  const args = this.parseArgumentList();
965
- expr = this.maybeNeighborsCall(expr, args);
1813
+ const callExpr = this.maybeNeighborsCall(expr, args);
1814
+ callExpr.loc = expr?.loc;
1815
+ expr = callExpr;
966
1816
  }
967
1817
  else {
968
1818
  break;
@@ -979,6 +1829,7 @@ class Parser {
979
1829
  return {
980
1830
  kind: "Literal",
981
1831
  value: tok.value,
1832
+ loc: { line: tok.line, column: tok.column },
982
1833
  };
983
1834
  }
984
1835
  case TokenType.String: {
@@ -986,6 +1837,7 @@ class Parser {
986
1837
  return {
987
1838
  kind: "Literal",
988
1839
  value: tok.value,
1840
+ loc: { line: tok.line, column: tok.column },
989
1841
  };
990
1842
  }
991
1843
  case TokenType.Bool: {
@@ -993,6 +1845,23 @@ class Parser {
993
1845
  return {
994
1846
  kind: "Literal",
995
1847
  value: tok.value,
1848
+ loc: { line: tok.line, column: tok.column },
1849
+ };
1850
+ }
1851
+ case TokenType.LBracket: {
1852
+ this.advance(); // '['
1853
+ const items = [];
1854
+ if (!this.check(TokenType.RBracket)) {
1855
+ items.push(this.parseExpr());
1856
+ while (this.match(TokenType.Comma)) {
1857
+ items.push(this.parseExpr());
1858
+ }
1859
+ }
1860
+ this.consume(TokenType.RBracket, "Expected ']' after list literal");
1861
+ return {
1862
+ kind: "ListExpression",
1863
+ items,
1864
+ loc: { line: tok.line, column: tok.column },
996
1865
  };
997
1866
  }
998
1867
  case TokenType.Identifier: {
@@ -1000,6 +1869,7 @@ class Parser {
1000
1869
  return {
1001
1870
  kind: "Identifier",
1002
1871
  name: tok.lexeme,
1872
+ loc: { line: tok.line, column: tok.column },
1003
1873
  };
1004
1874
  }
1005
1875
  case TokenType.LBrace: {
@@ -1026,13 +1896,23 @@ class Parser {
1026
1896
  this.consume(TokenType.RParen, "Expected ')' after argument list");
1027
1897
  return args;
1028
1898
  }
1029
- args.push(this.parseExpr());
1899
+ args.push(this.parseCallArg());
1030
1900
  while (this.match(TokenType.Comma)) {
1031
- args.push(this.parseExpr());
1901
+ args.push(this.parseCallArg());
1032
1902
  }
1033
1903
  this.consume(TokenType.RParen, "Expected ')' after argument list");
1034
1904
  return args;
1035
1905
  }
1906
+ parseCallArg() {
1907
+ if (this.check(TokenType.Identifier) && this.peek(1).type === TokenType.Equals) {
1908
+ const nameTok = this.advance();
1909
+ const name = String(nameTok.value);
1910
+ this.consume(TokenType.Equals, "Expected '=' after argument name");
1911
+ const value = this.parseExpr();
1912
+ return { kind: "NamedArg", name, value };
1913
+ }
1914
+ return this.parseExpr();
1915
+ }
1036
1916
  maybeNeighborsCall(callee, args) {
1037
1917
  if (callee.kind === "MemberExpression" &&
1038
1918
  callee.object.kind === "Identifier" &&
@@ -1056,6 +1936,7 @@ class Parser {
1056
1936
  op,
1057
1937
  left,
1058
1938
  right,
1939
+ loc: left?.loc,
1059
1940
  };
1060
1941
  }
1061
1942
  // --- Rule & Runtime (placeholders for now) ---
@@ -1267,27 +2148,43 @@ class Parser {
1267
2148
  const unitTok = this.advance();
1268
2149
  const raw = String(unitTok.value);
1269
2150
  const lowered = raw.toLowerCase();
1270
- // seconds
1271
- if (lowered === "s" ||
1272
- lowered === "sec" ||
1273
- lowered === "secs" ||
1274
- lowered === "second" ||
1275
- lowered === "seconds") {
1276
- unit = "s";
1277
- }
1278
- // milliseconds
1279
- else if (lowered === "ms" ||
1280
- lowered === "millisecond" ||
1281
- lowered === "milliseconds") {
1282
- unit = "ms";
1283
- }
1284
- // beats (musical)
1285
- else if (lowered === "beat" || lowered === "beats") {
1286
- unit = "beats";
1287
- }
1288
- else {
2151
+ const unitMap = {
2152
+ s: "s",
2153
+ sec: "s",
2154
+ secs: "s",
2155
+ second: "s",
2156
+ seconds: "s",
2157
+ ms: "ms",
2158
+ millisecond: "ms",
2159
+ milliseconds: "ms",
2160
+ m: "m",
2161
+ min: "m",
2162
+ mins: "m",
2163
+ minute: "m",
2164
+ minutes: "m",
2165
+ h: "h",
2166
+ hr: "h",
2167
+ hrs: "h",
2168
+ hour: "h",
2169
+ hours: "h",
2170
+ beat: "beats",
2171
+ beats: "beats",
2172
+ bar: "bars",
2173
+ bars: "bars",
2174
+ measure: "bars",
2175
+ measures: "bars",
2176
+ sub: "subs",
2177
+ subs: "subs",
2178
+ subdivision: "subs",
2179
+ subdivisions: "subs",
2180
+ tick: "ticks",
2181
+ ticks: "ticks",
2182
+ };
2183
+ const mapped = unitMap[lowered];
2184
+ if (!mapped) {
1289
2185
  throw this.errorAtToken(unitTok, `Unknown duration unit '${unitTok.lexeme}'`);
1290
2186
  }
2187
+ unit = mapped;
1291
2188
  }
1292
2189
  return { amount, unit };
1293
2190
  }
@@ -1370,6 +2267,38 @@ class Parser {
1370
2267
  throw this.errorAtToken(tok, "Expected literal");
1371
2268
  }
1372
2269
  }
2270
+ parseValueLiteral() {
2271
+ const tok = this.peek();
2272
+ switch (tok.type) {
2273
+ case TokenType.Int:
2274
+ case TokenType.Float:
2275
+ this.advance();
2276
+ return tok.value;
2277
+ case TokenType.String:
2278
+ this.advance();
2279
+ return tok.value;
2280
+ case TokenType.Bool:
2281
+ this.advance();
2282
+ return tok.value;
2283
+ case TokenType.Identifier:
2284
+ this.advance();
2285
+ return tok.lexeme;
2286
+ case TokenType.LBracket: {
2287
+ this.advance();
2288
+ const items = [];
2289
+ if (!this.check(TokenType.RBracket)) {
2290
+ items.push(this.parseValueLiteral());
2291
+ while (this.match(TokenType.Comma)) {
2292
+ items.push(this.parseValueLiteral());
2293
+ }
2294
+ }
2295
+ this.consume(TokenType.RBracket, "Expected ']' after list literal");
2296
+ return items;
2297
+ }
2298
+ default:
2299
+ throw this.errorAtToken(tok, "Expected literal value");
2300
+ }
2301
+ }
1373
2302
  consumeNumber(message) {
1374
2303
  const tok = this.peek();
1375
2304
  if (tok.type === TokenType.Int || tok.type === TokenType.Float) {
@@ -1442,11 +2371,190 @@ class Parser {
1442
2371
  return new Error(`Parse error at ${token.line}:${token.column} near '${token.lexeme}': ${message}`);
1443
2372
  }
1444
2373
  }
1445
- // Public API
1446
- export function parseDocument(source) {
2374
+ function isTransitionEase(value) {
2375
+ return value === "linear" || value === "inOut" || value === "in" || value === "out";
2376
+ }
2377
+ function isTransitionDirection(value) {
2378
+ return value === "left" || value === "right" || value === "up" || value === "down";
2379
+ }
2380
+ const DEFAULT_INCLUDE_BYTES = 1024 * 1024;
2381
+ const DEFAULT_INCLUDE_DEPTH = 8;
2382
+ export function parseDocument(source, options = {}) {
1447
2383
  const lexer = new Lexer(source);
1448
2384
  const tokens = lexer.tokenize();
1449
- const parser = new Parser(tokens);
1450
- return parser.parseDocument();
2385
+ const parser = new Parser(tokens, { allowBodyFragments: options.allowBodyFragments });
2386
+ const doc = parser.parseDocument();
2387
+ const shouldResolve = options.resolveIncludes || options.sourcePath || options.docRoot;
2388
+ if (!shouldResolve)
2389
+ return doc;
2390
+ const root = options.docRoot ?? (options.sourcePath ? path.dirname(options.sourcePath) : null);
2391
+ if (!root)
2392
+ return doc;
2393
+ return resolveIncludes(doc, {
2394
+ docRoot: root,
2395
+ sourcePath: options.sourcePath ?? "<buffer>",
2396
+ maxIncludeBytes: options.maxIncludeBytes ?? DEFAULT_INCLUDE_BYTES,
2397
+ includeDepthLimit: options.includeDepthLimit ?? DEFAULT_INCLUDE_DEPTH,
2398
+ });
2399
+ }
2400
+ function resolveIncludes(doc, options) {
2401
+ const context = { ...options, seen: new Set() };
2402
+ const body = doc.body;
2403
+ if (!body?.nodes?.length)
2404
+ return doc;
2405
+ const nodes = resolveIncludeNodes(body.nodes, context, "root", 0);
2406
+ return { ...doc, body: { nodes } };
2407
+ }
2408
+ function resolveIncludeNodes(nodes, ctx, parentPath, depth) {
2409
+ if (depth > ctx.includeDepthLimit) {
2410
+ throw new Error(`Include depth exceeds limit (${ctx.includeDepthLimit}) at ${parentPath}`);
2411
+ }
2412
+ const result = [];
2413
+ for (let index = 0; index < nodes.length; index += 1) {
2414
+ const node = nodes[index];
2415
+ const nodePath = `${parentPath}/${node.kind}:${node.id}:${index}`;
2416
+ if (node.kind === "include") {
2417
+ const includePath = resolveIncludePath(node, ctx, nodePath);
2418
+ const included = loadIncludedDocument(includePath, ctx, depth + 1);
2419
+ const includeNodes = included.body?.nodes ?? [];
2420
+ const rewritten = rewriteNodeIds(includeNodes, includePath, nodePath);
2421
+ result.push(...rewritten);
2422
+ continue;
2423
+ }
2424
+ const children = node.children?.length
2425
+ ? resolveIncludeNodes(node.children, ctx, nodePath, depth)
2426
+ : [];
2427
+ result.push({ ...node, children });
2428
+ }
2429
+ return result;
2430
+ }
2431
+ function resolveIncludePath(node, ctx, nodePath) {
2432
+ const raw = node.props?.path?.kind === "LiteralValue"
2433
+ ? String(node.props.path.value)
2434
+ : node.props?.src?.kind === "LiteralValue"
2435
+ ? String(node.props.src.value)
2436
+ : null;
2437
+ if (!raw) {
2438
+ throw new Error(`Include at ${nodePath} requires a literal 'path' (or 'src') string`);
2439
+ }
2440
+ if (path.isAbsolute(raw)) {
2441
+ throw new Error(`Include path must be relative: '${raw}'`);
2442
+ }
2443
+ if (raw.split(/[\\/]+/).some((part) => part === "..")) {
2444
+ throw new Error(`Include path cannot contain '..': '${raw}'`);
2445
+ }
2446
+ const resolved = path.resolve(ctx.docRoot, raw);
2447
+ if (!resolved.startsWith(ctx.docRoot)) {
2448
+ throw new Error(`Include path escapes doc root: '${raw}'`);
2449
+ }
2450
+ if (path.extname(resolved) !== ".flux") {
2451
+ throw new Error(`Include path must target a .flux file: '${raw}'`);
2452
+ }
2453
+ return resolved;
2454
+ }
2455
+ function parseTimeString(raw) {
2456
+ const trimmed = raw.trim();
2457
+ const match = trimmed.match(/^([0-9]*\.?[0-9]+)\s*(ms|s|m)$/i);
2458
+ if (!match)
2459
+ return null;
2460
+ const value = Number(match[1]);
2461
+ if (!Number.isFinite(value) || value < 0)
2462
+ return null;
2463
+ const unit = match[2].toLowerCase();
2464
+ switch (unit) {
2465
+ case "ms":
2466
+ return value / 1000;
2467
+ case "s":
2468
+ return value;
2469
+ case "m":
2470
+ return value * 60;
2471
+ default:
2472
+ return null;
2473
+ }
2474
+ }
2475
+ function durationToSeconds(amount, unit) {
2476
+ switch (unit) {
2477
+ case "ms":
2478
+ return amount / 1000;
2479
+ case "s":
2480
+ case "sec":
2481
+ case "secs":
2482
+ case "second":
2483
+ case "seconds":
2484
+ return amount;
2485
+ case "m":
2486
+ case "min":
2487
+ case "mins":
2488
+ case "minute":
2489
+ case "minutes":
2490
+ return amount * 60;
2491
+ case "h":
2492
+ case "hr":
2493
+ case "hrs":
2494
+ case "hour":
2495
+ case "hours":
2496
+ return amount * 3600;
2497
+ default:
2498
+ return null;
2499
+ }
2500
+ }
2501
+ function loadIncludedDocument(filePath, ctx, depth) {
2502
+ if (ctx.seen.has(filePath)) {
2503
+ throw new Error(`Include cycle detected at '${filePath}'`);
2504
+ }
2505
+ ctx.seen.add(filePath);
2506
+ const stat = fs.statSync(filePath);
2507
+ if (stat.size > ctx.maxIncludeBytes) {
2508
+ throw new Error(`Include file too large (${stat.size} bytes): '${filePath}'`);
2509
+ }
2510
+ const source = fs.readFileSync(filePath, "utf8");
2511
+ const doc = parseDocument(source, {
2512
+ sourcePath: filePath,
2513
+ docRoot: ctx.docRoot,
2514
+ resolveIncludes: true,
2515
+ maxIncludeBytes: ctx.maxIncludeBytes,
2516
+ includeDepthLimit: ctx.includeDepthLimit - depth,
2517
+ allowBodyFragments: true,
2518
+ });
2519
+ ctx.seen.delete(filePath);
2520
+ return doc;
2521
+ }
2522
+ function rewriteNodeIds(nodes, includePath, parentPath) {
2523
+ return nodes.map((node, index) => {
2524
+ const nodePath = `${parentPath}/${node.kind}:${node.id}:${index}`;
2525
+ const hash = stableHash(includePath, nodePath).toString(16).padStart(6, "0");
2526
+ const nextId = `${node.id}_${hash}`;
2527
+ const children = rewriteNodeIds(node.children ?? [], includePath, nodePath);
2528
+ return { ...node, id: nextId, children };
2529
+ });
2530
+ }
2531
+ function stableHash(...values) {
2532
+ const serialized = values.map((value) => stableSerialize(value)).join("|");
2533
+ let hash = 0x811c9dc5;
2534
+ for (let i = 0; i < serialized.length; i += 1) {
2535
+ hash ^= serialized.charCodeAt(i);
2536
+ hash = Math.imul(hash, 0x01000193);
2537
+ }
2538
+ return hash >>> 0;
2539
+ }
2540
+ function stableSerialize(value) {
2541
+ if (value == null)
2542
+ return "null";
2543
+ if (typeof value === "number")
2544
+ return `n:${String(value)}`;
2545
+ if (typeof value === "string")
2546
+ return `s:${value}`;
2547
+ if (typeof value === "boolean")
2548
+ return `b:${value}`;
2549
+ if (Array.isArray(value)) {
2550
+ return `a:[${value.map((item) => stableSerialize(item)).join(",")}]`;
2551
+ }
2552
+ if (typeof value === "object") {
2553
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
2554
+ return `o:{${entries
2555
+ .map(([key, val]) => `${key}:${stableSerialize(val)}`)
2556
+ .join(",")}}`;
2557
+ }
2558
+ return `u:${String(value)}`;
1451
2559
  }
1452
2560
  //# sourceMappingURL=parser.js.map