mcp-recipes 1.3.1 → 2.0.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.
@@ -13,17 +13,41 @@ function readLanguage(line, evidence = {
13
13
  }) {
14
14
  let french = 0;
15
15
  let english = 0;
16
- if (FRENCH_WORDS.test(line)) french += STRUCTURAL;
17
- if (FRENCH_LETTERS.test(line)) french += STRUCTURAL;
18
- if (ENGLISH_WORDS.test(line)) english += STRUCTURAL;
19
- if (evidence.frenchUnit) french += VOCABULARY;
20
- if (evidence.englishUnit) english += VOCABULARY;
21
- if (FRENCH_FOODS.test(line)) french += NAME;
22
- if (ENGLISH_FOODS.test(line)) english += NAME;
16
+ if (FRENCH_WORDS.test(line)) {
17
+ french += STRUCTURAL;
18
+ }
19
+ if (FRENCH_LETTERS.test(line)) {
20
+ french += STRUCTURAL;
21
+ }
22
+ if (ENGLISH_WORDS.test(line)) {
23
+ english += STRUCTURAL;
24
+ }
25
+ if (evidence.frenchUnit) {
26
+ french += VOCABULARY;
27
+ }
28
+ if (evidence.englishUnit) {
29
+ english += VOCABULARY;
30
+ }
31
+ if (FRENCH_FOODS.test(line)) {
32
+ french += NAME;
33
+ }
34
+ if (ENGLISH_FOODS.test(line)) {
35
+ english += NAME;
36
+ }
23
37
  return { language: french > english ? "fr" : "en", french, english };
24
38
  }
25
39
 
26
40
  // src/recipe/units.ts
