@mathscan/math-exercise-engine 1.2.0 → 1.2.1

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/index.js CHANGED
@@ -394,13 +394,9 @@ const MATCHER = chevrotain.createToken({
394
394
  name: "MATCHER",
395
395
  pattern: /MATCHER/
396
396
  });
397
- const TOP_ROW = chevrotain.createToken({
398
- name: "TOP_ROW",
399
- pattern: /TOP_ROW/
400
- });
401
- const BOTTOM_ROW = chevrotain.createToken({
402
- name: "BOTTOM_ROW",
403
- pattern: /BOTTOM_ROW/
397
+ const GROUP = chevrotain.createToken({
398
+ name: "GROUP",
399
+ pattern: /GROUP/
404
400
  });
405
401
  const ITEM = chevrotain.createToken({
406
402
  name: "ITEM",
@@ -556,8 +552,7 @@ const allTokens = [
556
552
  ARROW,
557
553
  TABLE,
558
554
  ROW,
559
- TOP_ROW,
560
- BOTTOM_ROW,
555
+ GROUP,
561
556
  CELL,
562
557
  COUNTER,
563
558
  CLOCK,
@@ -1065,39 +1060,19 @@ class MathExerciseParser extends chevrotain.CstParser {
1065
1060
  this.MANY3(() => {
1066
1061
  this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1067
1062
  });
1068
- this.SUBRULE(this.pairMatcherTopSection);
1069
- this.SUBRULE(this.pairMatcherBottomSection);
1063
+ this.SUBRULE(this.pairMatcherGroupSection);
1064
+ this.SUBRULE2(this.pairMatcherGroupSection);
1070
1065
  this.SUBRULE(this.matcherAnswers);
1071
1066
  this.CONSUME(RBrace);
1072
1067
  this.MANY4(() => {
1073
1068
  this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
1074
1069
  });
1075
1070
  });
1076
- this.pairMatcherTopSection = this.RULE("pairMatcherTopSection", () => {
1077
- this.MANY(() => {
1078
- this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1079
- });
1080
- this.CONSUME(TOP_ROW);
1081
- this.MANY2(() => {
1082
- this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1083
- });
1084
- this.CONSUME(LBrace);
1085
- this.MANY3(() => {
1086
- this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1087
- });
1088
- this.AT_LEAST_ONE(() => {
1089
- this.SUBRULE(this.matcherItem);
1090
- });
1091
- this.CONSUME(RBrace);
1092
- this.MANY4(() => {
1093
- this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1094
- });
1095
- });
1096
- this.pairMatcherBottomSection = this.RULE("pairMatcherBottomSection", () => {
1071
+ this.pairMatcherGroupSection = this.RULE("pairMatcherGroupSection", () => {
1097
1072
  this.MANY(() => {
1098
1073
  this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1099
1074
  });
1100
- this.CONSUME(BOTTOM_ROW);
1075
+ this.CONSUME(GROUP);
1101
1076
  this.MANY2(() => {
1102
1077
  this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1103
1078
  });
@@ -2590,20 +2565,24 @@ function isMatcherBlock(value) {
2590
2565
  const PairMatcherBlockSchema = zod.z.object({
2591
2566
  type: zod.z.literal(AST_TYPES.PAIR_MATCHER_BLOCK),
2592
2567
  name: zod.z.string().min(1),
2593
- /** Items in the top row — at least one required */
2594
- topItems: zod.z.array(MatcherItemSchema).min(1),
2595
- /** Items in the bottom row — at least one required */
2596
- bottomItems: zod.z.array(MatcherItemSchema).min(1),
2568
+ /** Row layout (top/bottom, default) or column layout (left/right). */
2569
+ layout: zod.z.enum(["rows", "columns"]).optional(),
2570
+ /** Exactly two groups, in declaration order — neither more nor fewer. */
2571
+ groups: zod.z.tuple([
2572
+ zod.z.array(MatcherItemSchema).min(1),
2573
+ zod.z.array(MatcherItemSchema).min(1)
2574
+ ]),
2597
2575
  /**
2598
- * Each answer pair is [topId, bottomId].
2576
+ * Each answer pair is [groups[0] id, groups[1] id].
2599
2577
  * At least one pair is required.
2600
2578
  */
2601
2579
  answers: zod.z.array(zod.z.tuple([zod.z.string().min(1), zod.z.string().min(1)])).min(1)
2602
2580
  }).superRefine((data, ctx) => {
2581
+ const [groupAItems, groupBItems] = data.groups;
2603
2582
  const idRegistry = /* @__PURE__ */ new Map();
2604
2583
  const sections = [
2605
- { key: "topItems", items: data.topItems },
2606
- { key: "bottomItems", items: data.bottomItems }
2584
+ { key: 0, items: groupAItems },
2585
+ { key: 1, items: groupBItems }
2607
2586
  ];
2608
2587
  for (const { key: key2, items } of sections) {
2609
2588
  for (let i = 0; i < items.length; i++) {
@@ -2613,59 +2592,59 @@ const PairMatcherBlockSchema = zod.z.object({
2613
2592
  const isCrossSection = existing.section !== key2;
2614
2593
  ctx.addIssue({
2615
2594
  code: zod.z.ZodIssueCode.custom,
2616
- message: isCrossSection ? `itemId "${itemId}" appears in both TOP and BOTTOM — ids must be globally unique` : `Duplicate itemId "${itemId}" in ${key2 === "topItems" ? "TOP" : "BOTTOM"} section`,
2617
- path: [key2, i, "itemId"]
2595
+ message: isCrossSection ? `itemId "${itemId}" appears in both groups — ids must be globally unique` : `Duplicate itemId "${itemId}" in group ${key2}`,
2596
+ path: ["groups", key2, i, "itemId"]
2618
2597
  });
2619
2598
  } else {
2620
2599
  idRegistry.set(itemId, { section: key2, index: i });
2621
2600
  }
2622
2601
  }
2623
2602
  }
2624
- const topIds = new Set(data.topItems.map((it) => it.itemId));
2625
- const bottomIds = new Set(data.bottomItems.map((it) => it.itemId));
2626
- const usedTopIds = /* @__PURE__ */ new Set();
2627
- const usedBottomIds = /* @__PURE__ */ new Set();
2628
- data.answers.forEach(([topId, bottomId], i) => {
2629
- if (!topIds.has(topId)) {
2603
+ const groupAIds = new Set(groupAItems.map((it) => it.itemId));
2604
+ const groupBIds = new Set(groupBItems.map((it) => it.itemId));
2605
+ const usedGroupAIds = /* @__PURE__ */ new Set();
2606
+ const usedGroupBIds = /* @__PURE__ */ new Set();
2607
+ data.answers.forEach(([aId, bId], i) => {
2608
+ if (!groupAIds.has(aId)) {
2630
2609
  ctx.addIssue({
2631
2610
  code: zod.z.ZodIssueCode.custom,
2632
- message: `Answer pair [${topId}, ${bottomId}]: "${topId}" is not declared in TOP section`,
2611
+ message: `Answer pair [${aId}, ${bId}]: "${aId}" is not declared in groups[0]`,
2633
2612
  path: ["answers", i, 0]
2634
2613
  });
2635
2614
  }
2636
- if (!bottomIds.has(bottomId)) {
2615
+ if (!groupBIds.has(bId)) {
2637
2616
  ctx.addIssue({
2638
2617
  code: zod.z.ZodIssueCode.custom,
2639
- message: `Answer pair [${topId}, ${bottomId}]: "${bottomId}" is not declared in BOTTOM section`,
2618
+ message: `Answer pair [${aId}, ${bId}]: "${bId}" is not declared in groups[1]`,
2640
2619
  path: ["answers", i, 1]
2641
2620
  });
2642
2621
  }
2643
- if (usedTopIds.has(topId)) {
2622
+ if (usedGroupAIds.has(aId)) {
2644
2623
  ctx.addIssue({
2645
2624
  code: zod.z.ZodIssueCode.custom,
2646
- message: `topId "${topId}" appears in more than one answer pair`,
2625
+ message: `id "${aId}" appears in more than one answer pair`,
2647
2626
  path: ["answers", i, 0]
2648
2627
  });
2649
2628
  } else {
2650
- usedTopIds.add(topId);
2629
+ usedGroupAIds.add(aId);
2651
2630
  }
2652
- if (usedBottomIds.has(bottomId)) {
2631
+ if (usedGroupBIds.has(bId)) {
2653
2632
  ctx.addIssue({
2654
2633
  code: zod.z.ZodIssueCode.custom,
2655
- message: `bottomId "${bottomId}" appears in more than one answer pair`,
2634
+ message: `id "${bId}" appears in more than one answer pair`,
2656
2635
  path: ["answers", i, 1]
2657
2636
  });
2658
2637
  } else {
2659
- usedBottomIds.add(bottomId);
2638
+ usedGroupBIds.add(bId);
2660
2639
  }
2661
2640
  });
2662
2641
  });
2663
- function createPairMatcherBlock(name, topItems, bottomItems, answers) {
2642
+ function createPairMatcherBlock(name, groups, answers, layout) {
2664
2643
  return PairMatcherBlockSchema.parse({
2665
2644
  type: AST_TYPES.PAIR_MATCHER_BLOCK,
2666
2645
  name,
2667
- topItems,
2668
- bottomItems,
2646
+ layout,
2647
+ groups,
2669
2648
  answers
2670
2649
  });
2671
2650
  }
@@ -2852,7 +2831,7 @@ const ExerciseDocumentSchema = zod.z.object({
2852
2831
  } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
2853
2832
  const pm = block;
2854
2833
  record(pm.name, ["lines", bi, "name"]);
2855
- [...pm.topItems, ...pm.bottomItems].forEach((item, ii) => {
2834
+ pm.groups.flat().forEach((item, ii) => {
2856
2835
  visitInlines(item.inlines, ["lines", bi, "items", ii, "inlines"]);
2857
2836
  });
2858
2837
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -3065,24 +3044,14 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3065
3044
  pairMatcher(ctx) {
3066
3045
  const params = this.visit(ctx.parameterList);
3067
3046
  const name = params.name;
3068
- const topItems = this.visit(ctx.pairMatcherTopSection);
3069
- const bottomItems = this.visit(ctx.pairMatcherBottomSection);
3047
+ const layout = params.layout;
3048
+ const [firstGroup, secondGroup] = ctx.pairMatcherGroupSection;
3049
+ const groupAItems = this.visit(firstGroup);
3050
+ const groupBItems = this.visit(secondGroup);
3070
3051
  const rawAnswers = this.visit(ctx.matcherAnswers);
3071
- return createPairMatcherBlock(name, topItems, bottomItems, rawAnswers);
3052
+ return createPairMatcherBlock(name, [groupAItems, groupBItems], rawAnswers, layout);
3072
3053
  }
3073
- pairMatcherTopSection(ctx) {
3074
- return this.visitMatcherItemList(ctx);
3075
- }
3076
- pairMatcherBottomSection(ctx) {
3077
- return this.visitMatcherItemList(ctx);
3078
- }
3079
- /**
3080
- * Maps `ctx.matcherItem` CST nodes to MatcherItem AST nodes.
3081
- * Shared by pairMatcherTopSection and pairMatcherBottomSection because their
3082
- * grammar rules are structurally identical — both are `matcherItem+` lists
3083
- * wrapped in a labelled section keyword.
3084
- */
3085
- visitMatcherItemList(ctx) {
3054
+ pairMatcherGroupSection(ctx) {
3086
3055
  return ctx.matcherItem ? ctx.matcherItem.map((i) => this.visit(i)) : [];
3087
3056
  }
3088
3057
  // ═══════════════════════════════════════════════════════════════════════════
@@ -3874,8 +3843,12 @@ const MatcherWidgetJSONSchema = zod.z.object({
3874
3843
  });
3875
3844
  const PairMatcherWidgetJSONSchema = zod.z.object({
3876
3845
  type: zod.z.literal(JSON_TYPES.PAIR_MATCHER),
3877
- topItems: zod.z.array(MatcherItemContentSchema).min(1),
3878
- bottomItems: zod.z.array(MatcherItemContentSchema).min(1)
3846
+ /** Row layout (top/bottom, default) or column layout (left/right). */
3847
+ layout: zod.z.enum(["rows", "columns"]).optional(),
3848
+ groups: zod.z.tuple([
3849
+ zod.z.array(MatcherItemContentSchema).min(1),
3850
+ zod.z.array(MatcherItemContentSchema).min(1)
3851
+ ])
3879
3852
  });
3880
3853
  const VisualFractionJSONSchema = zod.z.object({
3881
3854
  type: zod.z.literal(JSON_TYPES.VISUAL_FRACTION),
@@ -9835,7 +9808,7 @@ function collectAllInlines(blocks) {
9835
9808
  }
9836
9809
  } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
9837
9810
  const pm = block;
9838
- for (const item of [...pm.topItems, ...pm.bottomItems]) {
9811
+ for (const item of pm.groups.flat()) {
9839
9812
  result.push(...item.inlines);
9840
9813
  }
9841
9814
  }
@@ -9942,10 +9915,14 @@ function buildMatcherWidget(matcher, idGenerator, inlineDragIds) {
9942
9915
  return result;
9943
9916
  }
9944
9917
  function buildPairMatcherWidget(pm, idGenerator, inlineDragIds) {
9918
+ const [groupAItems, groupBItems] = pm.groups;
9945
9919
  return {
9946
9920
  type: JSON_TYPES.PAIR_MATCHER,
9947
- topItems: mapMatcherItemList(pm.topItems, idGenerator, inlineDragIds),
9948
- bottomItems: mapMatcherItemList(pm.bottomItems, idGenerator, inlineDragIds)
9921
+ ...pm.layout !== void 0 && { layout: pm.layout },
9922
+ groups: [
9923
+ mapMatcherItemList(groupAItems, idGenerator, inlineDragIds),
9924
+ mapMatcherItemList(groupBItems, idGenerator, inlineDragIds)
9925
+ ]
9949
9926
  };
9950
9927
  }
9951
9928
  function transformVerticalOperation(vop, idGenerator) {
@@ -16740,7 +16717,9 @@ const PairMatcherWidget = ({
16740
16717
  widgets,
16741
16718
  shuffleItems = true
16742
16719
  }) => {
16743
- const { topItems, bottomItems } = widget;
16720
+ const { groups, layout } = widget;
16721
+ const [topItems, bottomItems] = groups;
16722
+ const isColumns = layout === "columns";
16744
16723
  const exerciseLocked = useExerciseLocked();
16745
16724
  const [shuffledTop] = React.useState(
16746
16725
  () => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
@@ -16917,8 +16896,8 @@ const PairMatcherWidget = ({
16917
16896
  pairStatusByTopId
16918
16897
  });
16919
16898
  const cols = React.useMemo(
16920
- () => Math.max(topItems.length, bottomItems.length, 1),
16921
- [topItems.length, bottomItems.length]
16899
+ () => isColumns ? null : Math.max(topItems.length, bottomItems.length, 1),
16900
+ [isColumns, topItems.length, bottomItems.length]
16922
16901
  );
16923
16902
  const renderCard = (item, isTop) => {
16924
16903
  const isRtl = shouldItemsUseRTL(item.content);
@@ -16964,8 +16943,8 @@ const PairMatcherWidget = ({
16964
16943
  return /* @__PURE__ */ jsxRuntime.jsxs(
16965
16944
  "div",
16966
16945
  {
16967
- className: `pair-matcher-widget${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
16968
- style: { "--pm-cols": cols },
16946
+ className: `pair-matcher-widget${isColumns ? " pair-matcher-widget--columns" : ""}${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
16947
+ style: cols === null ? {} : { "--pm-cols": cols },
16969
16948
  ref: containerRef,
16970
16949
  role: "group",
16971
16950
  "aria-label": "Pair matcher exercise",