41
+ var AL_ENDING = /al$/i;
42
+ var AUX_ENDING = /aux$/i;
43
+ var EAUX_ENDING = /eaux$/i;
44
+ var EAU_ENDING = /eau$/i;
45
+ var FUL_MEASURE = /^[a-z]{3,}fuls?$/;
46
+ var HEAD_BEFORE_DE = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u");
47
+ var HOUSEHOLD_MEASURE = /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse)$/;
48
+ var SIBILANT_LETTER = /[sxz]$/i;
49
+ var TRAILING_S_ANY_CASE = /s$/i;
50
+ var VOWEL_THEN_S = /[aiou]s$/i;
27
51
  var SHARED_UNITS = {
28
52
  mg: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
29
53
  g: { canonical: "g", kind: "measured", system: "metric", symbol: true },
@@ -357,12 +381,20 @@ var NOT_A_MEASURE = /* @__PURE__ */ new Set([
357
381
  "ensemble"
358
382
  ]);
359
383
  function readPartitiveMeasure(text) {
360
- const match = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u").exec(text);
361
- if (!match) return null;
362
- const word = match[1];
363
- if (word.length < 3) return null;
364
- if (NOT_A_MEASURE.has(normalizeUnitKey(word))) return null;
365
- if (lookupUnit(word, "fr")) return null;
384
+ const match = HEAD_BEFORE_DE.exec(text);
385
+ if (!match) {
386
+ return null;
387
+ }
388
+ const [word = ""] = match.slice(1);
389
+ if (word.length < 3) {
390
+ return null;
391
+ }
392
+ if (NOT_A_MEASURE.has(normalizeUnitKey(word))) {
393
+ return null;
394
+ }
395
+ if (lookupUnit(word, "fr")) {
396
+ return null;
397
+ }
366
398
  const canonical = frenchSingular(word);
367
399
  return {
368
400
  unit: {
@@ -376,21 +408,37 @@ function readPartitiveMeasure(text) {
376
408
  }
377
409
  function readContainerLoad(word) {
378
410
  const key = normalizeUnitKey(word);
379
- if (!/^[a-z]{3,}fuls?$/.test(key)) return null;
411
+ if (!FUL_MEASURE.test(key)) {
412
+ return null;
413
+ }
380
414
  const canonical = key.endsWith("fuls") ? key.slice(0, -1) : key;
381
415
  return { canonical, kind: "approximate", system: "none", plural: `${canonical}s` };
382
416
  }
383
417
  function frenchSingular(word) {
384
- if (/eaux$/i.test(word)) return word.slice(0, -1);
385
- if (/aux$/i.test(word)) return `${word.slice(0, -3)}al`;
386
- if (/[aiou]s$/i.test(word)) return word;
387
- if (/s$/i.test(word) && word.length > 3) return word.slice(0, -1);
418
+ if (EAUX_ENDING.test(word)) {
419
+ return word.slice(0, -1);
420
+ }
421
+ if (AUX_ENDING.test(word)) {
422
+ return `${word.slice(0, -3)}al`;
423
+ }
424
+ if (VOWEL_THEN_S.test(word)) {
425
+ return word;
426
+ }
427
+ if (TRAILING_S_ANY_CASE.test(word) && word.length > 3) {
428
+ return word.slice(0, -1);
429
+ }
388
430
  return word;
389
431
  }
390
432
  function frenchPlural(word) {
391
- if (/[sxz]$/i.test(word)) return word;
392
- if (/eau$/i.test(word)) return `${word}x`;
393
- if (/al$/i.test(word)) return `${word.slice(0, -2)}aux`;
433
+ if (SIBILANT_LETTER.test(word)) {
434
+ return word;
435
+ }
436
+ if (EAU_ENDING.test(word)) {
437
+ return `${word}x`;
438
+ }
439
+ if (AL_ENDING.test(word)) {
440
+ return `${word.slice(0, -2)}aux`;
441
+ }
394
442
  return `${word}s`;
395
443
  }
396
444
  var APPROXIMATE_EQUIVALENT = {
@@ -451,17 +499,19 @@ var DEMOTIONS = {
451
499
  };
452
500
  function demoteUnit(unit) {
453
501
  const step = DEMOTIONS[normalizeUnitKey(unit.canonical)];
454
- if (!step) return null;
502
+ if (!step) {
503
+ return null;
504
+ }
455
505
  const target = lookupUnit(step.to, step.language);
456
506
  return target ? { unit: target, per: step.per } : null;
457
507
  }
458
508
  function isSpoonMeasure(unit) {
459
- return /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse)$/.test(
460
- unit.canonical
461
- );
509
+ return HOUSEHOLD_MEASURE.test(unit.canonical);
462
510
  }
463
511
  function unitDivisibility(unit) {
464
- if (unit.kind === "approximate") return "whole";
512
+ if (unit.kind === "approximate") {
513
+ return "whole";
514
+ }
465
515
  return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
466
516
  }
467
517
  var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|tranches?|slices?)\b/i;
@@ -476,7 +526,9 @@ function chooseReadableUnit(unit, amount) {
476
526
  let ratio = 1;
477
527
  while (amount * ratio < 1) {
478
528
  const step = demoteUnit(current);
479
- if (!step) break;
529
+ if (!step) {
530
+ break;
531
+ }
480
532
  ratio *= step.per;
481
533
  current = step.unit;
482
534
  }
@@ -490,7 +542,9 @@ function chooseReadableUnit(unit, amount) {
490
542
  }
491
543
  while (!writesExactly(amount * ratio)) {
492
544
  const step = demoteUnit(current);
493
- if (!step) break;
545
+ if (!step) {
546
+ break;
547
+ }
494
548
  ratio *= step.per;
495
549
  current = step.unit;
496
550
  }
@@ -500,13 +554,64 @@ function writesExactly(value) {
500
554
  return Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
501
555
  }
502
556
  function formatUnit(unit, amount, language) {
503
- if (unit.symbol) return unit.canonical;
557
+ if (unit.symbol) {
558
+ return unit.canonical;
559
+ }
504
560
  const singular = language === "fr" ? amount < 2 : amount <= 1;
505
- if (singular) return unit.canonical;
561
+ if (singular) {
562
+ return unit.canonical;
563
+ }
506
564
  return unit.plural ?? `${unit.canonical}s`;
507
565
  }
508
566
 
509
567
  // src/recipe/quantity.ts
568
+ var BRACKETED_TAIL = /\s*(\([^()]*\))\s*$/;
569
+ var LEADING_ARTICLE = /^(?:of\s+)?an?\s+/i;
570
+ var LEADING_ARTICLE_FRENCH = /^(?:une|un)\s+/i;
571
+ var LEADING_INDEFINITE_WITH_SPACE = /^an?\s+/i;
572
+ var LEADING_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i;
573
+ var OF_OPENING = /^of\s+/i;
574
+ var OPENING_ARTICLE_FRENCH = /^\s*(un|une)\b\s*/i;
575
+ var REPEATED_COMMA_GROUPS = /^\s*\d+,\d+,\d/;
576
+ var SLASH_OUTSIDE_A_FRACTION = /(?<!\d)\/|\/(?!\d)/;
577
+ var ATTACHED_DE = /\s+de\s+(?=\d)/i;
578
+ var DECIMAL = /^(\d+(?:[.,]\d+)?)/;
579
+ var DIGIT = /\d/;
580
+ var ENGLISH_RANGE_JOINER = /^\s+(to|or)\s+/i;
581
+ var FRACTION_WORD = /^(?:(a|an|one|two|three)[\s-]+)?(halves|half|thirds|third|quarters|quarter|fourths|fourth)\b/i;
582
+ var FRENCH_RANGE_JOINER = /^\s+(à|a|ou)\s+/i;
583
+ var GROUPED_DECIMAL = /^(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/;
584
+ var HYPHENATED_TAIL = new RegExp("^-\\p{L}", "u");
585
+ var LEADING_DASH = /^\s*(–|—|-)\s*/;
586
+ var LEADING_INDEFINITE = /^an?\s/i;
587
+ var LEADING_OF = /^\s*of\s+/i;
588
+ var LEADING_QUANTITY = /^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u;
589
+ var LEADING_WORD = new RegExp("^\\s*(\\p{L}+)\\s+", "u");
590
+ var MIXED_FRACTION = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/;
591
+ var PARTITIVE_BEFORE_BRACKET = /^(?:de\s+|du\s+|des\s+|d'|of\s+)(?=\()/i;
592
+ var PARTITIVE_OPENING = /^(?:de\s|d'|du\s|des\s|,)/i;
593
+ var SIMPLE_FRACTION = /^(\d+)\s*\/\s*(\d+)/;
594
+ var TRAILING_S = /s$/;
595
+ var VAGUE_ARTICLE = /^(?:un|une|quelques|of\s+an?|an?)\s+/iu;
596
+ var WHITESPACE = /\s+/;
597
+ function leadingMeasure(direct, behind) {
598
+ if (direct.unit) {
599
+ return direct;
600
+ }
601
+ return behind?.unit ? behind : direct;
602
+ }
603
+ function alternateStyleOf(count, slashed, trailing) {
604
+ if (count === 0) {
605
+ return null;
606
+ }
607
+ if (slashed) {
608
+ return "slash";
609
+ }
610
+ if (trailing) {
611
+ return "trailing";
612
+ }
613
+ return "bracket";
614
+ }
510
615
  var VULGAR_FRACTIONS = {
511
616
  "\xBD": 0.5,
512
617
  "\u2153": 1 / 3,
@@ -546,13 +651,18 @@ var NAMED_ENTITIES = {
546
651
  mdash: "\u2014"
547
652
  };
548
653
  function decodeEntities(text) {
549
- return text.replace(/&#x([0-9a-f]+);/gi, (whole, hex) => codePoint(parseInt(hex, 16), whole)).replace(/&#(\d+);/g, (whole, digits) => codePoint(Number(digits), whole)).replace(
654
+ return text.replace(
655
+ /&#x([0-9a-f]+);/gi,
656
+ (whole, hex) => codePoint(Number.parseInt(hex, 16), whole)
657
+ ).replace(/&#(\d+);/g, (whole, digits) => codePoint(Number(digits), whole)).replace(
550
658
  /&([a-z][a-z0-9]*);/gi,
551
659
  (whole, name) => NAMED_ENTITIES[name.toLowerCase()] ?? whole
552
660
  );
553
661
  }
554
662
  function codePoint(value, published) {
555
- if (!Number.isInteger(value) || value < 32 || value > 1114111) return published;
663
+ if (!Number.isInteger(value) || value < 32 || value > 1114111) {
664
+ return published;
665
+ }
556
666
  try {
557
667
  return String.fromCodePoint(value);
558
668
  } catch {
@@ -569,37 +679,59 @@ var APPROXIMATION_PREFIX = {
569
679
  };
570
680
  var CONTAINER_CONTENTS = /^\s*(?:de\s|d'|du\s|des\s)/i;
571
681
  function statesItemSize(item) {
572
- const attached = /\s+de\s+(?=\d)/i.exec(item);
573
- if (!attached) return false;
682
+ const attached = ATTACHED_DE.exec(item);
683
+ if (!attached) {
684
+ return false;
685
+ }
574
686
  const named = item.slice(0, attached.index).trim();
575
- if (!named || /\d/.test(named)) return false;
687
+ if (!named || DIGIT.test(named)) {
688
+ return false;
689
+ }
576
690
  return isStatedSize(item.slice(attached.index + attached[0].length));
577
691
  }
578
692
  function isStatedSize(text) {
579
693
  const size = parseLeadingQuantity(text, "fr");
580
- if (!size) return false;
694
+ if (!size) {
695
+ return false;
696
+ }
581
697
  const measure = takeUnit(text.slice(size.length).trimStart(), "fr");
582
- if (!measure.unit || measure.unit.kind !== "measured") return false;
698
+ if (measure.unit?.kind !== "measured") {
699
+ return false;
700
+ }
583
701
  return !CONTAINER_CONTENTS.test(measure.rest);
584
702
  }
585
703
  function takeCompoundMember(text, unit, language) {
586
- if (!unit) return null;
704
+ if (!unit) {
705
+ return null;
706
+ }
587
707
  const step = demoteUnit(unit);
588
- if (!step) return null;
708
+ if (!step) {
709
+ return null;
710
+ }
589
711
  const joiner = ADDS_UP.exec(text);
590
- if (joiner) text = text.slice(joiner[0].length);
591
- const second = parseLeadingQuantity(text, language);
592
- if (!second || second.amount <= 0 || second.amount >= step.per) return null;
593
- const after = text.slice(second.length);
712
+ const behindTheJoiner = joiner ? text.slice(joiner[0].length) : text;
713
+ const second = parseLeadingQuantity(behindTheJoiner, language);
714
+ if (!second || second.amount <= 0 || second.amount >= step.per) {
715
+ return null;
716
+ }
717
+ const after = behindTheJoiner.slice(second.length);
594
718
  const measure = takeUnit(after.trimStart(), language);
595
719
  if (measure.unit) {
596
- if (measure.unit.canonical !== step.unit.canonical) return null;
720
+ if (measure.unit.canonical !== step.unit.canonical) {
721
+ return null;
722
+ }
597
723
  return { adds: second.amount / step.per, rest: measure.rest.trimStart() };
598
724
  }
599
- if (unit.kind !== "measured") return null;
600
- if (!Number.isInteger(second.amount)) return null;
725
+ if (unit.kind !== "measured") {
726
+ return null;
727
+ }
728
+ if (!Number.isInteger(second.amount)) {
729
+ return null;
730
+ }
601
731
  const trailing = after.trimStart();
602
- if (trailing !== "" && !/^(?:de\s|d'|du\s|des\s|,)/i.test(trailing)) return null;
732
+ if (trailing !== "" && !PARTITIVE_OPENING.test(trailing)) {
733
+ return null;
734
+ }
603
735
  return { adds: second.amount / step.per, rest: trailing };
604
736
  }
605
737
  var ADDS_UP = /^\s*(?:\+|et|and|plus)\s+/i;
@@ -647,12 +779,17 @@ var MEASURE_ADJECTIVES = {
647
779
  ])
648
780
  };
649
781
  function takeMeasureAdjective(text, language) {
650
- const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text);
651
- if (!match) return { adjective: null, rest: text };
652
- const folded = normalizeUnitKey(match[1]);
653
- const listed = MEASURE_ADJECTIVES[language].has(folded) || MEASURE_ADJECTIVES[language].has(folded.replace(/s$/, ""));
654
- if (!listed) return { adjective: null, rest: text };
655
- return { adjective: match[1], rest: text.slice(match[0].length) };
782
+ const match = LEADING_WORD.exec(text);
783
+ if (!match) {
784
+ return { adjective: null, rest: text };
785
+ }
786
+ const [adjective = ""] = match.slice(1);
787
+ const folded = normalizeUnitKey(adjective);
788
+ const listed = MEASURE_ADJECTIVES[language].has(folded) || MEASURE_ADJECTIVES[language].has(folded.replace(TRAILING_S, ""));
789
+ if (!listed) {
790
+ return { adjective: null, rest: text };
791
+ }
792
+ return { adjective, rest: text.slice(match[0].length) };
656
793
  }
657
794
  var COMMA_GROUPED = /^\s*\d{1,3}(?:,\d{3})+(?!\d)/;
658
795
  var COMMA_DECIMAL = /^\s*\d+,\d+/;
@@ -662,10 +799,10 @@ function parseLeadingQuantity(text, language) {
662
799
  const mixedGlyph = new RegExp(`^(\\d+)\\s*([${VULGAR_CLASS}])`).exec(trimmed);
663
800
  if (mixedGlyph) {
664
801
  const whole = Number(mixedGlyph[1]);
665
- const fraction2 = VULGAR_FRACTIONS[mixedGlyph[2]];
802
+ const fraction2 = VULGAR_FRACTIONS[mixedGlyph[2] ?? ""] ?? Number.NaN;
666
803
  return { amount: whole + fraction2, length: offset + mixedGlyph[0].length };
667
804
  }
668
- const mixed = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/.exec(trimmed);
805
+ const mixed = MIXED_FRACTION.exec(trimmed);
669
806
  if (mixed) {
670
807
  const denominator = Number(mixed[3]);
671
808
  if (denominator !== 0) {
@@ -675,27 +812,33 @@ function parseLeadingQuantity(text, language) {
675
812
  };
676
813
  }
677
814
  }
678
- const fraction = /^(\d+)\s*\/\s*(\d+)/.exec(trimmed);
815
+ const fraction = SIMPLE_FRACTION.exec(trimmed);
679
816
  if (fraction) {
680
817
  const denominator = Number(fraction[2]);
681
- if (denominator === 0) return null;
818
+ if (denominator === 0) {
819
+ return null;
820
+ }
682
821
  return { amount: Number(fraction[1]) / denominator, length: offset + fraction[0].length };
683
822
  }
684
823
  const glyph = trimmed[0];
685
824
  if (glyph && glyph in VULGAR_FRACTIONS) {
686
- return { amount: VULGAR_FRACTIONS[glyph], length: offset + 1 };
825
+ return { amount: VULGAR_FRACTIONS[glyph] ?? Number.NaN, length: offset + 1 };
687
826
  }
688
- const decimal = (language === "fr" ? /^(\d+(?:[.,]\d+)?)/ : /^(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/).exec(trimmed);
827
+ const decimal = (language === "fr" ? DECIMAL : GROUPED_DECIMAL).exec(trimmed);
689
828
  if (decimal) {
690
- const written = decimal[1];
829
+ const [written = ""] = decimal.slice(1);
691
830
  const amount = Number(
692
831
  language === "fr" ? written.replace(",", ".") : written.replace(/,/g, "")
693
832
  );
694
- if (Number.isFinite(amount)) return { amount, length: offset + decimal[0].length };
833
+ if (Number.isFinite(amount)) {
834
+ return { amount, length: offset + decimal[0].length };
835
+ }
695
836
  }
696
837
  if (language === "en") {
697
838
  const written = parseWrittenFraction(trimmed);
698
- if (written) return { amount: written.amount, length: offset + written.length };
839
+ if (written) {
840
+ return { amount: written.amount, length: offset + written.length };
841
+ }
699
842
  }
700
843
  return null;
701
844
  }
@@ -711,30 +854,40 @@ var WRITTEN_DENOMINATORS = {
711
854
  fourths: 4
712
855
  };
713
856
  function parseWrittenFraction(text) {
714
- const match = /^(?:(a|an|one|two|three)[\s-]+)?(halves|half|thirds|third|quarters|quarter|fourths|fourth)\b/i.exec(
715
- text
716
- );
717
- if (!match) return null;
857
+ const match = FRACTION_WORD.exec(text);
858
+ if (!match) {
859
+ return null;
860
+ }
718
861
  const numerator = match[1] ? WRITTEN_NUMERATORS[match[1].toLowerCase()] : 1;
719
- const denominator = WRITTEN_DENOMINATORS[match[2].toLowerCase()];
720
- if (!numerator || !denominator) return null;
721
- const rest = text.slice(match[0].length).replace(/^\s*of\s+/i, "").trimStart();
722
- if (!/^an?\s/i.test(rest) && !takeUnit(rest, "en").unit) return null;
862
+ const denominator = WRITTEN_DENOMINATORS[(match[2] ?? "").toLowerCase()];
863
+ if (!(numerator && denominator)) {
864
+ return null;
865
+ }
866
+ const rest = text.slice(match[0].length).replace(LEADING_OF, "").trimStart();
867
+ if (!(LEADING_INDEFINITE.test(rest) || takeUnit(rest, "en").unit)) {
868
+ return null;
869
+ }
723
870
  return { amount: numerator / denominator, length: match[0].length };
724
871
  }
725
872
  function parseLeadingRange(text, language) {
726
873
  const low = parseLeadingQuantity(text, language);
727
- if (!low) return null;
874
+ if (!low) {
875
+ return null;
876
+ }
728
877
  const after = text.slice(low.length);
729
- const written = language === "fr" ? /^\s+(à|a|ou)\s+/i : /^\s+(to|or)\s+/i;
730
- const separator = written.exec(after) ?? /^\s*(–|—|-)\s*/.exec(after);
731
- if (!separator) return null;
878
+ const written = language === "fr" ? FRENCH_RANGE_JOINER : ENGLISH_RANGE_JOINER;
879
+ const separator = written.exec(after) ?? LEADING_DASH.exec(after);
880
+ if (!separator) {
881
+ return null;
882
+ }
732
883
  const high = parseLeadingQuantity(after.slice(separator[0].length), language);
733
- if (!high || high.amount <= low.amount) return null;
884
+ if (!high || high.amount <= low.amount) {
885
+ return null;
886
+ }
734
887
  return {
735
888
  amount: low.amount,
736
889
  max: high.amount,
737
- separator: separator[1],
890
+ separator: separator[1] ?? "",
738
891
  length: low.length + separator[0].length + high.length
739
892
  };
740
893
  }
@@ -742,41 +895,122 @@ var FRENCH_ARTICLES = { un: 1, une: 1 };
742
895
  function takeUnit(text, language) {
743
896
  const normalized = normalizeUnitKey(text);
744
897
  for (const key of unitKeys(language)) {
745
- if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
898
+ if (normalized !== key && !normalized.startsWith(`${key} `)) {
899
+ continue;
900
+ }
746
901
  const unit = lookupUnit(key, language);
747
- if (!unit) continue;
902
+ if (!unit) {
903
+ continue;
904
+ }
748
905
  const rest = afterKey(text, key);
749
- if (rest === null) continue;
906
+ if (rest === null) {
907
+ continue;
908
+ }
750
909
  return { unit, rest };
751
910
  }
752
911
  if (language === "en") {
753
- const words = text.trimStart().split(/\s+/);
912
+ const words = text.trimStart().split(WHITESPACE);
754
913
  const load = words[0] ? readContainerLoad(words[0]) : null;
755
- if (load) return { unit: load, rest: words.slice(1).join(" ") };
914
+ if (load) {
915
+ return { unit: load, rest: words.slice(1).join(" ") };
916
+ }
756
917
  }
757
918
  return { unit: null, rest: text };
758
919
  }
759
920
  function afterKey(text, key) {
760
- const words = text.trim().split(/\s+/);
921
+ const words = text.trim().split(WHITESPACE);
761
922
  for (let count = 1; count <= words.length; count += 1) {
762
- if (normalizeUnitKey(words.slice(0, count).join(" ")) !== key) continue;
923
+ if (normalizeUnitKey(words.slice(0, count).join(" ")) !== key) {
924
+ continue;
925
+ }
763
926
  return words.slice(count).join(" ");
764
927
  }
765
928
  return null;
766
929
  }
767
930
  function takeUnitEitherLanguage(text, language) {
768
931
  const first = takeUnit(text, language);
769
- if (first.unit) return first;
932
+ if (first.unit) {
933
+ return first;
934
+ }
770
935
  return takeUnit(text, language === "fr" ? "en" : "fr");
771
936
  }
772
937
  function readEvidence(line) {
773
938
  const text = line.trim();
774
- const afterFigures = text.replace(/^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u, "").trimStart();
775
- const afterArticle = afterFigures.replace(/^(?:un|une|quelques|of\s+an?|an?)\s+/iu, "");
939
+ const afterFigures = text.replace(LEADING_QUANTITY, "").trimStart();
940
+ const afterArticle = afterFigures.replace(VAGUE_ARTICLE, "");
776
941
  const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
777
942
  const englishUnit = takeUnit(afterArticle, "en").unit !== null;
778
943
  return readLanguage(text, { frenchUnit, englishUnit });
779
944
  }
945
+ function readMeasure(rest, language, fromArticle) {
946
+ const direct = takeLeadingUnit(rest, language, fromArticle);
947
+ const described = direct.unit ? { adjective: null, rest } : takeMeasureAdjective(rest, language);
948
+ const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
949
+ return { leading: leadingMeasure(direct, behind), described, behind };
950
+ }
951
+ function whatTheFigureIsNot(behindFigure, couldBeARank, language) {
952
+ if (HYPHENATED_TAIL.test(behindFigure)) {
953
+ return "sizeQualifier";
954
+ }
955
+ if (couldBeARank && ORDINAL_SUFFIX.test(behindFigure)) {
956
+ return null;
957
+ }
958
+ if (TIME_UNIT[language].test(behindFigure.trimStart())) {
959
+ return "duration";
960
+ }
961
+ return false;
962
+ }
963
+ function takeEquivalents(text, language) {
964
+ const introducedBracket = PARTITIVE_BEFORE_BRACKET.exec(text);
965
+ const bracketed = takeAlternates(
966
+ introducedBracket ? text.slice(introducedBracket[0].length) : text,
967
+ language
968
+ );
969
+ let rest = bracketed.measures.length > 0 ? bracketed.rest : text;
970
+ const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
971
+ if (slashed) {
972
+ rest = slashed.rest;
973
+ }
974
+ let item = stripItemLead(rest, language);
975
+ const trailing = bracketed.measures.length === 0 && !slashed ? takeTrailingAlternates(item, language) : null;
976
+ if (trailing) {
977
+ item = trailing.item;
978
+ }
979
+ const group = trailing ?? (bracketed.measures.length > 0 ? bracketed : null);
980
+ return { rest, item, bracketed, slashed, trailing, group };
981
+ }
982
+ function whatTheFiguresSize(leading, item, language) {
983
+ if (language === "fr" && !leading.unit && statesItemSize(item)) {
984
+ return "itemSize";
985
+ }
986
+ if (language === "fr" && leading.partitive && isStatedSize(item)) {
987
+ return "itemSize";
988
+ }
989
+ if (leading.unit?.kind === "measured" && CONTAINER_NOUN.test(normalizeUnitKey(item))) {
990
+ return "containerSize";
991
+ }
992
+ return null;
993
+ }
994
+ function whatTheCountIsOf(behindFigure, language) {
995
+ let rest = behindFigure.trimStart();
996
+ if (language === "en") {
997
+ rest = rest.replace(LEADING_ARTICLE, "");
998
+ }
999
+ const multiplier = readCountMultiplier(rest);
1000
+ if (multiplier) {
1001
+ rest = multiplier.rest;
1002
+ }
1003
+ return { rest, multiplier };
1004
+ }
1005
+ function capacityOfTheContainer(group, measureName, item) {
1006
+ if (!group?.measures.every((measure) => measure.unit?.kind === "measured")) {
1007
+ return null;
1008
+ }
1009
+ if (!(namesContainer(measureName) || namesContainer(item))) {
1010
+ return null;
1011
+ }
1012
+ return group.published;
1013
+ }
780
1014
  function parseIngredient(line, choice = "auto") {
781
1015
  const original = line;
782
1016
  const marked = readable(line);
@@ -803,50 +1037,48 @@ function parseIngredient(line, choice = "auto") {
803
1037
  articleWord: null,
804
1038
  countMultiplier: null
805
1039
  });
806
- if (unreadableComma(text, language, evidence)) return empty("ambiguousDecimal");
1040
+ if (unreadableComma(text, language, evidence)) {
1041
+ return empty("ambiguousDecimal");
1042
+ }
807
1043
  const loose = APPROXIMATION_PREFIX[language].exec(text);
808
1044
  const stated = loose ? text.slice(loose[0].length) : text;
809
1045
  const range = parseLeadingRange(stated, language);
810
1046
  const article = range ? null : readArticle(stated, language);
811
1047
  const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
812
- if (!quantity) return empty(null);
813
- const behindFigure = stated.slice(quantity.length);
814
- if (new RegExp("^-\\p{L}", "u").test(behindFigure)) return empty("sizeQualifier");
815
- if (quantity !== article && ORDINAL_SUFFIX.test(behindFigure)) return empty(null);
816
- if (TIME_UNIT[language].test(behindFigure.trimStart())) return empty("duration");
817
- let rest = stated.slice(quantity.length).trimStart();
818
- if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
819
- const multiplier = readCountMultiplier(rest);
820
- if (multiplier) rest = multiplier.rest;
1048
+ if (!quantity) {
1049
+ return empty(null);
1050
+ }
1051
+ const notAnAmount = whatTheFigureIsNot(
1052
+ stated.slice(quantity.length),
1053
+ quantity !== article,
1054
+ language
1055
+ );
1056
+ if (notAnAmount !== false) {
1057
+ return empty(notAnAmount);
1058
+ }
1059
+ const counted = whatTheCountIsOf(stated.slice(quantity.length), language);
1060
+ let rest = counted.rest;
1061
+ const multiplier = counted.multiplier;
821
1062
  const times = multiplier?.times ?? 1;
822
1063
  const fromArticle = quantity === article;
823
- const direct = takeLeadingUnit(rest, language, fromArticle);
824
- const described = direct.unit ? { adjective: null, rest } : takeMeasureAdjective(rest, language);
825
- const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
826
- const leading = direct.unit ? direct : behind?.unit ? behind : direct;
1064
+ const measure = readMeasure(rest, language, fromArticle);
1065
+ const { leading, described, behind } = measure;
827
1066
  rest = leading.rest;
828
1067
  const compound = range ? null : takeCompoundMember(rest, leading.unit, language);
829
- if (compound) rest = compound.rest;
1068
+ if (compound) {
1069
+ rest = compound.rest;
1070
+ }
830
1071
  const whole = quantity.amount + (compound?.adds ?? 0);
831
- const introducedBracket = /^(?:de\s+|du\s+|des\s+|d'|of\s+)(?=\()/i.exec(rest);
832
- const bracketed = takeAlternates(
833
- introducedBracket ? rest.slice(introducedBracket[0].length) : rest,
834
- language
835
- );
836
- rest = bracketed.measures.length > 0 ? bracketed.rest : rest;
837
- const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
838
- if (slashed) rest = slashed.rest;
839
- let item = stripItemLead(rest, language);
840
- const trailing = bracketed.measures.length === 0 && !slashed ? takeTrailingAlternates(item, language) : null;
841
- if (trailing) item = trailing.item;
842
- const group = trailing ?? (bracketed.measures.length > 0 ? bracketed : null);
843
- const capacity = group !== null && group.measures.every((measure) => measure.unit?.kind === "measured") && (namesContainer(leading.unit?.canonical) || namesContainer(item)) ? group.published : null;
844
- if (language === "fr" && !leading.unit && statesItemSize(item)) return empty("itemSize");
845
- if (language === "fr" && leading.partitive && isStatedSize(item)) return empty("itemSize");
846
- if (leading.unit?.kind === "measured" && CONTAINER_NOUN.test(normalizeUnitKey(item))) {
847
- return empty("containerSize");
1072
+ const equivalents = takeEquivalents(rest, language);
1073
+ const { slashed, trailing, group } = equivalents;
1074
+ rest = equivalents.rest;
1075
+ const item = equivalents.item;
1076
+ const capacity = capacityOfTheContainer(group, leading.unit?.canonical, item);
1077
+ const sizedRatherThanCounted = whatTheFiguresSize(leading, item, language);
1078
+ if (sizedRatherThanCounted !== null) {
1079
+ return empty(sizedRatherThanCounted);
848
1080
  }
849
- const alternates = capacity !== null ? [] : slashed?.measures ?? group?.measures ?? [];
1081
+ const alternates = capacity === null ? slashed?.measures ?? group?.measures ?? [] : [];
850
1082
  return {
851
1083
  original,
852
1084
  language,
@@ -859,8 +1091,8 @@ function parseIngredient(line, choice = "auto") {
859
1091
  rangeSeparator: range?.separator ?? null,
860
1092
  unit: leading.unit,
861
1093
  alternates,
862
- alternateStyle: alternates.length === 0 ? null : slashed ? "slash" : trailing ? "trailing" : "bracket",
863
- alternateIntro: capacity !== null ? null : group?.intro ?? null,
1094
+ alternateStyle: alternateStyleOf(alternates.length, slashed !== null, trailing !== null),
1095
+ alternateIntro: capacity === null ? group?.intro ?? null : null,
864
1096
  capacity,
865
1097
  item,
866
1098
  articleWord: fromArticle ? article?.word ?? null : null,
@@ -872,21 +1104,35 @@ function unreadableComma(text, language, evidence) {
872
1104
  const settled = evidence.french !== evidence.english;
873
1105
  return !settled;
874
1106
  }
875
- if (language === "en") return COMMA_DECIMAL.test(text);
876
- return /^\s*\d+,\d+,\d/.test(text);
1107
+ if (language === "en") {
1108
+ return COMMA_DECIMAL.test(text);
1109
+ }
1110
+ return REPEATED_COMMA_GROUPS.test(text);
877
1111
  }
878
1112
  function readArticle(text, language) {
879
1113
  const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
880
1114
  if (language === "fr") {
881
- const match = /^\s*(un|une)\b\s*/i.exec(text);
882
- if (!match) return null;
883
- if (!counts(text.slice(match[0].length))) return null;
884
- const word = match[1];
885
- return { amount: FRENCH_ARTICLES[word.toLowerCase()], length: match[0].length, word };
886
- }
887
- const article = /^an?\s+/i.exec(text);
888
- if (!article) return null;
889
- if (!counts(text.slice(article[0].length))) return null;
1115
+ const match = OPENING_ARTICLE_FRENCH.exec(text);
1116
+ if (!match) {
1117
+ return null;
1118
+ }
1119
+ if (!counts(text.slice(match[0].length))) {
1120
+ return null;
1121
+ }
1122
+ const [word = ""] = match.slice(1);
1123
+ const amount = FRENCH_ARTICLES[word.toLowerCase()];
1124
+ if (amount === void 0) {
1125
+ return null;
1126
+ }
1127
+ return { amount, length: match[0].length, word };
1128
+ }
1129
+ const article = LEADING_INDEFINITE_WITH_SPACE.exec(text);
1130
+ if (!article) {
1131
+ return null;
1132
+ }
1133
+ if (!counts(text.slice(article[0].length))) {
1134
+ return null;
1135
+ }
890
1136
  return { amount: 1, length: article[0].length, word: article[0].trim() };
891
1137
  }
892
1138
  var COUNT_MULTIPLIERS = {
@@ -896,57 +1142,80 @@ var COUNT_MULTIPLIERS = {
896
1142
  douzaines: 12
897
1143
  };
898
1144
  function readCountMultiplier(text) {
899
- const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text);
900
- if (!match) return null;
901
- const times = COUNT_MULTIPLIERS[normalizeUnitKey(match[1])];
902
- if (times === void 0) return null;
1145
+ const match = LEADING_WORD.exec(text);
1146
+ if (!match) {
1147
+ return null;
1148
+ }
1149
+ const [word = ""] = match.slice(1);
1150
+ const times = COUNT_MULTIPLIERS[normalizeUnitKey(word)];
1151
+ if (times === void 0) {
1152
+ return null;
1153
+ }
903
1154
  return { times, rest: text.slice(match[0].length) };
904
1155
  }
905
1156
  function takeLeadingUnit(text, language, fromArticle) {
906
1157
  const taken = takeUnit(text, language);
907
- if (taken.unit) return { ...taken, partitive: false };
1158
+ if (taken.unit) {
1159
+ return { ...taken, partitive: false };
1160
+ }
908
1161
  if (language === "fr" && fromArticle) {
909
1162
  const measure = readPartitiveMeasure(text);
910
- if (measure) return { unit: measure.unit, rest: measure.rest, partitive: true };
1163
+ if (measure) {
1164
+ return { unit: measure.unit, rest: measure.rest, partitive: true };
1165
+ }
911
1166
  }
912
1167
  return { ...taken, partitive: false };
913
1168
  }
914
1169
  function stripItemLead(text, language) {
915
1170
  if (language === "fr") {
916
- return text.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
1171
+ return text.replace(LEADING_PARTITIVE, "").replace(LEADING_ARTICLE_FRENCH, "").trim();
917
1172
  }
918
- return text.replace(/^of\s+/i, "").replace(/^an?\s+/i, "").trim();
1173
+ return text.replace(OF_OPENING, "").replace(LEADING_INDEFINITE_WITH_SPACE, "").trim();
919
1174
  }
920
1175
  function takeAlternates(text, language) {
921
1176
  const none = { measures: [], intro: null, published: "", rest: text };
922
- if (!text.startsWith("(")) return none;
1177
+ if (!text.startsWith("(")) {
1178
+ return none;
1179
+ }
923
1180
  const close = text.indexOf(")");
924
- if (close < 0) return none;
1181
+ if (close < 0) {
1182
+ return none;
1183
+ }
925
1184
  const read = readBracket(text.slice(0, close + 1), language);
926
- if (!read) return none;
1185
+ if (!read) {
1186
+ return none;
1187
+ }
927
1188
  return { ...read, rest: text.slice(close + 1).trimStart() };
928
1189
  }
929
1190
  var EQUIVALENT_INTRODUCER = /^(?:soit|environ|about|approx\.?|approximately|or|ou|=)\s*/i;
930
1191
  var MEASURE_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+|of\s+)/i;
931
1192
  function takeMeasureAfterQuantity(text, language) {
932
1193
  const direct = takeUnitEitherLanguage(text, language);
933
- if (direct.unit) return direct;
1194
+ if (direct.unit) {
1195
+ return direct;
1196
+ }
934
1197
  const partitive = MEASURE_PARTITIVE.exec(text);
935
- if (!partitive) return direct;
1198
+ if (!partitive) {
1199
+ return direct;
1200
+ }
936
1201
  return takeUnitEitherLanguage(text.slice(partitive[0].length), language);
937
1202
  }
938
1203
  function readBracket(bracket, language) {
939
1204
  const inside = bracket.slice(1, -1);
940
1205
  const intro = EQUIVALENT_INTRODUCER.exec(inside);
941
1206
  const body = intro ? inside.slice(intro[0].length) : inside;
942
- const parts = body.split(/(?<!\d)\/|\/(?!\d)/).map((part) => part.trim());
1207
+ const parts = body.split(SLASH_OUTSIDE_A_FRACTION).map((part) => part.trim());
943
1208
  const measures = [];
944
1209
  for (const part of parts) {
945
1210
  const range = parseLeadingRange(part, language);
946
1211
  const quantity = range ?? parseLeadingQuantity(part, language);
947
- if (!quantity) return null;
1212
+ if (!quantity) {
1213
+ return null;
1214
+ }
948
1215
  const after = takeMeasureAfterQuantity(part.slice(quantity.length).trimStart(), language);
949
- if (!after.unit || after.rest.trim() !== "") return null;
1216
+ if (!after.unit || after.rest.trim() !== "") {
1217
+ return null;
1218
+ }
950
1219
  measures.push({
951
1220
  amount: quantity.amount,
952
1221
  amountMax: range?.max ?? null,
@@ -954,16 +1223,24 @@ function readBracket(bracket, language) {
954
1223
  unit: after.unit
955
1224
  });
956
1225
  }
957
- if (measures.length === 0) return null;
1226
+ if (measures.length === 0) {
1227
+ return null;
1228
+ }
958
1229
  return { measures, intro: intro ? intro[0].trim() : null, published: bracket };
959
1230
  }
960
1231
  function takeTrailingAlternates(item, language) {
961
- const closing = /\s*(\([^()]*\))\s*$/.exec(item);
962
- if (!closing) return null;
963
- const read = readBracket(closing[1], language);
964
- if (!read) return null;
1232
+ const closing = BRACKETED_TAIL.exec(item);
1233
+ if (!closing) {
1234
+ return null;
1235
+ }
1236
+ const read = readBracket(closing[1] ?? "", language);
1237
+ if (!read) {
1238
+ return null;
1239
+ }
965
1240
  const head = item.slice(0, closing.index).trim();
966
- if (head === "") return null;
1241
+ if (head === "") {
1242
+ return null;
1243
+ }
967
1244
  return { ...read, item: head };
968
1245
  }
969
1246
  function takeSlashAlternates(text, language) {
@@ -973,9 +1250,13 @@ function takeSlashAlternates(text, language) {
973
1250
  const after = rest.slice(1).trimStart();
974
1251
  const range = parseLeadingRange(after, language);
975
1252
  const quantity = range ?? parseLeadingQuantity(after, language);
976
- if (!quantity) break;
1253
+ if (!quantity) {
1254
+ break;
1255
+ }
977
1256
  const taken = takeUnitEitherLanguage(after.slice(quantity.length).trimStart(), language);
978
- if (!taken.unit) break;
1257
+ if (!taken.unit) {
1258
+ break;
1259
+ }
979
1260
  measures.push({
980
1261
  amount: quantity.amount,
981
1262
  amountMax: range?.max ?? null,
@@ -992,9 +1273,15 @@ function formatAmount(amount, language, options = {}) {
992
1273
  const rendered = String(rounded);
993
1274
  return language === "fr" ? rendered.replace(".", ",") : rendered;
994
1275
  };
995
- if (!Number.isFinite(amount)) return "";
996
- if (Number.isInteger(amount)) return String(amount);
997
- if (options.fractions === false) return decimal(amount);
1276
+ if (!Number.isFinite(amount)) {
1277
+ return "";
1278
+ }
1279
+ if (Number.isInteger(amount)) {
1280
+ return String(amount);
1281
+ }
1282
+ if (options.fractions === false) {
1283
+ return decimal(amount);
1284
+ }
998
1285
  const whole = Math.floor(amount);
999
1286
  const rest = amount - whole;
1000
1287
  const known = [
@@ -1005,20 +1292,52 @@ function formatAmount(amount, language, options = {}) {
1005
1292
  [0.75, "3/4"]
1006
1293
  ];
1007
1294
  for (const [value, label] of known) {
1008
- if (Math.abs(rest - value) < 0.02) return whole > 0 ? `${whole} ${label}` : label;
1295
+ if (Math.abs(rest - value) < 0.02) {
1296
+ return whole > 0 ? `${whole} ${label}` : label;
1297
+ }
1009
1298
  }
1010
1299
  return decimal(amount);
1011
1300
  }
1012
1301
 
1013
1302
  // src/recipe/scale.ts
1303
+ var DASH_ONLY = /^[-–—]$/;
1304
+ var AL_ENDING2 = /al$/i;
1305
+ var AUX_ENDING2 = /aux$/i;
1306
+ var CLOVE = /\bcloves?\b/i;
1307
+ var CONSONANT_THEN_Y = /[^aeiou]y$/i;
1308
+ var DOUBLE_S_ENDING = /ss$/i;
1309
+ var EAUX_ENDING2 = /eaux$/i;
1310
+ var EAU_ENDING2 = /eau$/i;
1311
+ var F_ENDING = /(?:[^f]f|fe)$/i;
1312
+ var F_OR_FE_ENDING = /fe?$/i;
1313
+ var GARLIC = /\bgarlic\b/i;
1314
+ var IES_ENDING = /ies$/i;
1315
+ var LETTER = new RegExp("\\p{L}", "u");
1316
+ var LETTERS_ONLY = /^[A-Za-z]+$/;
1317
+ var OF_JOINER = / of /i;
1318
+ var PLURAL_ENDING = /s$|eaux$|aux$/i;
1319
+ var SIBILANT_ENDING = /(?:ch|sh|s|x|z)$/i;
1320
+ var SIBILANT_LETTER2 = /[sxz]$/i;
1321
+ var SIBILANT_PLURAL = /(?:ch|sh|s|x|z)es$/i;
1322
+ var TRAILING_S_ANY_CASE2 = /s$/i;
1323
+ var VOWEL_OPENING = /^[aeiouàâäéèêëîïôöûü]/i;
1324
+ var X_ENDING = /x$/i;
1014
1325
  function roundTo(value, step) {
1015
1326
  return Math.round(Math.round(value / step) * step * 100) / 100;
1016
1327
  }
1017
1328
  function roundMeasured(value) {
1018
- if (Number.isInteger(value)) return value;
1019
- if (value >= 100) return roundTo(value, 5);
1020
- if (value >= 10) return roundTo(value, 1);
1021
- if (value >= 1) return roundTo(value, 0.1);
1329
+ if (Number.isInteger(value)) {
1330
+ return value;
1331
+ }
1332
+ if (value >= 100) {
1333
+ return roundTo(value, 5);
1334
+ }
1335
+ if (value >= 10) {
1336
+ return roundTo(value, 1);
1337
+ }
1338
+ if (value >= 1) {
1339
+ return roundTo(value, 0.1);
1340
+ }
1022
1341
  return Math.round(value * 100) / 100;
1023
1342
  }
1024
1343
  var SMALLEST_USABLE_FRACTION = 0.25;
@@ -1034,38 +1353,50 @@ function trim(value) {
1034
1353
  return Math.round(value * 100) / 100;
1035
1354
  }
1036
1355
  function roundCountable(value, divisibility, ceiling) {
1037
- if (value <= 0) return { value: 0, clamped: false };
1356
+ if (value <= 0) {
1357
+ return { value: 0, clamped: false };
1358
+ }
1038
1359
  const floor = SMALLEST_USABLE[divisibility];
1039
1360
  if (divisibility !== "whole" && value >= floor && isHalfStep(value)) {
1040
1361
  return { value: trim(value), clamped: false };
1041
1362
  }
1042
1363
  if (divisibility === "whole") {
1043
- if (value < 0.5) return { value: floor, clamped: true };
1364
+ if (value < 0.5) {
1365
+ return { value: floor, clamped: true };
1366
+ }
1044
1367
  return { value: Math.round(value), clamped: false };
1045
1368
  }
1046
- if (value < floor) return { value: floor, clamped: true };
1369
+ if (value < floor) {
1370
+ return { value: floor, clamped: true };
1371
+ }
1047
1372
  if (value < 1) {
1048
1373
  const steps = divisibility === "quarter" ? [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1] : [0.5, 1];
1049
1374
  const candidates = steps.filter(
1050
1375
  (candidate) => candidate >= floor && candidate <= Math.max(ceiling, floor)
1051
1376
  );
1052
- let closest = candidates[0];
1377
+ let closest = candidates[0] ?? floor;
1053
1378
  for (const candidate of candidates) {
1054
- if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
1379
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) {
1380
+ closest = candidate;
1381
+ }
1055
1382
  }
1056
1383
  return { value: trim(closest), clamped: false };
1057
1384
  }
1058
1385
  return { value: Math.round(value), clamped: false };
1059
1386
  }
1060
1387
  function roundSpoon(value, ceiling) {
1061
- if (value <= 0) return { value: 0, clamped: false };
1388
+ if (value <= 0) {
1389
+ return { value: 0, clamped: false };
1390
+ }
1062
1391
  if (value < 1) {
1063
1392
  const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
1064
1393
  (candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION)
1065
1394
  );
1066
- let closest = candidates[0];
1395
+ let closest = candidates[0] ?? SMALLEST_USABLE_FRACTION;
1067
1396
  for (const candidate of candidates) {
1068
- if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
1397
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) {
1398
+ closest = candidate;
1399
+ }
1069
1400
  }
1070
1401
  return { value: trim(closest), clamped: value < SMALLEST_USABLE_FRACTION };
1071
1402
  }
@@ -1076,7 +1407,9 @@ function stepDownSpoon(unit, reference) {
1076
1407
  let ratio = 1;
1077
1408
  while (reference * ratio < 1 && !isHalfStep(reference * ratio)) {
1078
1409
  const step = demoteUnit(current);
1079
- if (!step) break;
1410
+ if (!step) {
1411
+ break;
1412
+ }
1080
1413
  ratio *= step.per;
1081
1414
  current = step.unit;
1082
1415
  }
@@ -1086,24 +1419,33 @@ var EXACT_WITHIN = 0.01;
1086
1419
  var EXACT_SHARE = 5e-3;
1087
1420
  function landedExactly(exact, amount) {
1088
1421
  const gap = Math.abs(exact - amount);
1089
- if (gap > EXACT_WITHIN) return false;
1422
+ if (gap > EXACT_WITHIN) {
1423
+ return false;
1424
+ }
1090
1425
  return exact === 0 || gap / Math.abs(exact) <= EXACT_SHARE;
1091
1426
  }
1092
1427
  function boundLandedExactly(bound) {
1093
- const fine = bound.ratio >= 1 ? bound.ratio : 1;
1428
+ const fine = Math.max(bound.ratio, 1);
1094
1429
  return landedExactly(bound.raw * fine, bound.amount * (fine / bound.ratio));
1095
1430
  }
1096
1431
  function scaleMeasure(low, high, unit, factor, divisibility) {
1097
- const raws = high === null ? [low * factor] : [low * factor, high * factor];
1098
- const sources = high === null ? [low] : [low, high];
1432
+ const ends = high === null ? [{ published: low, raw: low * factor }] : [
1433
+ { published: low, raw: low * factor },
1434
+ { published: high, raw: high * factor }
1435
+ ];
1436
+ const eachEnd = (read) => [
1437
+ read(ends[0]),
1438
+ ...ends.slice(1).map(read)
1439
+ ];
1440
+ const raws = ends.map((end) => end.raw);
1099
1441
  const positive = raws.filter((raw) => raw > 0);
1100
- const reference = positive.length > 0 ? Math.min(...positive) : raws[0];
1442
+ const reference = positive.length > 0 ? Math.min(...positive) : low * factor;
1101
1443
  const inUnit = (target, ratio) => ({
1102
- bounds: raws.map((raw, index) => {
1444
+ bounds: eachEnd(({ published, raw }) => {
1103
1445
  const exact = raw * ratio;
1104
1446
  const rounded = ratio < 1 ? Number((roundMeasured(raw) * ratio).toPrecision(12)) : roundMeasured(exact);
1105
1447
  const usable = rounded === 0 && exact > 0 ? Number(exact.toPrecision(2)) : rounded;
1106
- const ceiling = factor < 1 ? sources[index] * ratio : Number.POSITIVE_INFINITY;
1448
+ const ceiling = factor < 1 ? published * ratio : Number.POSITIVE_INFINITY;
1107
1449
  return {
1108
1450
  amount: Math.min(usable, ceiling),
1109
1451
  exact,
@@ -1120,16 +1462,18 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1120
1462
  }
1121
1463
  if (unit && isSpoonMeasure(unit)) {
1122
1464
  const stepped = stepDownSpoon(unit, reference);
1123
- if (stepped.ratio !== 1) return inUnit(stepped.unit, stepped.ratio);
1124
- const bounds2 = raws.map((raw, index) => {
1125
- const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1465
+ if (stepped.ratio !== 1) {
1466
+ return inUnit(stepped.unit, stepped.ratio);
1467
+ }
1468
+ const bounds2 = eachEnd(({ published, raw }) => {
1469
+ const ceiling = factor < 1 ? published : Number.POSITIVE_INFINITY;
1126
1470
  const rounded = roundSpoon(raw, ceiling);
1127
1471
  return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
1128
1472
  });
1129
1473
  return { bounds: bounds2, unit };
1130
1474
  }
1131
- const bounds = raws.map((raw, index) => {
1132
- const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1475
+ const bounds = eachEnd(({ published, raw }) => {
1476
+ const ceiling = factor < 1 ? published : Number.POSITIVE_INFINITY;
1133
1477
  const rounded = roundCountable(raw, divisibility, ceiling);
1134
1478
  return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
1135
1479
  });
@@ -1147,14 +1491,18 @@ var HALVED_ITEM = /\b(?:jus|juices?)\b/iu;
1147
1491
  var WHOLE_ITEM = /\b(?:eggs?|yolks?|egg\s+whites?|oeufs?|œufs?|jaunes?\s+d['e]|clous?|zestes?|zests?)\b/iu;
1148
1492
  var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ailes?|pilons?|escalopes?|magrets?)\b/iu;
1149
1493
  function cloveDivisibility(unit, item) {
1150
- const counted = unit ? unit.canonical === "clove" : /\bcloves?\b/i.test(item);
1151
- if (!counted) return null;
1152
- return /\bgarlic\b/i.test(item) ? "half" : "whole";
1494
+ const counted = unit ? unit.canonical === "clove" : CLOVE.test(item);
1495
+ if (!counted) {
1496
+ return null;
1497
+ }
1498
+ return GARLIC.test(item) ? "half" : "whole";
1153
1499
  }
1154
1500
  var BLANC_OF = /\bblancs?\s+d(?:e\s|['’])/iu;
1155
1501
  var BLANC_OF_EGG = /\bblancs?\s+d(?:e\s|['’])\s*(?:oeufs?|œufs?)\b/iu;
1156
1502
  function blancDivisibility(item) {
1157
- if (!BLANC_OF.test(item)) return null;
1503
+ if (!BLANC_OF.test(item)) {
1504
+ return null;
1505
+ }
1158
1506
  return BLANC_OF_EGG.test(item) ? "whole" : "half";
1159
1507
  }
1160
1508
  function foldItem(item) {
@@ -1163,15 +1511,31 @@ function foldItem(item) {
1163
1511
  function divisibilityOf(unit, item) {
1164
1512
  const key = foldItem(item);
1165
1513
  const clove = cloveDivisibility(unit, key);
1166
- if (clove) return clove;
1167
- if (unit && !countsBarePieces(unit)) return unitDivisibility(unit);
1514
+ if (clove) {
1515
+ return clove;
1516
+ }
1517
+ if (unit && !countsBarePieces(unit)) {
1518
+ return unitDivisibility(unit);
1519
+ }
1168
1520
  const blanc = blancDivisibility(key);
1169
- if (blanc) return blanc;
1170
- if (WHOLE_ITEM.test(key)) return "whole";
1171
- if (PORTION_SIZED_ITEM.test(key)) return "whole";
1172
- if (HALVED_ITEM.test(key)) return "half";
1173
- if (HALVED_CUT.test(key)) return "half";
1174
- if (QUARTERED_MEASURE.test(key)) return "quarter";
1521
+ if (blanc) {
1522
+ return blanc;
1523
+ }
1524
+ if (WHOLE_ITEM.test(key)) {
1525
+ return "whole";
1526
+ }
1527
+ if (PORTION_SIZED_ITEM.test(key)) {
1528
+ return "whole";
1529
+ }
1530
+ if (HALVED_ITEM.test(key)) {
1531
+ return "half";
1532
+ }
1533
+ if (HALVED_CUT.test(key)) {
1534
+ return "half";
1535
+ }
1536
+ if (QUARTERED_MEASURE.test(key)) {
1537
+ return "quarter";
1538
+ }
1175
1539
  return QUARTERED_ITEM.test(key) ? "quarter" : "half";
1176
1540
  }
1177
1541
  var INVARIABLE_ITEM = /* @__PURE__ */ new Set([
@@ -1260,54 +1624,87 @@ var IRREGULAR_SINGULAR = Object.fromEntries(
1260
1624
  Object.entries(IRREGULAR_PLURAL).map(([one, many]) => [many, one])
1261
1625
  );
1262
1626
  function matchCase(source, replacement) {
1263
- const hasLetter = new RegExp("\\p{L}", "u").test(source);
1264
- if (hasLetter && source === source.toUpperCase()) return replacement.toUpperCase();
1265
- if (source[0] === source[0]?.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
1266
- return replacement[0].toUpperCase() + replacement.slice(1);
1627
+ const hasLetter = LETTER.test(source);
1628
+ if (hasLetter && source === source.toUpperCase()) {
1629
+ return replacement.toUpperCase();
1630
+ }
1631
+ const first = source.slice(0, 1);
1632
+ if (first !== "" && first === first.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
1633
+ return (replacement[0] ?? "").toUpperCase() + replacement.slice(1);
1267
1634
  }
1268
1635
  return replacement;
1269
1636
  }
1270
1637
  function toEnglishPlural(word) {
1271
1638
  const key = word.toLowerCase();
1272
- if (INVARIABLE_ITEM.has(key)) return word;
1639
+ if (INVARIABLE_ITEM.has(key)) {
1640
+ return word;
1641
+ }
1273
1642
  const irregular = IRREGULAR_PLURAL[key];
1274
- if (irregular) return matchCase(word, irregular);
1275
- if (/(?:ch|sh|s|x|z)$/i.test(word)) return `${word}es`;
1276
- if (/[^aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
1277
- if (/(?:[^f]f|fe)$/i.test(word)) return `${word.replace(/fe?$/i, "")}ves`;
1643
+ if (irregular) {
1644
+ return matchCase(word, irregular);
1645
+ }
1646
+ if (SIBILANT_ENDING.test(word)) {
1647
+ return `${word}es`;
1648
+ }
1649
+ if (CONSONANT_THEN_Y.test(word)) {
1650
+ return `${word.slice(0, -1)}ies`;
1651
+ }
1652
+ if (F_ENDING.test(word)) {
1653
+ return `${word.replace(F_OR_FE_ENDING, "")}ves`;
1654
+ }
1278
1655
  return `${word}s`;
1279
1656
  }
1280
1657
  function toEnglishSingular(word) {
1281
1658
  const key = word.toLowerCase();
1282
- if (INVARIABLE_ITEM.has(key)) return word;
1659
+ if (INVARIABLE_ITEM.has(key)) {
1660
+ return word;
1661
+ }
1283
1662
  const irregular = IRREGULAR_SINGULAR[key];
1284
- if (irregular) return matchCase(word, irregular);
1285
- if (/ies$/i.test(word) && word.length > 4) return `${word.slice(0, -3)}y`;
1286
- if (/(?:ch|sh|s|x|z)es$/i.test(word)) return word.slice(0, -2);
1287
- if (/ss$/i.test(word)) return word;
1288
- if (/s$/i.test(word)) return word.slice(0, -1);
1663
+ if (irregular) {
1664
+ return matchCase(word, irregular);
1665
+ }
1666
+ if (IES_ENDING.test(word) && word.length > 4) {
1667
+ return `${word.slice(0, -3)}y`;
1668
+ }
1669
+ if (SIBILANT_PLURAL.test(word)) {
1670
+ return word.slice(0, -2);
1671
+ }
1672
+ if (DOUBLE_S_ENDING.test(word)) {
1673
+ return word;
1674
+ }
1675
+ if (TRAILING_S_ANY_CASE2.test(word)) {
1676
+ return word.slice(0, -1);
1677
+ }
1289
1678
  return word;
1290
1679
  }
1291
1680
  function agreeInEnglish(item, amount) {
1292
- if (!item) return item;
1681
+ if (!item) {
1682
+ return item;
1683
+ }
1293
1684
  const comma = item.indexOf(",");
1294
1685
  const head = comma < 0 ? item : item.slice(0, comma);
1295
1686
  const tail = comma < 0 ? "" : item.slice(comma);
1296
- const preposition = / of /i.exec(head);
1687
+ const preposition = OF_JOINER.exec(head);
1297
1688
  if (preposition) {
1298
1689
  const counted = agreeInEnglish(head.slice(0, preposition.index), amount);
1299
1690
  return `${counted}${head.slice(preposition.index)}${tail}`;
1300
1691
  }
1301
1692
  const words = head.trimEnd().split(" ");
1302
- const last = words[words.length - 1] ?? "";
1303
- if (!/^[A-Za-z]+$/.test(last) || last.length <= 2) return item;
1693
+ const last = words.at(-1) ?? "";
1694
+ if (!LETTERS_ONLY.test(last) || last.length <= 2) {
1695
+ return item;
1696
+ }
1304
1697
  const wantsPlural = amount > 1;
1305
1698
  const plural = toEnglishPlural(last);
1306
1699
  const singular = toEnglishSingular(last);
1307
1700
  const isPlural = last.toLowerCase() !== singular.toLowerCase();
1308
- if (wantsPlural && !isPlural) words[words.length - 1] = plural;
1309
- else if (!wantsPlural && isPlural) words[words.length - 1] = singular;
1310
- else return item;
1701
+ if (wantsPlural && !isPlural) {
1702
+ words[words.length - 1] = plural;
1703
+ } else if (!wantsPlural && isPlural) {
1704
+ words[words.length - 1] = singular;
1705
+ } else {
1706
+ return item;
1707
+ }
1311
1708
  return `${words.join(" ")}${tail}`;
1312
1709
  }
1313
1710
  var FRENCH_OU_PLURAL_IN_X = /* @__PURE__ */ new Set([
@@ -1386,62 +1783,109 @@ function agreeTrailingAdjective(word, wantsPlural) {
1386
1783
  const folded = foldWord(word);
1387
1784
  const isPlural = folded.endsWith("s");
1388
1785
  const singular = isPlural ? word.slice(0, -1) : word;
1389
- if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return null;
1390
- if (wantsPlural === isPlural) return null;
1786
+ if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) {
1787
+ return null;
1788
+ }
1789
+ if (wantsPlural === isPlural) {
1790
+ return null;
1791
+ }
1391
1792
  return wantsPlural ? `${word}s` : singular;
1392
1793
  }
1794
+ function agreeFrenchHead(head, number) {
1795
+ const { wantsPlural, isPlural, ouPlural } = number;
1796
+ if (wantsPlural && !isPlural) {
1797
+ if (SIBILANT_LETTER2.test(head)) {
1798
+ return head;
1799
+ }
1800
+ if (EAU_ENDING2.test(head)) {
1801
+ return `${head}x`;
1802
+ }
1803
+ if (FRENCH_OU_PLURAL_IN_X.has(foldWord(head))) {
1804
+ return `${head}x`;
1805
+ }
1806
+ if (AL_ENDING2.test(head)) {
1807
+ return `${head.slice(0, -2)}aux`;
1808
+ }
1809
+ return `${head}s`;
1810
+ }
1811
+ if (!wantsPlural && isPlural) {
1812
+ if (EAUX_ENDING2.test(head)) {
1813
+ return head.slice(0, -1);
1814
+ }
1815
+ if (AUX_ENDING2.test(head)) {
1816
+ return `${head.slice(0, -3)}al`;
1817
+ }
1818
+ if (ouPlural) {
1819
+ return head.slice(0, -1);
1820
+ }
1821
+ if (INVARIABLE_FRENCH_NOUN.has(foldWord(head))) {
1822
+ return head;
1823
+ }
1824
+ return head.slice(0, -1);
1825
+ }
1826
+ return head;
1827
+ }
1393
1828
  function agreeInFrench(item, amount) {
1394
- if (!item) return item;
1829
+ if (!item) {
1830
+ return item;
1831
+ }
1395
1832
  const words = item.split(" ");
1396
1833
  const head = words[0] ?? "";
1397
- if (head.length <= 3) return item;
1398
- const wantsPlural = amount >= 2;
1399
- const ouPlural = /x$/i.test(head) && FRENCH_OU_PLURAL_IN_X.has(foldWord(head.slice(0, -1)));
1400
- const isPlural = /s$|eaux$|aux$/i.test(head) || ouPlural;
1401
- if (wantsPlural && !isPlural) {
1402
- if (/[sxz]$/i.test(head)) {
1403
- } else if (/eau$/i.test(head)) words[0] = `${head}x`;
1404
- else if (FRENCH_OU_PLURAL_IN_X.has(foldWord(head))) words[0] = `${head}x`;
1405
- else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
1406
- else words[0] = `${head}s`;
1407
- } else if (!wantsPlural && isPlural) {
1408
- if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
1409
- else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
1410
- else if (ouPlural) words[0] = head.slice(0, -1);
1411
- else if (INVARIABLE_FRENCH_NOUN.has(foldWord(head))) {
1412
- } else words[0] = head.slice(0, -1);
1834
+ if (head.length <= 3) {
1835
+ return item;
1413
1836
  }
1837
+ const wantsPlural = amount >= 2;
1838
+ const ouPlural = X_ENDING.test(head) && FRENCH_OU_PLURAL_IN_X.has(foldWord(head.slice(0, -1)));
1839
+ const isPlural = PLURAL_ENDING.test(head) || ouPlural;
1840
+ words[0] = agreeFrenchHead(head, { wantsPlural, isPlural, ouPlural });
1414
1841
  const last = words.length - 1;
1415
- if (last > 0) {
1416
- const adjective = agreeTrailingAdjective(words[last], wantsPlural);
1417
- if (adjective) words[last] = adjective;
1842
+ const trailing = last > 0 ? words[last] : void 0;
1843
+ if (trailing !== void 0) {
1844
+ const adjective = agreeTrailingAdjective(trailing, wantsPlural);
1845
+ if (adjective) {
1846
+ words[last] = adjective;
1847
+ }
1418
1848
  }
1419
1849
  return words.join(" ");
1420
1850
  }
1421
1851
  function agreeLeadingAdjective(word, amount, language) {
1422
- if (language === "en") return word;
1852
+ if (language === "en") {
1853
+ return word;
1854
+ }
1423
1855
  const wantsPlural = amount >= 2;
1424
1856
  const folded = foldWord(word);
1425
1857
  const isPlural = folded.endsWith("s") && !AGREEABLE_ADJECTIVES.has(folded);
1426
1858
  const singular = isPlural ? word.slice(0, -1) : word;
1427
- if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return word;
1428
- if (wantsPlural === isPlural) return word;
1859
+ if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) {
1860
+ return word;
1861
+ }
1862
+ if (wantsPlural === isPlural) {
1863
+ return word;
1864
+ }
1429
1865
  return wantsPlural ? `${singular}s` : singular;
1430
1866
  }
1431
1867
  function agreeWithAmount(item, amount, language) {
1432
1868
  return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
1433
1869
  }
1434
1870
  function agreeCountedContainer(item, amount, language) {
1435
- if (language === "fr") return agreeInFrench(item, amount);
1871
+ if (language === "fr") {
1872
+ return agreeInFrench(item, amount);
1873
+ }
1436
1874
  const space = item.indexOf(" ");
1437
- if (space < 0) return agreeInEnglish(item, amount);
1875
+ if (space < 0) {
1876
+ return agreeInEnglish(item, amount);
1877
+ }
1438
1878
  return `${agreeInEnglish(item.slice(0, space), amount)}${item.slice(space)}`;
1439
1879
  }
1440
1880
  var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
1441
1881
  function joinItem(item, language) {
1442
- if (!item) return "";
1443
- if (language === "en") return ` ${item}`;
1444
- const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);
1882
+ if (!item) {
1883
+ return "";
1884
+ }
1885
+ if (language === "en") {
1886
+ return ` ${item}`;
1887
+ }
1888
+ const elides = VOWEL_OPENING.test(item) || MUTE_H_WORDS.test(item);
1445
1889
  return elides ? ` d'${item}` : ` de ${item}`;
1446
1890
  }
1447
1891
  var BRANCH_SEPARATORS = {
@@ -1450,29 +1894,41 @@ var BRANCH_SEPARATORS = {
1450
1894
  };
1451
1895
  function splitBranch(text, parsed) {
1452
1896
  const itemStart = parsed.item ? text.indexOf(parsed.item) : text.length;
1453
- if (itemStart < 0) return null;
1897
+ if (itemStart < 0) {
1898
+ return null;
1899
+ }
1454
1900
  const separator = BRANCH_SEPARATORS[parsed.language];
1455
1901
  separator.lastIndex = 0;
1456
1902
  for (let match = separator.exec(text); match; match = separator.exec(text)) {
1457
- if (match.index < itemStart) continue;
1903
+ if (match.index < itemStart) {
1904
+ continue;
1905
+ }
1458
1906
  const tail = text.slice(match.index + match[0].length);
1459
- if (parseIngredient(tail, parsed.language).amount === null) continue;
1907
+ if (parseIngredient(tail, parsed.language).amount === null) {
1908
+ continue;
1909
+ }
1460
1910
  return { head: text.slice(0, match.index), separator: match[0], tail };
1461
1911
  }
1462
1912
  return null;
1463
1913
  }
1464
1914
  function scaleIngredient(line, options) {
1465
1915
  const { factor } = options;
1466
- if (factor === 1) return passthroughIngredient(line, options.language);
1916
+ if (factor === 1) {
1917
+ return passthroughIngredient(line, options.language);
1918
+ }
1467
1919
  const text = line.trim();
1468
1920
  const parsed = parseIngredient(text, options.language ?? "auto");
1469
1921
  const branch = splitBranch(text, parsed);
1470
- if (branch) return scaleBranchedLine(line, branch, { ...options, language: parsed.language });
1922
+ if (branch) {
1923
+ return scaleBranchedLine(line, branch, { ...options, language: parsed.language });
1924
+ }
1471
1925
  return scaleSingleLine(line, options);
1472
1926
  }
1473
1927
  function scaleBranchedLine(line, branch, options) {
1474
1928
  const head = scaleSingleLine(branch.head, options);
1475
- if (head.scaling === "unscaled") return { ...head, text: line.trim(), original: line };
1929
+ if (head.scaling === "unscaled") {
1930
+ return { ...head, text: line.trim(), original: line };
1931
+ }
1476
1932
  const tail = scaleAlternative(branch.tail, options);
1477
1933
  const result = {
1478
1934
  ...head,
@@ -1487,11 +1943,128 @@ function scaleBranchedLine(line, branch, options) {
1487
1943
  function scaleAlternative(tail, options) {
1488
1944
  const parsed = parseIngredient(tail, options.language ?? "auto");
1489
1945
  const published = tail.trim();
1490
- if (parsed.amount === null) return { text: published, rewritten: false };
1946
+ if (parsed.amount === null) {
1947
+ return { text: published, rewritten: false };
1948
+ }
1491
1949
  const largest = (parsed.amountMax ?? parsed.amount) * options.factor;
1492
- if (largest < 1) return { text: published, rewritten: false };
1950
+ if (largest < 1) {
1951
+ return { text: published, rewritten: false };
1952
+ }
1493
1953
  return { text: scaleIngredient(tail, options).text, rewritten: true };
1494
1954
  }
1955
+ function alternateLabel(count, style, texts, bracketed) {
1956
+ if (count === 0) {
1957
+ return "";
1958
+ }
1959
+ if (style === "slash") {
1960
+ return ` / ${texts.join(" / ")}`;
1961
+ }
1962
+ if (style === "trailing") {
1963
+ return "";
1964
+ }
1965
+ return bracketed;
1966
+ }
1967
+ function itemLabelFor(named, item, counted, language) {
1968
+ if (named) {
1969
+ return joinItem(item, language);
1970
+ }
1971
+ if (counted) {
1972
+ return ` ${counted}`;
1973
+ }
1974
+ return "";
1975
+ }
1976
+ function noteForScaledLine(outcome) {
1977
+ const {
1978
+ parsed,
1979
+ bounds: primaryBounds,
1980
+ unit,
1981
+ low,
1982
+ clamped,
1983
+ movedPrimary,
1984
+ movedAlternate,
1985
+ restated,
1986
+ collapsed,
1987
+ language,
1988
+ carriesMore
1989
+ } = outcome;
1990
+ let note;
1991
+ const asPublished = (value, source) => `${formatAmount(value, language, { fractions: false })}${source ? ` ${formatUnit(source, value, language)}` : ""}`;
1992
+ const sentences = [];
1993
+ if (clamped) {
1994
+ sentences.push(
1995
+ `Clamped up to ${formatAmount(clamped.amount, language)} from ${formatAmount(Math.round(clamped.raw * 1e3) / 1e3, language)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`
1996
+ );
1997
+ } else if (movedPrimary) {
1998
+ const moved = primaryBounds.filter((bound) => !boundLandedExactly(bound));
1999
+ sentences.push(
2000
+ moved.map(
2001
+ (bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
2002
+ ).join(" ")
2003
+ );
2004
+ } else if (movedAlternate) {
2005
+ sentences.push(
2006
+ `The amount is exact; the equivalent ${restated ? "beside it" : "in brackets"} was rounded to stay readable.`
2007
+ );
2008
+ } else if (restated) {
2009
+ sentences.push(
2010
+ "This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
2011
+ );
2012
+ }
2013
+ if (carriesMore) {
2014
+ sentences.push(
2015
+ "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
2016
+ );
2017
+ }
2018
+ if (parsed.capacity) {
2019
+ sentences.push(
2020
+ `${parsed.capacity} is what one container holds, so it stays as published and the count is what the factor moved. Scaling this line is a matter of opening that many containers of the size named.`
2021
+ );
2022
+ }
2023
+ if (collapsed) {
2024
+ sentences.push("The page gave a range, and at this size both ends come to the same amount.");
2025
+ }
2026
+ if (unit?.kind === "measured" && low.amount > 0 && low.amount < 0.05) {
2027
+ sentences.push(
2028
+ "This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
2029
+ );
2030
+ }
2031
+ if (parsed.approximation) {
2032
+ sentences.push(
2033
+ "The page gave this amount as an approximation, and the scaled figure is no firmer."
2034
+ );
2035
+ }
2036
+ if (sentences.length > 0) {
2037
+ note = sentences.join(" ");
2038
+ }
2039
+ if (parsed.unit && parsed.unit.kind === "approximate") {
2040
+ note = withApproximateNote(parsed.unit, note);
2041
+ }
2042
+ if (parsed.articleWord) {
2043
+ const stood = (parsed.amount ?? 0) / (parsed.countMultiplier ?? 1);
2044
+ const read = `"${parsed.articleWord}" read as ${formatAmount(stood, language)}.`;
2045
+ note = note ? `${read} ${note}` : read;
2046
+ }
2047
+ return note;
2048
+ }
2049
+ function renderScaledLine(parsed, alternates, unit, shown, language) {
2050
+ const named = unit && !countsBarePieces(unit) ? unit : null;
2051
+ const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
2052
+ const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
2053
+ const alternateTexts = alternates.map((entry) => entry.text);
2054
+ const introduced = parsed.alternateIntro ? `${parsed.alternateIntro} ` : "";
2055
+ const bracketed = alternates.length === 0 ? "" : ` (${introduced}${alternateTexts.join(" / ")})`;
2056
+ const altLabel = alternateLabel(
2057
+ alternates.length,
2058
+ parsed.alternateStyle,
2059
+ alternateTexts,
2060
+ bracketed
2061
+ );
2062
+ const trailingLabel = parsed.alternateStyle === "trailing" ? bracketed : "";
2063
+ const capacityLabel = parsed.capacity ? ` ${parsed.capacity}` : "";
2064
+ const counted = parsed.capacity ? agreeCountedContainer(parsed.item, shown, language) : agreeWithAmount(parsed.item, shown, language);
2065
+ const itemLabel = itemLabelFor(named, parsed.item, counted, language);
2066
+ return { named, unitLabel, capacityLabel, altLabel, trailingLabel, itemLabel };
2067
+ }
1495
2068
  var HELD_BACK_NOTE = {
1496
2069
  sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
1497
2070
  itemSize: "The measure standing behind the item gives the size of one of them rather than how many, so the line is left as published. Serving more people is a matter of taking a bigger one, and serving fewer a smaller one.",
@@ -1538,17 +2111,13 @@ function scaleSingleLine(line, options) {
1538
2111
  high === null || collapsed ? null : asText(high.amount),
1539
2112
  parsed.rangeSeparator
1540
2113
  );
1541
- const named = unit && !countsBarePieces(unit) ? unit : null;
1542
- const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
1543
- const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
1544
- const alternateTexts = alternates.map((entry) => entry.text);
1545
- const introduced = parsed.alternateIntro ? `${parsed.alternateIntro} ` : "";
1546
- const bracketed = alternates.length === 0 ? "" : ` (${introduced}${alternateTexts.join(" / ")})`;
1547
- const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : parsed.alternateStyle === "trailing" ? "" : bracketed;
1548
- const trailingLabel = parsed.alternateStyle === "trailing" ? bracketed : "";
1549
- const capacityLabel = parsed.capacity ? ` ${parsed.capacity}` : "";
1550
- const counted = parsed.capacity ? agreeCountedContainer(parsed.item, shown, language) : agreeWithAmount(parsed.item, shown, language);
1551
- const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
2114
+ const { named, unitLabel, capacityLabel, altLabel, trailingLabel, itemLabel } = renderScaledLine(
2115
+ parsed,
2116
+ alternates,
2117
+ unit,
2118
+ shown,
2119
+ language
2120
+ );
1552
2121
  const result = {
1553
2122
  text: `${parsed.decoration ? `${parsed.decoration} ` : ""}${parsed.approximation ?? ""}${amountText}${unitLabel}${capacityLabel}${altLabel}${itemLabel}${trailingLabel}`.trim(),
1554
2123
  original: parsed.original,
@@ -1558,59 +2127,21 @@ function scaleSingleLine(line, options) {
1558
2127
  unit: named?.canonical ?? null,
1559
2128
  language
1560
2129
  };
1561
- const asPublished = (value, source) => `${formatAmount(value, language, { fractions: false })}${source ? ` ${formatUnit(source, value, language)}` : ""}`;
1562
- const sentences = [];
1563
- if (clamped) {
1564
- sentences.push(
1565
- `Clamped up to ${formatAmount(clamped.amount, language)} from ${formatAmount(Math.round(clamped.raw * 1e3) / 1e3, language)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`
1566
- );
1567
- } else if (movedPrimary) {
1568
- const moved = primaryBounds.filter((bound) => !boundLandedExactly(bound));
1569
- sentences.push(
1570
- moved.map(
1571
- (bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
1572
- ).join(" ")
1573
- );
1574
- } else if (movedAlternate) {
1575
- sentences.push(
1576
- `The amount is exact; the equivalent ${restated ? "beside it" : "in brackets"} was rounded to stay readable.`
1577
- );
1578
- } else if (restated) {
1579
- sentences.push(
1580
- "This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
1581
- );
1582
- }
1583
- if (carriesMore) {
1584
- sentences.push(
1585
- "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
1586
- );
1587
- }
1588
- if (parsed.capacity) {
1589
- sentences.push(
1590
- `${parsed.capacity} is what one container holds, so it stays as published and the count is what the factor moved. Scaling this line is a matter of opening that many containers of the size named.`
1591
- );
1592
- }
1593
- if (collapsed) {
1594
- sentences.push("The page gave a range, and at this size both ends come to the same amount.");
1595
- }
1596
- if (unit?.kind === "measured" && low.amount > 0 && low.amount < 0.05) {
1597
- sentences.push(
1598
- "This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
1599
- );
1600
- }
1601
- if (parsed.approximation) {
1602
- sentences.push(
1603
- "The page gave this amount as an approximation, and the scaled figure is no firmer."
1604
- );
1605
- }
1606
- if (sentences.length > 0) result.note = sentences.join(" ");
1607
- if (parsed.unit && parsed.unit.kind === "approximate") {
1608
- result.note = withApproximateNote(parsed.unit, result.note);
1609
- }
1610
- if (parsed.articleWord) {
1611
- const stood = (parsed.amount ?? 0) / (parsed.countMultiplier ?? 1);
1612
- const read = `"${parsed.articleWord}" read as ${formatAmount(stood, language)}.`;
1613
- result.note = result.note ? `${read} ${result.note}` : read;
2130
+ const note = noteForScaledLine({
2131
+ parsed,
2132
+ bounds: primaryBounds,
2133
+ unit,
2134
+ low,
2135
+ clamped,
2136
+ movedPrimary,
2137
+ movedAlternate,
2138
+ restated,
2139
+ collapsed,
2140
+ language,
2141
+ carriesMore
2142
+ });
2143
+ if (note !== void 0) {
2144
+ result.note = note;
1614
2145
  }
1615
2146
  return result;
1616
2147
  }
@@ -1642,8 +2173,10 @@ function renderMeasure(measure, factor, language) {
1642
2173
  };
1643
2174
  }
1644
2175
  function renderRange(low, high, separator) {
1645
- if (high === null || separator === null) return low;
1646
- return /^[-–—]$/.test(separator) ? `${low}${separator}${high}` : `${low} ${separator} ${high}`;
2176
+ if (high === null || separator === null) {
2177
+ return low;
2178
+ }
2179
+ return DASH_ONLY.test(separator) ? `${low}${separator}${high}` : `${low} ${separator} ${high}`;
1647
2180
  }
1648
2181
  function scaleIngredients(lines, options) {
1649
2182
  return lines.map((line) => scaleIngredient(line, options));
@@ -1660,9 +2193,11 @@ function passthroughIngredient(line, language = "auto") {
1660
2193
  unit: held ? null : parsed.unit?.canonical ?? null,
1661
2194
  language: parsed.language
1662
2195
  };
1663
- if (parsed.heldBack) result.note = HELD_BACK_NOTE[parsed.heldBack];
1664
- else if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
1665
- else if (parsed.unit?.kind === "approximate") {
2196
+ if (parsed.heldBack) {
2197
+ result.note = HELD_BACK_NOTE[parsed.heldBack];
2198
+ } else if (parsed.amount === null) {
2199
+ result.note = "No quantity given; adjust to taste.";
2200
+ } else if (parsed.unit?.kind === "approximate") {
1666
2201
  result.note = withApproximateNote(parsed.unit, void 0);
1667
2202
  }
1668
2203
  return result;