mcp-recipes 3.0.0 → 4.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.
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import process from "process";
12
12
 
13
13
  // src/version.ts
14
- var PKG_VERSION = "3.0.0";
14
+ var PKG_VERSION = "4.0.0";
15
15
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
16
16
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
17
17
 
@@ -94,6 +94,7 @@ function loadConfig(env = process.env) {
94
94
  // src/sources/registry.ts
95
95
  import { GoodFoodClient } from "mcp-bbc-goodfood/client";
96
96
  import { MarmitonClient } from "mcp-marmiton/client";
97
+ import { PequerecetasClient } from "mcp-pequerecetas/client";
97
98
  import { PtitchefClient } from "mcp-ptitchef/client";
98
99
  import { SupertoinetteClient } from "mcp-supertoinette/client";
99
100
  import { CookbookClient } from "mcp-wikibooks-cookbook/client";
@@ -254,8 +255,7 @@ var COOKBOOK_PROFILE = {
254
255
  homeUrl: "https://en.wikibooks.org/wiki/Cookbook:Table_of_Contents",
255
256
  language: "en",
256
257
  attribution: "Source: Wikibooks Cookbook",
257
- // The namespace holds pages about salt and about a dish made with it alike.
258
- mixesReferencePages: true
258
+ rowsThatAreNotRecipes: "a page about an ingredient"
259
259
  };
260
260
  var SITE_URL = /^https?:\/\/en\.wikibooks\.org\/wiki\//i;
261
261
  var NAMESPACE = /^cookbook\s*:/i;
@@ -362,6 +362,7 @@ function cookbookDetail(payload) {
362
362
  prepMinutes: null,
363
363
  cookMinutes: null,
364
364
  totalMinutes: count(recipe.totalMinutes),
365
+ gathers: null,
365
366
  category: text(recipe.category),
366
367
  // The page is written by everyone who edited it, so it credits nobody in
367
368
  // particular and carries no reader rating.
@@ -388,9 +389,7 @@ var GOODFOOD_PROFILE = {
388
389
  homeUrl: "https://www.bbcgoodfood.com",
389
390
  language: "en",
390
391
  attribution: "Source: BBC Good Food",
391
- // The search serves recipe pages; a question aimed at the site's reviews and
392
- // buying guides is answered with recipes all the same.
393
- mixesReferencePages: false
392
+ rowsThatAreNotRecipes: null
394
393
  };
395
394
  var SITE_URL2 = /^https?:\/\/(?:www\.)?bbcgoodfood\.com\//i;
396
395
  var REFERENCE = /^recipes\/[^/]+$/;
@@ -529,6 +528,7 @@ function goodfoodDetail(payload) {
529
528
  } : null,
530
529
  // The site files a recipe under collections rather than under one category
531
530
  // on the page itself.
531
+ gathers: null,
532
532
  category: null,
533
533
  author: text(recipe.author),
534
534
  rating: value === null ? null : { value, count: count(recipe.rating_count), max: 5 },
@@ -548,8 +548,7 @@ var MARMITON_PROFILE = {
548
548
  homeUrl: "https://www.marmiton.org",
549
549
  language: "fr",
550
550
  attribution: "Source: Marmiton",
551
- // Every page the search returns is a recipe.
552
- mixesReferencePages: false
551
+ rowsThatAreNotRecipes: null
553
552
  };
554
553
  var SITE_URL3 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
555
554
  var REFERENCE2 = /^\d+$/;
@@ -649,6 +648,7 @@ function marmitonDetail(payload) {
649
648
  prepMinutes: count(recipe.prepMinutes),
650
649
  cookMinutes: count(recipe.cookMinutes),
651
650
  totalMinutes: count(recipe.totalMinutes),
651
+ gathers: null,
652
652
  category: text(recipe.category),
653
653
  author: text(recipe.author),
654
654
  rating: value === null ? null : { value, count: count(rating?.count), max: count(rating?.best) },
@@ -665,6 +665,181 @@ function marmitonDetail(payload) {
665
665
  };
666
666
  }
667
667
 
668
+ // src/sources/pequerecetas.ts
669
+ var PEQUERECETAS_PROFILE = {
670
+ id: "pequerecetas",
671
+ name: "Pequerecetas",
672
+ homeUrl: "https://www.pequerecetas.com",
673
+ language: "es",
674
+ attribution: "Source: Pequerecetas",
675
+ rowsThatAreNotRecipes: "an article gathering other recipes"
676
+ };
677
+ var SITE_RECIPE_URL = /^https?:\/\/(?:www\.)?pequerecetas\.com\/receta\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?$/i;
678
+ var REFERENCE3 = /^[a-z0-9]+(?:-[a-z0-9]+)+$/;
679
+ function pequerecetasAdapter(reader) {
680
+ return {
681
+ ...PEQUERECETAS_PROFILE,
682
+ claims(raw) {
683
+ const address = SITE_RECIPE_URL.exec(raw);
684
+ if (address?.[1]) {
685
+ return {
686
+ reference: address[1],
687
+ why: "the address is a Pequerecetas recipe page",
688
+ guess: false
689
+ };
690
+ }
691
+ if (REFERENCE3.test(raw)) {
692
+ return {
693
+ reference: raw,
694
+ why: "hyphenated lowercase words are the shape Pequerecetas mints",
695
+ guess: true
696
+ };
697
+ }
698
+ return null;
699
+ },
700
+ async search(query) {
701
+ const outcome = await reader.searchRecipes(query);
702
+ const listing = outcome.data ?? {};
703
+ const list = rowsOf(listing.rows, PEQUERECETAS_PROFILE);
704
+ const rows = [];
705
+ let skipped = outcome.skipped?.length ?? 0;
706
+ for (const summary of list) {
707
+ const row = toRow(summary);
708
+ if (row === null) {
709
+ skipped += 1;
710
+ continue;
711
+ }
712
+ rows.push(row);
713
+ }
714
+ return {
715
+ rows,
716
+ skipped,
717
+ // The site prints no count of what a search matched, and the rows of
718
+ // one page are not put here in its place.
719
+ reportedTotal: null,
720
+ reportedTotalMeans: null,
721
+ cached: outcome.cached
722
+ };
723
+ },
724
+ async getRecipe(reference) {
725
+ const outcome = await reader.getRecipe(reference);
726
+ return { recipe: pequerecetasDetail(outcome.data), cached: outcome.cached };
727
+ }
728
+ };
729
+ }
730
+ function toRow(summary) {
731
+ const id = text(summary?.id);
732
+ const title = text(summary?.title);
733
+ const url = text(summary?.url);
734
+ if (!(id && title && url)) {
735
+ return null;
736
+ }
737
+ return {
738
+ id: namespacedId(PEQUERECETAS_PROFILE.id, id),
739
+ source: PEQUERECETAS_PROFILE.id,
740
+ sourceName: PEQUERECETAS_PROFILE.name,
741
+ title,
742
+ url,
743
+ imageUrl: text(summary?.image_url),
744
+ // A listing row carries no gloss on this site.
745
+ excerpt: null
746
+ };
747
+ }
748
+ function pequerecetasDetail(payload) {
749
+ const page = payload ?? {};
750
+ if (page.kind === "collection") {
751
+ return collectionDetail(page.collection);
752
+ }
753
+ return recipeDetail(page.recipe);
754
+ }
755
+ function recipeDetail(payload) {
756
+ const recipe = payload ?? {};
757
+ const rating = recipe.rating;
758
+ const value = count(rating?.value);
759
+ const published = text(recipe.yield_text);
760
+ const span = readYieldSpan(published);
761
+ const steps = Array.isArray(recipe.steps) ? recipe.steps : [];
762
+ const energy = recipe.nutrition;
763
+ const figure = text(energy?.text);
764
+ return {
765
+ ...detailBase(PEQUERECETAS_PROFILE),
766
+ id: namespacedId(PEQUERECETAS_PROFILE.id, required(recipe.id, "id", PEQUERECETAS_PROFILE)),
767
+ title: required(recipe.title, "title", PEQUERECETAS_PROFILE),
768
+ url: required(recipe.url, "url", PEQUERECETAS_PROFILE),
769
+ imageUrl: text(recipe.images?.[0]),
770
+ yieldCount: span.count,
771
+ yieldMax: span.max,
772
+ yieldText: published,
773
+ yieldUnit: span.unit,
774
+ // Carried as published, equipment among them: the site writes what a
775
+ // recipe is cooked with in this list, and telling the two apart is the
776
+ // scaler's work rather than this adapter's.
777
+ ingredients: textList(recipe.ingredients),
778
+ steps: steps.map((step) => text(step?.text)).filter((step) => step !== null),
779
+ // The headings the site groups its steps under belong to the method rather
780
+ // than to the page's layout, so no list of published sections is claimed.
781
+ publishedSections: null,
782
+ prepMinutes: count(recipe.prep_minutes),
783
+ cookMinutes: count(recipe.cook_minutes),
784
+ totalMinutes: count(recipe.total_minutes),
785
+ restMinutes: null,
786
+ // The reader hands over the steps it read and says nothing about whether
787
+ // the page numbered them.
788
+ stepsAsOneBlock: null,
789
+ withheld: null,
790
+ gathers: null,
791
+ category: text(recipe.categories?.[0]),
792
+ author: text(recipe.author),
793
+ // The scale is published, so a value is never read against a guess.
794
+ rating: value === null ? null : { value, count: count(rating?.count), max: count(rating?.scale) },
795
+ // One figure, repeated in the words the page printed it with. The site
796
+ // states no serving size, so nothing is divided: an amount per serving
797
+ // would be arithmetic the page never showed.
798
+ nutrition: figure === null ? null : { calories: figure },
799
+ // The site keeps no list of its own: what a recipe is cooked with is
800
+ // written among the ingredients.
801
+ equipment: [],
802
+ tips: [],
803
+ // The site states no licence on a recipe page, and silence is not a grant.
804
+ license: null
805
+ };
806
+ }
807
+ function collectionDetail(payload) {
808
+ const collection = payload ?? {};
809
+ const listed = Array.isArray(collection.recipes) ? collection.recipes : [];
810
+ return {
811
+ ...detailBase(PEQUERECETAS_PROFILE),
812
+ id: namespacedId(PEQUERECETAS_PROFILE.id, required(collection.id, "id", PEQUERECETAS_PROFILE)),
813
+ title: required(collection.title, "title", PEQUERECETAS_PROFILE),
814
+ url: required(collection.url, "url", PEQUERECETAS_PROFILE),
815
+ imageUrl: text(collection.images?.[0]),
816
+ yieldCount: null,
817
+ yieldMax: null,
818
+ yieldText: null,
819
+ yieldUnit: null,
820
+ ingredients: [],
821
+ steps: [],
822
+ publishedSections: null,
823
+ prepMinutes: null,
824
+ cookMinutes: null,
825
+ totalMinutes: null,
826
+ restMinutes: null,
827
+ stepsAsOneBlock: null,
828
+ withheld: null,
829
+ gathers: {
830
+ headings: textList(collection.headings),
831
+ rows: listed.map(toRow).filter((row) => row !== null)
832
+ },
833
+ category: null,
834
+ author: null,
835
+ rating: null,
836
+ nutrition: null,
837
+ equipment: [],
838
+ tips: [],
839
+ license: null
840
+ };
841
+ }
842
+
668
843
  // src/sources/ptitchef.ts
669
844
  var PTITCHEF_PROFILE = {
670
845
  id: "ptitchef",
@@ -672,18 +847,15 @@ var PTITCHEF_PROFILE = {
672
847
  homeUrl: "https://www.ptitchef.com",
673
848
  language: "fr",
674
849
  attribution: "Source: Ptitchef",
675
- // Every row a listing serves names a recipe; a topic the site answers with a
676
- // guide of its own groups recipes under headings rather than mixing in pages
677
- // about ingredients.
678
- mixesReferencePages: false
850
+ rowsThatAreNotRecipes: null
679
851
  };
680
852
  var SITE_URL4 = /^https?:\/\/(?:www\.)?ptitchef\.com\//i;
681
- var REFERENCE3 = /^recettes\/[^/]+\/[^/]+-fid-\d+$/;
853
+ var REFERENCE4 = /^recettes\/[^/]+\/[^/]+-fid-\d+$/;
682
854
  var DOT_SEGMENT2 = /(?:^|\/)\.\.?(?:\/|$)/;
683
855
  var LEADING_SLASH2 = /^\/+/;
684
856
  function asReference2(raw) {
685
857
  const path = raw.replace(LEADING_SLASH2, "");
686
- return REFERENCE3.test(path) && !DOT_SEGMENT2.test(`/${path}`) ? path : null;
858
+ return REFERENCE4.test(path) && !DOT_SEGMENT2.test(`/${path}`) ? path : null;
687
859
  }
688
860
  function meansFor2(listing) {
689
861
  if (count(listing.total_available) === null) {
@@ -784,6 +956,7 @@ function ptitchefDetail(payload) {
784
956
  restMinutes: null,
785
957
  stepsAsOneBlock: typeof recipe.steps_are_one_block === "boolean" ? recipe.steps_are_one_block : null,
786
958
  withheld: null,
959
+ gathers: null,
787
960
  category: text(recipe.category),
788
961
  author: text(recipe.author),
789
962
  // The site prints two counts beside a rating, one of readers who rated and
@@ -805,17 +978,15 @@ var SUPERTOINETTE_PROFILE = {
805
978
  homeUrl: "https://www.supertoinette.com",
806
979
  language: "fr",
807
980
  attribution: "Source: Supertoinette",
808
- // The reader sets aside any row that opens onto something other than a
809
- // recipe, so what arrives here is recipes.
810
- mixesReferencePages: false
981
+ rowsThatAreNotRecipes: null
811
982
  };
812
- var SITE_RECIPE_URL = /^https?:\/\/(?:www\.)?supertoinette\.com\/recette\/([1-9][0-9]{0,9})\//i;
813
- var REFERENCE4 = /^[1-9][0-9]{0,9}$/;
983
+ var SITE_RECIPE_URL2 = /^https?:\/\/(?:www\.)?supertoinette\.com\/recette\/([1-9][0-9]{0,9})\//i;
984
+ var REFERENCE5 = /^[1-9][0-9]{0,9}$/;
814
985
  function supertoinetteAdapter(reader) {
815
986
  return {
816
987
  ...SUPERTOINETTE_PROFILE,
817
988
  claims(raw) {
818
- const address = SITE_RECIPE_URL.exec(raw);
989
+ const address = SITE_RECIPE_URL2.exec(raw);
819
990
  if (address?.[1]) {
820
991
  return {
821
992
  reference: address[1],
@@ -823,7 +994,7 @@ function supertoinetteAdapter(reader) {
823
994
  guess: false
824
995
  };
825
996
  }
826
- if (REFERENCE4.test(raw)) {
997
+ if (REFERENCE5.test(raw)) {
827
998
  return {
828
999
  reference: raw,
829
1000
  why: "a bare number is the shape Supertoinette mints",
@@ -908,6 +1079,7 @@ function supertoinetteDetail(payload) {
908
1079
  // the page numbered them.
909
1080
  stepsAsOneBlock: null,
910
1081
  withheld: null,
1082
+ gathers: null,
911
1083
  category: text(recipe.category),
912
1084
  author: text(recipe.author),
913
1085
  // The scale is published, so a value is never read against a guess.
@@ -925,30 +1097,54 @@ function supertoinetteDetail(payload) {
925
1097
 
926
1098
  // src/sources/registry.ts
927
1099
  var PAGE_READ_BOUNDS = { maxBodyBytes: 8e6, budgetMs: 6e4 };
928
- function buildSources(config, readers, logger) {
929
- const shared = {
1100
+ var PACING_FLOOR_MS = {
1101
+ marmiton: 500,
1102
+ cookbook: 500,
1103
+ ptitchef: 1e3,
1104
+ goodfood: 1e3,
1105
+ supertoinette: 3e3,
1106
+ pequerecetas: 3e3
1107
+ };
1108
+ var PACED_SOURCES = Object.keys(PACING_FLOOR_MS);
1109
+ function pacingFor(source, configured) {
1110
+ return Math.max(configured, PACING_FLOOR_MS[source] ?? SLOWEST_FLOOR_MS);
1111
+ }
1112
+ var SLOWEST_FLOOR_MS = Math.max(...Object.values(PACING_FLOOR_MS));
1113
+ function pacedConfig(config, source) {
1114
+ return {
930
1115
  userAgent: config.userAgent,
931
- minIntervalMs: config.minIntervalMs,
1116
+ minIntervalMs: pacingFor(source, config.minIntervalMs),
932
1117
  timeoutMs: config.timeoutMs,
933
1118
  maxRetries: config.maxRetries,
934
1119
  cacheTtlMs: config.cacheTtlMs,
935
1120
  cacheMaxEntries: config.cacheMaxEntries,
936
1121
  logLevel: config.logLevel
937
1122
  };
938
- const marmitonOptions = { config: shared };
939
- const cookbookOptions = { config: shared };
1123
+ }
1124
+ function buildSources(config, readers, logger) {
1125
+ const paced = (source) => pacedConfig(config, source);
1126
+ const marmitonOptions = { config: paced(MARMITON_PROFILE.id) };
1127
+ const cookbookOptions = { config: paced(COOKBOOK_PROFILE.id) };
940
1128
  const ptitchefOptions = {
941
- config: { ...shared, ...PAGE_READ_BOUNDS },
1129
+ config: { ...paced(PTITCHEF_PROFILE.id), ...PAGE_READ_BOUNDS },
1130
+ logger
1131
+ };
1132
+ const goodfoodOptions = { config: paced(GOODFOOD_PROFILE.id), logger };
1133
+ const supertoinetteOptions = {
1134
+ config: paced(SUPERTOINETTE_PROFILE.id),
1135
+ logger
1136
+ };
1137
+ const pequerecetasOptions = {
1138
+ config: paced(PEQUERECETAS_PROFILE.id),
942
1139
  logger
943
1140
  };
944
- const goodfoodOptions = { config: shared, logger };
945
- const supertoinetteOptions = { config: shared, logger };
946
1141
  return [
947
1142
  marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),
948
1143
  cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions)),
949
1144
  ptitchefAdapter(readers.ptitchef ?? new PtitchefClient(ptitchefOptions)),
950
1145
  goodfoodAdapter(readers.goodfood ?? new GoodFoodClient(goodfoodOptions)),
951
- supertoinetteAdapter(readers.supertoinette ?? new SupertoinetteClient(supertoinetteOptions))
1146
+ supertoinetteAdapter(readers.supertoinette ?? new SupertoinetteClient(supertoinetteOptions)),
1147
+ pequerecetasAdapter(readers.pequerecetas ?? new PequerecetasClient(pequerecetasOptions))
952
1148
  ];
953
1149
  }
954
1150
  function selectSources(sources, wanted) {
@@ -976,7 +1172,8 @@ var PROFILES = [
976
1172
  COOKBOOK_PROFILE,
977
1173
  PTITCHEF_PROFILE,
978
1174
  GOODFOOD_PROFILE,
979
- SUPERTOINETTE_PROFILE
1175
+ SUPERTOINETTE_PROFILE,
1176
+ PEQUERECETAS_PROFILE
980
1177
  ];
981
1178
 
982
1179
  // src/sources/ids.ts
@@ -1133,6 +1330,84 @@ var FRENCH_FRAME = /* @__PURE__ */ new Set([
1133
1330
  "vous",
1134
1331
  "\xE0"
1135
1332
  ]);
1333
+ var SPANISH_FRAME = /* @__PURE__ */ new Set([
1334
+ "algo",
1335
+ "alguna",
1336
+ "algunas",
1337
+ "alguno",
1338
+ "algunos",
1339
+ "buenas",
1340
+ "buenos",
1341
+ "busco",
1342
+ "buscar",
1343
+ "casera",
1344
+ "caseras",
1345
+ "casero",
1346
+ "caseros",
1347
+ "como",
1348
+ "conoces",
1349
+ "cual",
1350
+ "cuales",
1351
+ "dame",
1352
+ "de",
1353
+ "del",
1354
+ "dime",
1355
+ "el",
1356
+ "ella",
1357
+ "ellos",
1358
+ "en",
1359
+ "es",
1360
+ "esta",
1361
+ "estoy",
1362
+ "facil",
1363
+ "faciles",
1364
+ "gracias",
1365
+ "hacer",
1366
+ "hago",
1367
+ "hay",
1368
+ "hola",
1369
+ "las",
1370
+ "los",
1371
+ "mas",
1372
+ "me",
1373
+ "mejor",
1374
+ "mejores",
1375
+ "necesito",
1376
+ "para",
1377
+ "pero",
1378
+ "por",
1379
+ "porfavor",
1380
+ "puedes",
1381
+ "que",
1382
+ "queria",
1383
+ "quiero",
1384
+ "quisiera",
1385
+ "receta",
1386
+ "recetas",
1387
+ "rica",
1388
+ "ricas",
1389
+ "rico",
1390
+ "ricos",
1391
+ "saber",
1392
+ "sabes",
1393
+ "se",
1394
+ "sencilla",
1395
+ "sencillas",
1396
+ "sencillo",
1397
+ "sencillos",
1398
+ "ser",
1399
+ "si",
1400
+ "son",
1401
+ "soy",
1402
+ "tengo",
1403
+ "tienes",
1404
+ "un",
1405
+ "una",
1406
+ "unas",
1407
+ "unos",
1408
+ "y",
1409
+ "yo"
1410
+ ]);
1136
1411
  var ENGLISH_FRAME = /* @__PURE__ */ new Set([
1137
1412
  "a",
1138
1413
  "about",
@@ -1244,6 +1519,8 @@ var NEGATORS = /* @__PURE__ */ new Set([
1244
1519
  "pas",
1245
1520
  "sans",
1246
1521
  "sauf",
1522
+ "sin",
1523
+ "tampoco",
1247
1524
  "without",
1248
1525
  "zero"
1249
1526
  ]);
@@ -1265,7 +1542,12 @@ var HEALTH_MARKERS = /* @__PURE__ */ new Set([
1265
1542
  "intolerant",
1266
1543
  "intolerante",
1267
1544
  "intolerantes",
1268
- "intolerants"
1545
+ "alergia",
1546
+ "alergias",
1547
+ "alergica",
1548
+ "alergico",
1549
+ "celiaca",
1550
+ "celiaco"
1269
1551
  ]);
1270
1552
  var DIETS = /* @__PURE__ */ new Set([
1271
1553
  "casher",
@@ -1287,6 +1569,12 @@ var DIETS = /* @__PURE__ */ new Set([
1287
1569
  "vegetalienne",
1288
1570
  "vegetaliennes",
1289
1571
  "vegetaliens",
1572
+ "vegana",
1573
+ "veganas",
1574
+ "veganos",
1575
+ "vegetariana",
1576
+ "vegetarianas",
1577
+ "vegetarianos",
1290
1578
  "vegetarian",
1291
1579
  "vegetarians",
1292
1580
  "vegetarien",
@@ -1340,10 +1628,10 @@ function foldWord(word) {
1340
1628
  return word.toLowerCase().normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe");
1341
1629
  }
1342
1630
  function namesTheDish(word, key) {
1343
- if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) {
1631
+ if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key) || SPANISH_FRAME.has(key)) {
1344
1632
  return false;
1345
1633
  }
1346
- if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) {
1634
+ if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word) || SPANISH_FRAME.has(word)) {
1347
1635
  return false;
1348
1636
  }
1349
1637
  if (DIGITS_ONLY.test(word)) {
@@ -1451,7 +1739,7 @@ function takeFood(words, from) {
1451
1739
  }
1452
1740
  function isFiller(word) {
1453
1741
  const key = foldWord(word);
1454
- return CONDITION_JOINERS.has(key) || FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key);
1742
+ return CONDITION_JOINERS.has(key) || FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key) || SPANISH_FRAME.has(key);
1455
1743
  }
1456
1744
  function namesFood(word) {
1457
1745
  const key = foldWord(word);
@@ -1473,7 +1761,7 @@ function namesFood(word) {
1473
1761
  if (CONDITION_UNITS.has(key)) {
1474
1762
  return false;
1475
1763
  }
1476
- return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key));
1764
+ return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key) || SPANISH_FRAME.has(key));
1477
1765
  }
1478
1766
  function readConditions(question) {
1479
1767
  const { conditions } = readQuestion(question);
@@ -1547,6 +1835,7 @@ function tidy(value) {
1547
1835
 
1548
1836
  // src/sources/client.ts
1549
1837
  var LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
1838
+ var LONGEST_WAIT_BETWEEN_TRIES_MS = 3e4;
1550
1839
  var MAX_LIMIT_PER_SOURCE = 25;
1551
1840
  function withGuarantees(config) {
1552
1841
  const defaults = loadConfig({});
@@ -1588,7 +1877,8 @@ function withDeadline(work, ms, source) {
1588
1877
  });
1589
1878
  return Promise.race([work, alarm]).finally(() => clearTimeout(timer));
1590
1879
  }
1591
- function reportOf(attempt, count2) {
1880
+ function reportOf(attempt, rows, question) {
1881
+ const count2 = rows.length;
1592
1882
  return {
1593
1883
  source: attempt.source.id,
1594
1884
  name: attempt.source.name,
@@ -1597,11 +1887,14 @@ function reportOf(attempt, count2) {
1597
1887
  reportedTotal: attempt.reportedTotal,
1598
1888
  reportedTotalMeans: attempt.reportedTotalMeans,
1599
1889
  skipped: attempt.skipped,
1600
- mixesReferencePages: attempt.source.mixesReferencePages,
1890
+ rowsThatAreNotRecipes: attempt.source.rowsThatAreNotRecipes,
1601
1891
  cached: attempt.cached,
1602
1892
  error: attempt.error,
1603
1893
  wordings: attempt.wordings,
1604
- preferredByName: attempt.preferredByName
1894
+ preferredByName: attempt.preferredByName,
1895
+ // Counted over the rows this answer holds, so it is read against `count`
1896
+ // and never against a number of rows nobody was shown.
1897
+ namesTheDish: rows.filter((row) => namesDish(row.title, question)).length
1605
1898
  };
1606
1899
  }
1607
1900
  function heldBack(wording, because) {
@@ -1677,11 +1970,20 @@ var RecipesClient = class {
1677
1970
  }
1678
1971
  /**
1679
1972
  * The backstop deadline over one source, covering every attempt it is
1680
- * entitled to make and the pacing between them.
1973
+ * entitled to make and the waiting between them.
1974
+ *
1975
+ * Three things a reader spends between two attempts, and all three belong
1976
+ * here or this fires while the reader is still working: the spacing that
1977
+ * source is read at, which is its own and not the shared setting; the backoff
1978
+ * a reader adds after a failure; and the wait a site asks for by name when it
1979
+ * answers "slow down". A deadline shorter than the reader's own abandons a
1980
+ * read whose requests carry on reaching the site, and leaves that source's
1981
+ * queue occupied behind an answer nobody waits for.
1681
1982
  */
1682
- get deadlineMs() {
1983
+ deadlineFor(source) {
1683
1984
  const attempts = this.config.maxRetries + 1;
1684
- return this.config.timeoutMs * attempts + this.config.minIntervalMs * this.config.maxRetries;
1985
+ const between = pacingFor(source, this.config.minIntervalMs) + LONGEST_WAIT_BETWEEN_TRIES_MS;
1986
+ return this.config.timeoutMs * attempts + between * this.config.maxRetries;
1685
1987
  }
1686
1988
  /**
1687
1989
  * Ask every source the same question and merge the rows.
@@ -1710,7 +2012,7 @@ var RecipesClient = class {
1710
2012
  const answered = attempts.map((attempt) => ({ attempt, rows: attempt.rows.slice(0, limit) }));
1711
2013
  return {
1712
2014
  rows: interleave(answered.map((one) => one.rows)),
1713
- reports: answered.map(({ attempt, rows }) => reportOf(attempt, rows.length))
2015
+ reports: answered.map(({ attempt, rows }) => reportOf(attempt, rows, trimmed))
1714
2016
  };
1715
2017
  }
1716
2018
  /**
@@ -1772,7 +2074,7 @@ var RecipesClient = class {
1772
2074
  try {
1773
2075
  const read = await withDeadline(
1774
2076
  source.search(wording.query, limit),
1775
- this.deadlineMs,
2077
+ this.deadlineFor(source.id),
1776
2078
  source
1777
2079
  );
1778
2080
  answered = true;
@@ -1821,15 +2123,12 @@ var RecipesClient = class {
1821
2123
  failure = reason;
1822
2124
  }
1823
2125
  }
1824
- const contributing = wordings.filter((attempt) => (attempt.added ?? 0) > 0).length;
1825
- const ordered = contributing > 1 ? [
1826
- ...rows.filter((row) => namesDish(row.title, question)),
1827
- ...rows.filter((row) => !namesDish(row.title, question))
1828
- ] : rows;
2126
+ const onTopicRows = rows.filter((row) => namesDish(row.title, question));
2127
+ const ordered = [...onTopicRows, ...rows.filter((row) => !namesDish(row.title, question))];
1829
2128
  return {
1830
2129
  source,
1831
2130
  rows: ordered,
1832
- preferredByName: contributing > 1,
2131
+ preferredByName: true,
1833
2132
  cached,
1834
2133
  reportedTotal,
1835
2134
  reportedTotalMeans,
@@ -1850,7 +2149,7 @@ var RecipesClient = class {
1850
2149
  try {
1851
2150
  const outcome = await withDeadline(
1852
2151
  read.source.getRecipe(read.reference),
1853
- this.deadlineMs,
2152
+ this.deadlineFor(read.source.id),
1854
2153
  read.source
1855
2154
  );
1856
2155
  return { recipe: outcome.recipe, cached: outcome.cached, read };
@@ -1866,21 +2165,194 @@ import { z as z4 } from "zod";
1866
2165
  // src/tools/recipeView.ts
1867
2166
  import { z as z2 } from "zod";
1868
2167
 
2168
+ // src/recipe/equipment.ts
2169
+ var LEADING_QUANTITY = /^\s*(?:\d+(?:[.,/]\d+)?|[¼½¾⅓⅔⅛])\s*/u;
2170
+ var LEADING_ARTICLE = /^(?:un|una|unos|unas|une|des|del|de|the|an?|le|la|los|las)\s+/iu;
2171
+ var WORD_SPLIT = /[\s,;:()]+/u;
2172
+ var TOOL_NOUNS = /* @__PURE__ */ new Set([
2173
+ // Spanish
2174
+ "freidora",
2175
+ "airfryer",
2176
+ "batidora",
2177
+ "licuadora",
2178
+ "amasadora",
2179
+ "thermomix",
2180
+ "robot",
2181
+ "olla",
2182
+ "paellera",
2183
+ "cazuela",
2184
+ "sarten",
2185
+ "molde",
2186
+ "rejilla",
2187
+ "brocha",
2188
+ "pincel",
2189
+ "espatula",
2190
+ "varilla",
2191
+ "varillas",
2192
+ "colador",
2193
+ "escurridor",
2194
+ "tamiz",
2195
+ "mortero",
2196
+ "rodillo",
2197
+ "boquilla",
2198
+ "termometro",
2199
+ "temporizador",
2200
+ "microondas",
2201
+ "vaporera",
2202
+ "parrilla",
2203
+ "cuchillo",
2204
+ "pelador",
2205
+ "rallador",
2206
+ "exprimidor",
2207
+ "pinza",
2208
+ "pinzas",
2209
+ "cuenco",
2210
+ "recipiente",
2211
+ "batidor",
2212
+ "prensa",
2213
+ "palillo",
2214
+ "palillos",
2215
+ "brocheta",
2216
+ "brochetas",
2217
+ // French
2218
+ "poele",
2219
+ "cocotte",
2220
+ "sauteuse",
2221
+ "saladier",
2222
+ "fouet",
2223
+ "spatule",
2224
+ "passoire",
2225
+ "mixeur",
2226
+ "mandoline",
2227
+ "pinceau",
2228
+ "casserole",
2229
+ // English
2230
+ "skillet",
2231
+ "saucepan",
2232
+ "whisk",
2233
+ "spatula",
2234
+ "sieve",
2235
+ "colander",
2236
+ "grater",
2237
+ "blender",
2238
+ "processor",
2239
+ "ramekin",
2240
+ "thermometer",
2241
+ "peeler"
2242
+ ]);
2243
+ var AMBIGUOUS_TOOL_NOUNS = {
2244
+ papel: /^(?:hornear|horno|aluminio|film|vegetal|absorbente|cocina|parchment|baking)$/,
2245
+ papier: /^(?:cuisson|sulfurise|aluminium|film)$/,
2246
+ vaso: /^(?:medidor|batidora|thermomix)$/,
2247
+ cuchara: /^(?:madera|silicona|helado)$/,
2248
+ manga: /^(?:pastelera)$/,
2249
+ tabla: /^(?:cortar|madera)$/,
2250
+ plancha: /^(?:electrica|hierro)$/,
2251
+ horno: /^(?:precalentado)$/,
2252
+ bol: /^(?:grande|mediano|pequeno|hondo|cristal|acero)$/,
2253
+ plato: /^(?:hondo|llano|grande)$/,
2254
+ bandeja: /^(?:horno|hornear|pastelera)$/,
2255
+ fuente: /^(?:horno|hornear|cristal)$/,
2256
+ film: /^(?:transparente|plastico|alimentario)$/,
2257
+ moule: /^(?:tarte|manque|cake|silicone|charniere)$/,
2258
+ plat: /^(?:four|gratin|cuisson)$/,
2259
+ plaque: /^(?:four|cuisson|patisserie)$/,
2260
+ planche: /^(?:decouper|bois)$/,
2261
+ tin: /^(?:baking|cake|loaf|roasting|flan|sandwich)$/,
2262
+ pan: /^(?:frying|baking|roasting|cake|loaf|sauce|griddle)$/,
2263
+ dish: /^(?:baking|roasting|ovenproof|gratin)$/,
2264
+ tray: /^(?:baking|roasting|oven)$/,
2265
+ sheet: /^(?:baking|greaseproof|parchment)$/,
2266
+ bowl: /^(?:mixing|large|small)$/,
2267
+ mould: /^(?:cake|jelly|silicone)$/,
2268
+ mold: /^(?:cake|jelly|silicone)$/,
2269
+ paper: /^(?:baking|greaseproof|parchment)$/,
2270
+ foil: /^(?:aluminium|aluminum|kitchen)$/,
2271
+ board: /^(?:chopping|cutting|wooden)$/
2272
+ };
2273
+ var QUALIFIES_A_TOOL = /^(?:aire|hornear|horno|cocina|madera|silicona|silicone|barro|cristal|acero|hierro|aluminio|aluminium|plastico|papel|cortar|decouper|bois|varillas|mano|repostera|pasteleria|pastelera|tarte|manque|four|cuisson|gratin|patisserie|baking|frying|roasting|chopping|cutting|mixing|greaseproof|parchment|nonstick|cake|loaf|wooden|\d+|cm|mm|l|ml)$/;
2274
+ var PARTITIVE = /^(?:de|del|du|des|d|a|au|à)$/i;
2275
+ function fold2(word) {
2276
+ return word.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").toLowerCase();
2277
+ }
2278
+ function holds(vocabulary, word) {
2279
+ if (vocabulary.has(word)) {
2280
+ return true;
2281
+ }
2282
+ if (word.endsWith("es") && vocabulary.has(word.slice(0, -2))) {
2283
+ return true;
2284
+ }
2285
+ return word.endsWith("s") && vocabulary.has(word.slice(0, -1));
2286
+ }
2287
+ function isEquipmentLine(line) {
2288
+ const head = line.replace(LEADING_QUANTITY, "").replace(LEADING_ARTICLE, "").trim();
2289
+ const words = head.split(WORD_SPLIT).filter((word) => word !== "");
2290
+ const first = words[0];
2291
+ if (first === void 0) {
2292
+ return false;
2293
+ }
2294
+ const folded = fold2(first);
2295
+ if (holds(TOOL_NOUNS, folded)) {
2296
+ return notMeasuringSomethingElse(words);
2297
+ }
2298
+ const ambiguous = qualifierFor(folded);
2299
+ if (ambiguous) {
2300
+ return ambiguous.test(wordBeside(words, 0));
2301
+ }
2302
+ const second = words[1];
2303
+ if (second !== void 0 && QUALIFIES_A_TOOL.test(folded)) {
2304
+ const behind = qualifierFor(fold2(second));
2305
+ return behind ? behind.test(folded) : holds(TOOL_NOUNS, fold2(second));
2306
+ }
2307
+ return false;
2308
+ }
2309
+ function qualifierFor(word) {
2310
+ return AMBIGUOUS_TOOL_NOUNS[word] ?? (word.endsWith("es") ? AMBIGUOUS_TOOL_NOUNS[word.slice(0, -2)] : void 0) ?? (word.endsWith("s") ? AMBIGUOUS_TOOL_NOUNS[word.slice(0, -1)] : void 0);
2311
+ }
2312
+ function wordAt(words, index) {
2313
+ const word = words[index];
2314
+ return word === void 0 ? "" : fold2(word);
2315
+ }
2316
+ function wordBeside(words, index) {
2317
+ const next = wordAt(words, index + 1);
2318
+ return PARTITIVE.test(next) ? wordAt(words, index + 2) : next;
2319
+ }
2320
+ function notMeasuringSomethingElse(words) {
2321
+ const after = words[1];
2322
+ if (after === void 0 || !PARTITIVE.test(fold2(after))) {
2323
+ return true;
2324
+ }
2325
+ return QUALIFIES_A_TOOL.test(wordAt(words, 2));
2326
+ }
2327
+ var EQUIPMENT_NOTE = "This line names a tool, so it was left as published: a recipe made for more people uses the same one.";
2328
+
1869
2329
  // src/recipe/language.ts
1870
- var FRENCH_WORDS = /(?:^|[\s(])(?:de|du|des|à|au|aux|la|le|les|un|une|quelques|dans|avec|sans|pour|selon|environ)(?=[\s),.]|$)|\bd'|\bl'|\bqu'/iu;
2330
+ var FRENCH_WORDS = /(?:^|[\s(])(?:du|des|à|au|aux|le|les|une|quelques|dans|avec|sans|pour|selon|environ)(?=[\s),.]|$)|\bd'|\bl'|\bqu'/iu;
1871
2331
  var ENGLISH_WORDS = /(?:^|[\s(])(?:of|an|the|and|with|without|for|into|about|each|per|plus|freshly|finely|roughly|coarsely|optional)(?=[\s),.]|$)/iu;
1872
- var FRENCH_LETTERS = /[àâäçéèêëîïôöùûüœ]/iu;
1873
- var FRENCH_FOODS = /\b(?:oeufs?|œufs?|sel|poivre|sucres?|farine|beurre|lait|crème|creme|huile|ail|oignons?|fromage|pain|poulet|boeuf|bœuf|riz|pâtes|pates|jambon|citrons?|pommes?|vin|eau|persil|thym|miel|jaunes?|blancs?)\b/iu;
2332
+ var SPANISH_WORDS = /(?:^|[\s(])(?:del|el|los|las|una|unos|unas|con|sin|para|al|y|según|aproximadamente|picad[oa]s?|tro?cead[oa]s?|rallad[oa]s?|molid[oa]s?|cortad[oa]s?|pelad[oa]s?|opcional)(?=[\s),.]|$)/iu;
2333
+ var ROMANCE_WORDS = /(?:^|[\s(])(?:de|la|un|en)(?=[\s),.]|$)/iu;
2334
+ var FRENCH_LETTERS = /[àâçèêëîïôùûœ]/iu;
2335
+ var SPANISH_LETTERS = /[áíóúñ¿¡]/iu;
2336
+ var ROMANCE_LETTERS = /[éü]/iu;
2337
+ var FRENCH_FOODS = /\b(?:oeufs?|œufs?|sel|poivre|sucres?|farine|beurre|lait|crème|creme|huile|ail|oignons?|fromage|pain|poulet|boeuf|bœuf|riz|pâtes|pates|jambon|citrons?|pommes?|vin|eau|persil|thym|jaunes?|blancs?)\b/iu;
1874
2338
  var ENGLISH_FOODS = /\b(?:eggs?|salt|pepper|sugar|flour|butter|milk|cream|oil|garlic|onions?|cheese|bread|chicken|beef|rice|pasta|ham|lemons?|apples?|wine|water|parsley|thyme|honey|yolks?|whites?)\b/iu;
2339
+ var SPANISH_FOODS = /\b(?:huevos?|sal|pimienta|azúcar|azucar|harina|mantequilla|leche|nata|aceite|ajos?|cebollas?|queso|pollo|ternera|cerdo|arroz|jamón|jamon|limones|limón|limon|manzanas?|patatas?|vino|agua|perejil|tomillo|yemas?|claras?)\b/iu;
1875
2340
  var STRUCTURAL = 3;
1876
2341
  var VOCABULARY = 2;
1877
2342
  var NAME = 1;
1878
- function readLanguage(line, evidence = {
1879
- frenchUnit: false,
1880
- englishUnit: false
1881
- }) {
2343
+ var NO_UNIT = { frenchUnit: false, englishUnit: false, spanishUnit: false };
2344
+ function readLanguage(line, evidence = NO_UNIT) {
1882
2345
  let french = 0;
1883
2346
  let english = 0;
2347
+ let spanish = 0;
2348
+ if (ROMANCE_WORDS.test(line)) {
2349
+ french += STRUCTURAL;
2350
+ spanish += STRUCTURAL;
2351
+ }
2352
+ if (ROMANCE_LETTERS.test(line)) {
2353
+ french += STRUCTURAL;
2354
+ spanish += STRUCTURAL;
2355
+ }
1884
2356
  if (FRENCH_WORDS.test(line)) {
1885
2357
  french += STRUCTURAL;
1886
2358
  }
@@ -1890,19 +2362,40 @@ function readLanguage(line, evidence = {
1890
2362
  if (ENGLISH_WORDS.test(line)) {
1891
2363
  english += STRUCTURAL;
1892
2364
  }
2365
+ if (SPANISH_WORDS.test(line)) {
2366
+ spanish += STRUCTURAL;
2367
+ }
2368
+ if (SPANISH_LETTERS.test(line)) {
2369
+ spanish += STRUCTURAL;
2370
+ }
1893
2371
  if (evidence.frenchUnit) {
1894
2372
  french += VOCABULARY;
1895
2373
  }
1896
2374
  if (evidence.englishUnit) {
1897
2375
  english += VOCABULARY;
1898
2376
  }
2377
+ if (evidence.spanishUnit) {
2378
+ spanish += VOCABULARY;
2379
+ }
1899
2380
  if (FRENCH_FOODS.test(line)) {
1900
2381
  french += NAME;
1901
2382
  }
1902
2383
  if (ENGLISH_FOODS.test(line)) {
1903
2384
  english += NAME;
1904
2385
  }
1905
- return { language: french > english ? "fr" : "en", french, english };
2386
+ if (SPANISH_FOODS.test(line)) {
2387
+ spanish += NAME;
2388
+ }
2389
+ return { language: winner(french, english, spanish), french, english, spanish };
2390
+ }
2391
+ function winner(french, english, spanish) {
2392
+ if (french >= spanish && french > english) {
2393
+ return "fr";
2394
+ }
2395
+ if (spanish > english) {
2396
+ return "es";
2397
+ }
2398
+ return "en";
1906
2399
  }
1907
2400
 
1908
2401
  // src/recipe/units.ts
@@ -1912,10 +2405,14 @@ var EAUX_ENDING = /eaux$/i;
1912
2405
  var EAU_ENDING = /eau$/i;
1913
2406
  var FUL_MEASURE = /^[a-z]{3,}fuls?$/;
1914
2407
  var HEAD_BEFORE_DE = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u");
1915
- var HOUSEHOLD_MEASURE = /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse)$/;
2408
+ var HOUSEHOLD_MEASURE = /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse|taza|cucharada|cucharadita|cda|cdta)$/;
1916
2409
  var SIBILANT_LETTER = /[sxz]$/i;
1917
2410
  var TRAILING_S_ANY_CASE = /s$/i;
1918
2411
  var VOWEL_THEN_S = /[aiou]s$/i;
2412
+ var INVARIABLE_SPANISH = /(?:is|us)$/i;
2413
+ var ES_ENDING = /(?:[^aeiouáéíóú]es)$/i;
2414
+ var SPANISH_VOWEL_ENDING = /[aeiouáéíóú]$/i;
2415
+ var Z_ENDING = /z$/i;
1919
2416
  var SHARED_UNITS = {
1920
2417
  mg: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
1921
2418
  g: { canonical: "g", kind: "measured", system: "metric", symbol: true },
@@ -2197,9 +2694,102 @@ var ENGLISH_UNITS = {
2197
2694
  knob: { canonical: "knob", kind: "approximate", system: "none" },
2198
2695
  knobs: { canonical: "knob", kind: "approximate", system: "none" }
2199
2696
  };
2697
+ var SPANISH_UNITS = {
2698
+ ...SHARED_UNITS,
2699
+ gramo: { canonical: "g", kind: "measured", system: "metric", symbol: true },
2700
+ gramos: { canonical: "g", kind: "measured", system: "metric", symbol: true },
2701
+ kilogramo: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
2702
+ kilogramos: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
2703
+ litro: { canonical: "l", kind: "measured", system: "metric", symbol: true },
2704
+ litros: { canonical: "l", kind: "measured", system: "metric", symbol: true },
2705
+ mililitro: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
2706
+ mililitros: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
2707
+ decilitro: { canonical: "dl", kind: "measured", system: "metric", symbol: true },
2708
+ decilitros: { canonical: "dl", kind: "measured", system: "metric", symbol: true },
2709
+ centilitro: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
2710
+ centilitros: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
2711
+ // A page glossing a metric weight names the pound and the ounce, which
2712
+ // Spanish writes in full.
2713
+ libra: { canonical: "libra", kind: "measured", system: "imperial", plural: "libras" },
2714
+ libras: { canonical: "libra", kind: "measured", system: "imperial", plural: "libras" },
2715
+ onza: { canonical: "onza", kind: "measured", system: "imperial", plural: "onzas" },
2716
+ onzas: { canonical: "onza", kind: "measured", system: "imperial", plural: "onzas" },
2717
+ // Spoons and cups: real measures, but only in sensible fractions.
2718
+ cucharada: { canonical: "cucharada", kind: "portioned", system: "none", plural: "cucharadas" },
2719
+ cucharadas: { canonical: "cucharada", kind: "portioned", system: "none", plural: "cucharadas" },
2720
+ cda: { canonical: "cda", kind: "portioned", system: "none", symbol: true },
2721
+ cdas: { canonical: "cda", kind: "portioned", system: "none", symbol: true },
2722
+ "c s": { canonical: "cucharada", kind: "portioned", system: "none", plural: "cucharadas" },
2723
+ cucharadita: {
2724
+ canonical: "cucharadita",
2725
+ kind: "portioned",
2726
+ system: "none",
2727
+ plural: "cucharaditas"
2728
+ },
2729
+ cucharaditas: {
2730
+ canonical: "cucharadita",
2731
+ kind: "portioned",
2732
+ system: "none",
2733
+ plural: "cucharaditas"
2734
+ },
2735
+ cdta: { canonical: "cdta", kind: "portioned", system: "none", symbol: true },
2736
+ cdtas: { canonical: "cdta", kind: "portioned", system: "none", symbol: true },
2737
+ "c p": { canonical: "cucharadita", kind: "portioned", system: "none", plural: "cucharaditas" },
2738
+ taza: { canonical: "taza", kind: "portioned", system: "none", plural: "tazas" },
2739
+ tazas: { canonical: "taza", kind: "portioned", system: "none", plural: "tazas" },
2740
+ vaso: { canonical: "vaso", kind: "portioned", system: "none" },
2741
+ vasos: { canonical: "vaso", kind: "portioned", system: "none" },
2742
+ // Packaging and natural units: countable, so they round to whole things.
2743
+ sobre: { canonical: "sobre", kind: "portioned", system: "none" },
2744
+ sobres: { canonical: "sobre", kind: "portioned", system: "none" },
2745
+ diente: { canonical: "diente", kind: "portioned", system: "none" },
2746
+ dientes: { canonical: "diente", kind: "portioned", system: "none" },
2747
+ lata: { canonical: "lata", kind: "portioned", system: "none", plural: "latas" },
2748
+ latas: { canonical: "lata", kind: "portioned", system: "none", plural: "latas" },
2749
+ bote: { canonical: "bote", kind: "portioned", system: "none" },
2750
+ botes: { canonical: "bote", kind: "portioned", system: "none" },
2751
+ tarro: { canonical: "tarro", kind: "portioned", system: "none" },
2752
+ tarros: { canonical: "tarro", kind: "portioned", system: "none" },
2753
+ paquete: { canonical: "paquete", kind: "portioned", system: "none" },
2754
+ paquetes: { canonical: "paquete", kind: "portioned", system: "none" },
2755
+ loncha: { canonical: "loncha", kind: "portioned", system: "none", plural: "lonchas" },
2756
+ lonchas: { canonical: "loncha", kind: "portioned", system: "none", plural: "lonchas" },
2757
+ rebanada: { canonical: "rebanada", kind: "portioned", system: "none", plural: "rebanadas" },
2758
+ rebanadas: { canonical: "rebanada", kind: "portioned", system: "none", plural: "rebanadas" },
2759
+ rodaja: { canonical: "rodaja", kind: "portioned", system: "none", plural: "rodajas" },
2760
+ rodajas: { canonical: "rodaja", kind: "portioned", system: "none", plural: "rodajas" },
2761
+ hoja: { canonical: "hoja", kind: "portioned", system: "none", plural: "hojas" },
2762
+ hojas: { canonical: "hoja", kind: "portioned", system: "none", plural: "hojas" },
2763
+ rama: { canonical: "rama", kind: "portioned", system: "none", plural: "ramas" },
2764
+ ramas: { canonical: "rama", kind: "portioned", system: "none", plural: "ramas" },
2765
+ ramita: { canonical: "ramita", kind: "portioned", system: "none", plural: "ramitas" },
2766
+ ramitas: { canonical: "ramita", kind: "portioned", system: "none", plural: "ramitas" },
2767
+ manojo: { canonical: "manojo", kind: "portioned", system: "none" },
2768
+ manojos: { canonical: "manojo", kind: "portioned", system: "none" },
2769
+ unidad: { canonical: "unidad", kind: "portioned", system: "none", plural: "unidades" },
2770
+ unidades: { canonical: "unidad", kind: "portioned", system: "none", plural: "unidades" },
2771
+ // Held to no better than a hand: the count scales, the size of one does not.
2772
+ pizca: { canonical: "pizca", kind: "approximate", system: "none", plural: "pizcas" },
2773
+ pizcas: { canonical: "pizca", kind: "approximate", system: "none", plural: "pizcas" },
2774
+ punado: { canonical: "pu\xF1ado", kind: "approximate", system: "none", plural: "pu\xF1ados" },
2775
+ punados: { canonical: "pu\xF1ado", kind: "approximate", system: "none", plural: "pu\xF1ados" },
2776
+ chorro: { canonical: "chorro", kind: "approximate", system: "none" },
2777
+ chorros: { canonical: "chorro", kind: "approximate", system: "none" },
2778
+ chorrito: { canonical: "chorrito", kind: "approximate", system: "none" },
2779
+ chorritos: { canonical: "chorrito", kind: "approximate", system: "none" },
2780
+ gota: { canonical: "gota", kind: "approximate", system: "none", plural: "gotas" },
2781
+ gotas: { canonical: "gota", kind: "approximate", system: "none", plural: "gotas" },
2782
+ cazo: { canonical: "cazo", kind: "approximate", system: "none" },
2783
+ cazos: { canonical: "cazo", kind: "approximate", system: "none" },
2784
+ cucharon: { canonical: "cuchar\xF3n", kind: "approximate", system: "none", plural: "cucharones" },
2785
+ cucharones: { canonical: "cuchar\xF3n", kind: "approximate", system: "none", plural: "cucharones" },
2786
+ toque: { canonical: "toque", kind: "approximate", system: "none" },
2787
+ toques: { canonical: "toque", kind: "approximate", system: "none" }
2788
+ };
2200
2789
  var VOCABULARY2 = {
2201
2790
  fr: FRENCH_UNITS,
2202
- en: ENGLISH_UNITS
2791
+ en: ENGLISH_UNITS,
2792
+ es: SPANISH_UNITS
2203
2793
  };
2204
2794
  function normalizeUnitKey(text2) {
2205
2795
  return text2.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\./g, " ").replace(/\((?:s|x|es)\)/g, "").replace(/\s+/g, " ").trim();
@@ -2211,7 +2801,8 @@ function orderedKeys(vocabulary) {
2211
2801
  }
2212
2802
  var UNIT_KEYS = {
2213
2803
  fr: orderedKeys(FRENCH_UNITS),
2214
- en: orderedKeys(ENGLISH_UNITS)
2804
+ en: orderedKeys(ENGLISH_UNITS),
2805
+ es: orderedKeys(SPANISH_UNITS)
2215
2806
  };
2216
2807
  function unitKeys(language) {
2217
2808
  return UNIT_KEYS[language];
@@ -2221,7 +2812,8 @@ function lookupUnit(text2, language) {
2221
2812
  }
2222
2813
  var EMBEDDED = {
2223
2814
  fr: buildEmbedded("fr"),
2224
- en: buildEmbedded("en")
2815
+ en: buildEmbedded("en"),
2816
+ es: buildEmbedded("es")
2225
2817
  };
2226
2818
  function buildEmbedded(language) {
2227
2819
  const keys = UNIT_KEYS[language].map((key) => key.replace(/ /g, "\\s+"));
@@ -2248,7 +2840,7 @@ var NOT_A_MEASURE = /* @__PURE__ */ new Set([
2248
2840
  "melange",
2249
2841
  "ensemble"
2250
2842
  ]);
2251
- function readPartitiveMeasure(text2) {
2843
+ function readPartitiveMeasure(text2, language) {
2252
2844
  const match = HEAD_BEFORE_DE.exec(text2);
2253
2845
  if (!match) {
2254
2846
  return null;
@@ -2260,16 +2852,16 @@ function readPartitiveMeasure(text2) {
2260
2852
  if (NOT_A_MEASURE.has(normalizeUnitKey(word))) {
2261
2853
  return null;
2262
2854
  }
2263
- if (lookupUnit(word, "fr")) {
2855
+ if (lookupUnit(word, language)) {
2264
2856
  return null;
2265
2857
  }
2266
- const canonical = frenchSingular(word);
2858
+ const canonical = language === "es" ? spanishSingular(word) : frenchSingular(word);
2267
2859
  return {
2268
2860
  unit: {
2269
2861
  canonical,
2270
2862
  kind: "approximate",
2271
2863
  system: "none",
2272
- plural: frenchPlural(canonical)
2864
+ plural: language === "es" ? spanishPlural(canonical) : frenchPlural(canonical)
2273
2865
  },
2274
2866
  rest: text2.slice(match[0].length)
2275
2867
  };
@@ -2297,6 +2889,30 @@ function frenchSingular(word) {
2297
2889
  }
2298
2890
  return word;
2299
2891
  }
2892
+ function spanishSingular(word) {
2893
+ if (INVARIABLE_SPANISH.test(word)) {
2894
+ return word;
2895
+ }
2896
+ if (ES_ENDING.test(word) && word.length > 4) {
2897
+ return word.slice(0, -2);
2898
+ }
2899
+ if (TRAILING_S_ANY_CASE.test(word) && word.length > 3) {
2900
+ return word.slice(0, -1);
2901
+ }
2902
+ return word;
2903
+ }
2904
+ function spanishPlural(word) {
2905
+ if (INVARIABLE_SPANISH.test(word)) {
2906
+ return word;
2907
+ }
2908
+ if (SPANISH_VOWEL_ENDING.test(word)) {
2909
+ return `${word}s`;
2910
+ }
2911
+ if (Z_ENDING.test(word)) {
2912
+ return `${word.slice(0, -1)}ces`;
2913
+ }
2914
+ return `${word}es`;
2915
+ }
2300
2916
  function frenchPlural(word) {
2301
2917
  if (SIBILANT_LETTER.test(word)) {
2302
2918
  return word;
@@ -2326,7 +2942,13 @@ var APPROXIMATE_EQUIVALENT = {
2326
2942
  filet: "commonly taken as about a teaspoon poured in a thin line",
2327
2943
  noix: "commonly taken as about a tablespoon of butter",
2328
2944
  louche: "commonly taken as about half a cup",
2329
- soupcon: "commonly taken as the smallest amount a spoon tip carries"
2945
+ soupcon: "commonly taken as the smallest amount a spoon tip carries",
2946
+ pizca: "commonly taken as about half a teaspoon",
2947
+ punado: "commonly taken as about a quarter of a cup",
2948
+ chorrito: "commonly taken as about a teaspoon poured in a thin line",
2949
+ chorro: "commonly taken as about a tablespoon poured free-hand",
2950
+ gota: "commonly taken as a single drop",
2951
+ cucharon: "commonly taken as about half a cup"
2330
2952
  };
2331
2953
  function approximateEquivalent(unit) {
2332
2954
  return APPROXIMATE_EQUIVALENT[normalizeUnitKey(unit.canonical)] ?? null;
@@ -2354,6 +2976,9 @@ var DEMOTIONS = {
2354
2976
  tbsp: { to: "tsp", per: 3, language: "en" },
2355
2977
  "cuillere a soupe": { to: "cuillere a cafe", per: 3, language: "fr" },
2356
2978
  tasse: { to: "cuillere a soupe", per: 16, language: "fr" },
2979
+ cucharada: { to: "cucharadita", per: 3, language: "es" },
2980
+ cda: { to: "cdta", per: 3, language: "es" },
2981
+ taza: { to: "cucharada", per: 16, language: "es" },
2357
2982
  kg: { to: "g", per: 1e3, language: "en" },
2358
2983
  g: { to: "mg", per: 1e3, language: "en" },
2359
2984
  l: { to: "cl", per: 100, language: "en" },
@@ -2382,7 +3007,7 @@ function unitDivisibility(unit) {
2382
3007
  }
2383
3008
  return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
2384
3009
  }
2385
- var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|tranches?|slices?)\b/i;
3010
+ var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|tranches?|slices?|botes?|tarros?|botellas?|bloques?|lonchas?|rebanadas?|rodajas?)\b/i;
2386
3011
  function countsBarePieces(unit) {
2387
3012
  return unit.canonical === "ea";
2388
3013
  }
@@ -2429,17 +3054,22 @@ function formatUnit(unit, amount, language) {
2429
3054
  if (singular) {
2430
3055
  return unit.canonical;
2431
3056
  }
2432
- return unit.plural ?? `${unit.canonical}s`;
3057
+ if (unit.plural) {
3058
+ return unit.plural;
3059
+ }
3060
+ return language === "es" ? spanishPlural(unit.canonical) : `${unit.canonical}s`;
2433
3061
  }
2434
3062
 
2435
3063
  // src/recipe/quantity.ts
2436
3064
  var BRACKETED_TAIL = /\s*(\([^()]*\))\s*$/;
2437
- var LEADING_ARTICLE = /^(?:of\s+)?an?\s+/i;
3065
+ var LEADING_ARTICLE2 = /^(?:of\s+)?an?\s+/i;
2438
3066
  var LEADING_ARTICLE_FRENCH = /^(?:une|un)\s+/i;
2439
3067
  var LEADING_INDEFINITE_WITH_SPACE = /^an?\s+/i;
2440
3068
  var LEADING_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i;
2441
3069
  var OF_OPENING = /^of\s+/i;
2442
3070
  var OPENING_ARTICLE_FRENCH = /^\s*(un|une)\b\s*/i;
3071
+ var OPENING_ARTICLE_SPANISH = /^\s*(un|una)\b\s*/i;
3072
+ var LEADING_ARTICLE_SPANISH = /^(?:una|un)\s+/i;
2443
3073
  var REPEATED_COMMA_GROUPS = /^\s*\d+,\d+,\d/;
2444
3074
  var SLASH_OUTSIDE_A_FRACTION = /(?<!\d)\/|\/(?!\d)/;
2445
3075
  var ATTACHED_DE = /\s+de\s+(?=\d)/i;
@@ -2448,19 +3078,47 @@ var DIGIT = /\d/;
2448
3078
  var ENGLISH_RANGE_JOINER = /^\s+(to|or)\s+/i;
2449
3079
  var FRACTION_WORD = /^(?:(a|an|one|two|three)[\s-]+)?(halves|half|thirds|third|quarters|quarter|fourths|fourth)\b/i;
2450
3080
  var FRENCH_RANGE_JOINER = /^\s+(à|a|ou)\s+/i;
3081
+ var SPANISH_RANGE_JOINER = /^\s+(a|o)\s+/i;
3082
+ var SPANISH_FRACTION_WORD = /^(?:medi[oa]s?\b|(?:(un|una|dos|tres)\s+)?(cuartos|cuarto|tercios|tercio|mitad(?:es)?)\b)/i;
2451
3083
  var GROUPED_DECIMAL = /^(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/;
3084
+ function writesDecimalComma(language) {
3085
+ return language !== "en";
3086
+ }
3087
+ function usesPartitiveDe(language) {
3088
+ return language !== "en";
3089
+ }
3090
+ var OPENING_ARTICLE_ROMANCE = {
3091
+ en: OPENING_ARTICLE_FRENCH,
3092
+ fr: OPENING_ARTICLE_FRENCH,
3093
+ es: OPENING_ARTICLE_SPANISH
3094
+ };
3095
+ var ROMANCE_ARTICLE = {
3096
+ en: LEADING_ARTICLE_FRENCH,
3097
+ fr: LEADING_ARTICLE_FRENCH,
3098
+ es: LEADING_ARTICLE_SPANISH
3099
+ };
3100
+ var RANGE_JOINER = {
3101
+ en: ENGLISH_RANGE_JOINER,
3102
+ fr: FRENCH_RANGE_JOINER,
3103
+ es: SPANISH_RANGE_JOINER
3104
+ };
3105
+ var OTHER_LANGUAGES = {
3106
+ en: ["fr", "es"],
3107
+ fr: ["es", "en"],
3108
+ es: ["fr", "en"]
3109
+ };
2452
3110
  var HYPHENATED_TAIL = new RegExp("^-\\p{L}", "u");
2453
3111
  var LEADING_DASH = /^\s*(–|—|-)\s*/;
2454
3112
  var LEADING_INDEFINITE = /^an?\s/i;
2455
3113
  var LEADING_OF = /^\s*of\s+/i;
2456
- var LEADING_QUANTITY = /^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u;
3114
+ var LEADING_QUANTITY2 = /^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u;
2457
3115
  var LEADING_WORD = new RegExp("^\\s*(\\p{L}+)\\s+", "u");
2458
3116
  var MIXED_FRACTION = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/;
2459
3117
  var PARTITIVE_BEFORE_BRACKET = /^(?:de\s+|du\s+|des\s+|d'|of\s+)(?=\()/i;
2460
3118
  var PARTITIVE_OPENING = /^(?:de\s|d'|du\s|des\s|,)/i;
2461
3119
  var SIMPLE_FRACTION = /^(\d+)\s*\/\s*(\d+)/;
2462
3120
  var TRAILING_S = /s$/;
2463
- var VAGUE_ARTICLE = /^(?:un|une|quelques|of\s+an?|an?)\s+/iu;
3121
+ var VAGUE_ARTICLE = /^(?:une|unas|unos|una|un|quelques|of\s+an?|an?)\s+/iu;
2464
3122
  var WHITESPACE = /\s+/;
2465
3123
  function leadingMeasure(direct, behind) {
2466
3124
  if (direct.unit) {
@@ -2543,10 +3201,11 @@ function readable(text2) {
2543
3201
  }
2544
3202
  var APPROXIMATION_PREFIX = {
2545
3203
  en: /^(?:~|\u2248|about|approx\.?|approximately|around|roughly)\s*/i,
2546
- fr: /^(?:~|\u2248|environ|approximativement|\u00e0 peu pr\u00e8s|a peu pres)\s*/i
3204
+ fr: /^(?:~|\u2248|environ|approximativement|\u00e0 peu pr\u00e8s|a peu pres)\s*/i,
3205
+ es: /^(?:~|≈|unos|unas|aproximadamente|alrededor de|más o menos|mas o menos|como)\s*/i
2547
3206
  };
2548
3207
  var CONTAINER_CONTENTS = /^\s*(?:de\s|d'|du\s|des\s)/i;
2549
- function statesItemSize(item) {
3208
+ function statesItemSize(item, language) {
2550
3209
  const attached = ATTACHED_DE.exec(item);
2551
3210
  if (!attached) {
2552
3211
  return false;
@@ -2555,14 +3214,14 @@ function statesItemSize(item) {
2555
3214
  if (!named || DIGIT.test(named)) {
2556
3215
  return false;
2557
3216
  }
2558
- return isStatedSize(item.slice(attached.index + attached[0].length));
3217
+ return isStatedSize(item.slice(attached.index + attached[0].length), language);
2559
3218
  }
2560
- function isStatedSize(text2) {
2561
- const size = parseLeadingQuantity(text2, "fr");
3219
+ function isStatedSize(text2, language) {
3220
+ const size = parseLeadingQuantity(text2, language);
2562
3221
  if (!size) {
2563
3222
  return false;
2564
3223
  }
2565
- const measure = takeUnit(text2.slice(size.length).trimStart(), "fr");
3224
+ const measure = takeUnit(text2.slice(size.length).trimStart(), language);
2566
3225
  if (measure.unit?.kind !== "measured") {
2567
3226
  return false;
2568
3227
  }
@@ -2613,12 +3272,20 @@ function namesContainer(word) {
2613
3272
  }
2614
3273
  var TIME_UNIT = {
2615
3274
  en: /^(?:h|hr|hrs|hours?|mins?|minutes?|secs?|seconds?|days?|nights?|weeks?)\b/i,
2616
- fr: /^(?:h|mn|heures?|mins?|minutes?|secs?|secondes?|jours?|nuits?|semaines?)\b/i
3275
+ fr: /^(?:h|mn|heures?|mins?|minutes?|secs?|secondes?|jours?|nuits?|semaines?)\b/i,
3276
+ es: /^(?:h|hs|horas?|mins?|minutos?|segs?|segundos?|días?|dias?|noches?|semanas?)\b/i
2617
3277
  };
3278
+ function statesAnAmountPerEater(text2) {
3279
+ return Object.values(PER_PERSON).some((pattern) => pattern.test(text2));
3280
+ }
3281
+ function namesALengthOfTime(text2) {
3282
+ return Object.values(TIME_UNIT).some((pattern) => pattern.test(text2));
3283
+ }
2618
3284
  var ORDINAL_SUFFIX = /^(?:ers?|[eè]res?|[eè]mes?|es?|st|nd|rd|th)\b/i;
2619
3285
  var PER_PERSON = {
2620
3286
  en: /\bper\s+(?:person|head|serving|guest|diner)\b/i,
2621
- fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i
3287
+ fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i,
3288
+ es: /\bpor\s+(?:persona|comensal|ración|racion)\b/i
2622
3289
  };
2623
3290
  var MEASURE_ADJECTIVES = {
2624
3291
  en: /* @__PURE__ */ new Set([
@@ -2644,6 +3311,24 @@ var MEASURE_ADJECTIVES = {
2644
3311
  "grosse",
2645
3312
  "petit",
2646
3313
  "petite"
3314
+ ]),
3315
+ es: /* @__PURE__ */ new Set([
3316
+ "buen",
3317
+ "buena",
3318
+ "colmada",
3319
+ "colmado",
3320
+ "escasa",
3321
+ "escaso",
3322
+ "generosa",
3323
+ "generoso",
3324
+ "gran",
3325
+ "grande",
3326
+ "mediana",
3327
+ "mediano",
3328
+ "peque\xF1a",
3329
+ "peque\xF1o",
3330
+ "rasa",
3331
+ "raso"
2647
3332
  ])
2648
3333
  };
2649
3334
  function takeMeasureAdjective(text2, language) {
@@ -2692,25 +3377,33 @@ function parseLeadingQuantity(text2, language) {
2692
3377
  if (glyph && glyph in VULGAR_FRACTIONS) {
2693
3378
  return { amount: VULGAR_FRACTIONS[glyph] ?? Number.NaN, length: offset + 1 };
2694
3379
  }
2695
- const decimal = (language === "fr" ? DECIMAL : GROUPED_DECIMAL).exec(trimmed);
3380
+ const decimal = (writesDecimalComma(language) ? DECIMAL : GROUPED_DECIMAL).exec(trimmed);
2696
3381
  if (decimal) {
2697
- const [written = ""] = decimal.slice(1);
3382
+ const [written2 = ""] = decimal.slice(1);
2698
3383
  const amount = Number(
2699
- language === "fr" ? written.replace(",", ".") : written.replace(/,/g, "")
3384
+ writesDecimalComma(language) ? written2.replace(",", ".") : written2.replace(/,/g, "")
2700
3385
  );
2701
3386
  if (Number.isFinite(amount)) {
2702
3387
  return { amount, length: offset + decimal[0].length };
2703
3388
  }
2704
3389
  }
2705
- if (language === "en") {
2706
- const written = parseWrittenFraction(trimmed);
2707
- if (written) {
2708
- return { amount: written.amount, length: offset + written.length };
2709
- }
3390
+ const written = language === "es" ? parseSpanishFraction(trimmed) : parseWrittenFraction(trimmed, language);
3391
+ if (written) {
3392
+ return { amount: written.amount, length: offset + written.length };
2710
3393
  }
2711
3394
  return null;
2712
3395
  }
2713
- var WRITTEN_NUMERATORS = { a: 1, an: 1, one: 1, two: 2, three: 3 };
3396
+ var WRITTEN_NUMERATORS = {
3397
+ a: 1,
3398
+ an: 1,
3399
+ one: 1,
3400
+ two: 2,
3401
+ three: 3,
3402
+ un: 1,
3403
+ una: 1,
3404
+ dos: 2,
3405
+ tres: 3
3406
+ };
2714
3407
  var WRITTEN_DENOMINATORS = {
2715
3408
  half: 2,
2716
3409
  halves: 2,
@@ -2719,9 +3412,18 @@ var WRITTEN_DENOMINATORS = {
2719
3412
  quarter: 4,
2720
3413
  quarters: 4,
2721
3414
  fourth: 4,
2722
- fourths: 4
3415
+ fourths: 4,
3416
+ cuarto: 4,
3417
+ cuartos: 4,
3418
+ tercio: 3,
3419
+ tercios: 3,
3420
+ mitad: 2,
3421
+ mitades: 2
2723
3422
  };
2724
- function parseWrittenFraction(text2) {
3423
+ function parseWrittenFraction(text2, language) {
3424
+ if (language !== "en") {
3425
+ return null;
3426
+ }
2725
3427
  const match = FRACTION_WORD.exec(text2);
2726
3428
  if (!match) {
2727
3429
  return null;
@@ -2737,13 +3439,29 @@ function parseWrittenFraction(text2) {
2737
3439
  }
2738
3440
  return { amount: numerator / denominator, length: match[0].length };
2739
3441
  }
3442
+ function parseSpanishFraction(text2) {
3443
+ const match = SPANISH_FRACTION_WORD.exec(text2);
3444
+ if (!match) {
3445
+ return null;
3446
+ }
3447
+ const numerator = match[1] ? WRITTEN_NUMERATORS[match[1].toLowerCase()] : 1;
3448
+ const denominator = match[2] ? WRITTEN_DENOMINATORS[match[2].toLowerCase()] : 2;
3449
+ if (!(numerator && denominator)) {
3450
+ return null;
3451
+ }
3452
+ const rest = text2.slice(match[0].length).replace(LEADING_PARTITIVE, "").trimStart();
3453
+ if (rest === "") {
3454
+ return null;
3455
+ }
3456
+ return { amount: numerator / denominator, length: match[0].length };
3457
+ }
2740
3458
  function parseLeadingRange(text2, language) {
2741
3459
  const low = parseLeadingQuantity(text2, language);
2742
3460
  if (!low) {
2743
3461
  return null;
2744
3462
  }
2745
3463
  const after = text2.slice(low.length);
2746
- const written = language === "fr" ? FRENCH_RANGE_JOINER : ENGLISH_RANGE_JOINER;
3464
+ const written = RANGE_JOINER[language];
2747
3465
  const separator = written.exec(after) ?? LEADING_DASH.exec(after);
2748
3466
  if (!separator) {
2749
3467
  return null;
@@ -2759,7 +3477,7 @@ function parseLeadingRange(text2, language) {
2759
3477
  length: low.length + separator[0].length + high.length
2760
3478
  };
2761
3479
  }
2762
- var FRENCH_ARTICLES = { un: 1, une: 1 };
3480
+ var ROMANCE_ARTICLES = { un: 1, une: 1, una: 1 };
2763
3481
  function takeUnit(text2, language) {
2764
3482
  const normalized = normalizeUnitKey(text2);
2765
3483
  for (const key of unitKeys(language)) {
@@ -2795,35 +3513,57 @@ function afterKey(text2, key) {
2795
3513
  }
2796
3514
  return null;
2797
3515
  }
2798
- function takeUnitEitherLanguage(text2, language) {
3516
+ function takeUnitAnyLanguage(text2, language) {
2799
3517
  const first = takeUnit(text2, language);
2800
3518
  if (first.unit) {
2801
3519
  return first;
2802
3520
  }
2803
- return takeUnit(text2, language === "fr" ? "en" : "fr");
3521
+ for (const other of OTHER_LANGUAGES[language]) {
3522
+ const taken = takeUnit(text2, other);
3523
+ if (taken.unit) {
3524
+ return taken;
3525
+ }
3526
+ }
3527
+ return first;
3528
+ }
3529
+ function detectLanguage(line) {
3530
+ return readEvidence(line).language;
2804
3531
  }
2805
3532
  function readEvidence(line) {
2806
3533
  const text2 = line.trim();
2807
- const afterFigures = text2.replace(LEADING_QUANTITY, "").trimStart();
3534
+ const afterFigures = text2.replace(LEADING_QUANTITY2, "").trimStart();
2808
3535
  const afterArticle = afterFigures.replace(VAGUE_ARTICLE, "");
2809
3536
  const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
2810
3537
  const englishUnit = takeUnit(afterArticle, "en").unit !== null;
2811
- return readLanguage(text2, { frenchUnit, englishUnit });
3538
+ const spanishUnit = takeUnit(afterArticle, "es").unit !== null;
3539
+ return readLanguage(text2, { frenchUnit, englishUnit, spanishUnit });
2812
3540
  }
2813
3541
  function readMeasure(rest, language, fromArticle) {
2814
3542
  const direct = takeLeadingUnit(rest, language, fromArticle);
2815
- const described = direct.unit ? { adjective: null, rest } : takeMeasureAdjective(rest, language);
3543
+ if (direct.unit) {
3544
+ const following = language === "es" ? takeMeasureAdjective(direct.rest, language) : null;
3545
+ if (following?.adjective) {
3546
+ return {
3547
+ leading: { ...direct, rest: following.rest },
3548
+ described: { adjective: following.adjective, rest: direct.rest },
3549
+ behind: direct,
3550
+ follows: true
3551
+ };
3552
+ }
3553
+ return { leading: direct, described: { adjective: null, rest }, behind: null, follows: false };
3554
+ }
3555
+ const described = takeMeasureAdjective(rest, language);
2816
3556
  const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
2817
- return { leading: leadingMeasure(direct, behind), described, behind };
3557
+ return { leading: leadingMeasure(direct, behind), described, behind, follows: false };
2818
3558
  }
2819
- function whatTheFigureIsNot(behindFigure, couldBeARank, language) {
3559
+ function whatTheFigureIsNot(behindFigure, couldBeARank) {
2820
3560
  if (HYPHENATED_TAIL.test(behindFigure)) {
2821
3561
  return "sizeQualifier";
2822
3562
  }
2823
3563
  if (couldBeARank && ORDINAL_SUFFIX.test(behindFigure)) {
2824
3564
  return null;
2825
3565
  }
2826
- if (TIME_UNIT[language].test(behindFigure.trimStart())) {
3566
+ if (namesALengthOfTime(behindFigure.trimStart())) {
2827
3567
  return "duration";
2828
3568
  }
2829
3569
  return false;
@@ -2839,19 +3579,20 @@ function takeEquivalents(text2, language) {
2839
3579
  if (slashed) {
2840
3580
  rest = slashed.rest;
2841
3581
  }
2842
- let item = stripItemLead(rest, language);
3582
+ const lead = stripItemLead(rest, language);
3583
+ let item = lead.item;
2843
3584
  const trailing = bracketed.measures.length === 0 && !slashed ? takeTrailingAlternates(item, language) : null;
2844
3585
  if (trailing) {
2845
3586
  item = trailing.item;
2846
3587
  }
2847
3588
  const group = trailing ?? (bracketed.measures.length > 0 ? bracketed : null);
2848
- return { rest, item, bracketed, slashed, trailing, group };
3589
+ return { rest, item, partitive: lead.partitive, bracketed, slashed, trailing, group };
2849
3590
  }
2850
3591
  function whatTheFiguresSize(leading, item, language) {
2851
- if (language === "fr" && !leading.unit && statesItemSize(item)) {
3592
+ if (usesPartitiveDe(language) && !leading.unit && statesItemSize(item, language)) {
2852
3593
  return "itemSize";
2853
3594
  }
2854
- if (language === "fr" && leading.partitive && isStatedSize(item)) {
3595
+ if (usesPartitiveDe(language) && leading.partitive && isStatedSize(item, language)) {
2855
3596
  return "itemSize";
2856
3597
  }
2857
3598
  if (leading.unit?.kind === "measured" && CONTAINER_NOUN.test(normalizeUnitKey(item))) {
@@ -2862,7 +3603,7 @@ function whatTheFiguresSize(leading, item, language) {
2862
3603
  function whatTheCountIsOf(behindFigure, language) {
2863
3604
  let rest = behindFigure.trimStart();
2864
3605
  if (language === "en") {
2865
- rest = rest.replace(LEADING_ARTICLE, "");
3606
+ rest = rest.replace(LEADING_ARTICLE2, "");
2866
3607
  }
2867
3608
  const multiplier = readCountMultiplier(rest);
2868
3609
  if (multiplier) {
@@ -2893,6 +3634,8 @@ function parseIngredient(line, choice = "auto") {
2893
3634
  decoration,
2894
3635
  approximation: null,
2895
3636
  measureAdjective: null,
3637
+ measureAdjectiveFollows: false,
3638
+ itemPartitive: false,
2896
3639
  amount: null,
2897
3640
  amountMax: null,
2898
3641
  rangeSeparator: null,
@@ -2916,11 +3659,7 @@ function parseIngredient(line, choice = "auto") {
2916
3659
  if (!quantity) {
2917
3660
  return empty(null);
2918
3661
  }
2919
- const notAnAmount = whatTheFigureIsNot(
2920
- stated.slice(quantity.length),
2921
- quantity !== article,
2922
- language
2923
- );
3662
+ const notAnAmount = whatTheFigureIsNot(stated.slice(quantity.length), quantity !== article);
2924
3663
  if (notAnAmount !== false) {
2925
3664
  return empty(notAnAmount);
2926
3665
  }
@@ -2950,10 +3689,12 @@ function parseIngredient(line, choice = "auto") {
2950
3689
  return {
2951
3690
  original,
2952
3691
  language,
2953
- heldBack: PER_PERSON[language].test(text2) ? "perPerson" : null,
3692
+ heldBack: statesAnAmountPerEater(text2) ? "perPerson" : null,
2954
3693
  decoration,
2955
3694
  approximation: loose ? loose[0] : null,
2956
3695
  measureAdjective: behind?.unit ? described.adjective : null,
3696
+ measureAdjectiveFollows: measure.follows,
3697
+ itemPartitive: equivalents.partitive,
2957
3698
  amount: whole * times,
2958
3699
  amountMax: range === null ? null : range.max * times,
2959
3700
  rangeSeparator: range?.separator ?? null,
@@ -2969,7 +3710,9 @@ function parseIngredient(line, choice = "auto") {
2969
3710
  }
2970
3711
  function unreadableComma(text2, language, evidence) {
2971
3712
  if (COMMA_GROUPED.test(text2)) {
2972
- const settled = evidence.french !== evidence.english;
3713
+ const scores = [evidence.french, evidence.english, evidence.spanish];
3714
+ const top = Math.max(...scores);
3715
+ const settled = scores.filter((score) => score === top).length === 1;
2973
3716
  return !settled;
2974
3717
  }
2975
3718
  if (language === "en") {
@@ -2979,8 +3722,8 @@ function unreadableComma(text2, language, evidence) {
2979
3722
  }
2980
3723
  function readArticle(text2, language) {
2981
3724
  const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
2982
- if (language === "fr") {
2983
- const match = OPENING_ARTICLE_FRENCH.exec(text2);
3725
+ if (usesPartitiveDe(language)) {
3726
+ const match = OPENING_ARTICLE_ROMANCE[language].exec(text2);
2984
3727
  if (!match) {
2985
3728
  return null;
2986
3729
  }
@@ -2988,7 +3731,7 @@ function readArticle(text2, language) {
2988
3731
  return null;
2989
3732
  }
2990
3733
  const [word = ""] = match.slice(1);
2991
- const amount = FRENCH_ARTICLES[word.toLowerCase()];
3734
+ const amount = ROMANCE_ARTICLES[word.toLowerCase()];
2992
3735
  if (amount === void 0) {
2993
3736
  return null;
2994
3737
  }
@@ -3026,8 +3769,8 @@ function takeLeadingUnit(text2, language, fromArticle) {
3026
3769
  if (taken.unit) {
3027
3770
  return { ...taken, partitive: false };
3028
3771
  }
3029
- if (language === "fr" && fromArticle) {
3030
- const measure = readPartitiveMeasure(text2);
3772
+ if (usesPartitiveDe(language) && fromArticle) {
3773
+ const measure = readPartitiveMeasure(text2, language);
3031
3774
  if (measure) {
3032
3775
  return { unit: measure.unit, rest: measure.rest, partitive: true };
3033
3776
  }
@@ -3035,10 +3778,17 @@ function takeLeadingUnit(text2, language, fromArticle) {
3035
3778
  return { ...taken, partitive: false };
3036
3779
  }
3037
3780
  function stripItemLead(text2, language) {
3038
- if (language === "fr") {
3039
- return text2.replace(LEADING_PARTITIVE, "").replace(LEADING_ARTICLE_FRENCH, "").trim();
3781
+ if (usesPartitiveDe(language)) {
3782
+ const partitive = LEADING_PARTITIVE.test(text2);
3783
+ return {
3784
+ item: text2.replace(LEADING_PARTITIVE, "").replace(ROMANCE_ARTICLE[language], "").trim(),
3785
+ partitive
3786
+ };
3040
3787
  }
3041
- return text2.replace(OF_OPENING, "").replace(LEADING_INDEFINITE_WITH_SPACE, "").trim();
3788
+ return {
3789
+ item: text2.replace(OF_OPENING, "").replace(LEADING_INDEFINITE_WITH_SPACE, "").trim(),
3790
+ partitive: false
3791
+ };
3042
3792
  }
3043
3793
  function takeAlternates(text2, language) {
3044
3794
  const none = { measures: [], intro: null, published: "", rest: text2 };
@@ -3058,7 +3808,7 @@ function takeAlternates(text2, language) {
3058
3808
  var EQUIVALENT_INTRODUCER = /^(?:soit|environ|about|approx\.?|approximately|or|ou|=)\s*/i;
3059
3809
  var MEASURE_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+|of\s+)/i;
3060
3810
  function takeMeasureAfterQuantity(text2, language) {
3061
- const direct = takeUnitEitherLanguage(text2, language);
3811
+ const direct = takeUnitAnyLanguage(text2, language);
3062
3812
  if (direct.unit) {
3063
3813
  return direct;
3064
3814
  }
@@ -3066,7 +3816,7 @@ function takeMeasureAfterQuantity(text2, language) {
3066
3816
  if (!partitive) {
3067
3817
  return direct;
3068
3818
  }
3069
- return takeUnitEitherLanguage(text2.slice(partitive[0].length), language);
3819
+ return takeUnitAnyLanguage(text2.slice(partitive[0].length), language);
3070
3820
  }
3071
3821
  function readBracket(bracket, language) {
3072
3822
  const inside = bracket.slice(1, -1);
@@ -3121,7 +3871,7 @@ function takeSlashAlternates(text2, language) {
3121
3871
  if (!quantity) {
3122
3872
  break;
3123
3873
  }
3124
- const taken = takeUnitEitherLanguage(after.slice(quantity.length).trimStart(), language);
3874
+ const taken = takeUnitAnyLanguage(after.slice(quantity.length).trimStart(), language);
3125
3875
  if (!taken.unit) {
3126
3876
  break;
3127
3877
  }
@@ -3139,7 +3889,7 @@ function formatAmount(amount, language, options = {}) {
3139
3889
  const decimal = (value) => {
3140
3890
  const rounded = value !== 0 && Math.abs(value) < 0.01 ? Number(value.toPrecision(2)) : Math.round(value * 100) / 100;
3141
3891
  const rendered = String(rounded);
3142
- return language === "fr" ? rendered.replace(".", ",") : rendered;
3892
+ return writesDecimalComma(language) ? rendered.replace(".", ",") : rendered;
3143
3893
  };
3144
3894
  if (!Number.isFinite(amount)) {
3145
3895
  return "";
@@ -3182,6 +3932,7 @@ var GARLIC = /\bgarlic\b/i;
3182
3932
  var IES_ENDING = /ies$/i;
3183
3933
  var LETTER = new RegExp("\\p{L}", "u");
3184
3934
  var LETTERS_ONLY = /^[A-Za-z]+$/;
3935
+ var WORD_LETTERS = new RegExp("^\\p{L}+$", "u");
3185
3936
  var OF_JOINER = / of /i;
3186
3937
  var PLURAL_ENDING = /s$|eaux$|aux$/i;
3187
3938
  var SIBILANT_ENDING = /(?:ch|sh|s|x|z)$/i;
@@ -3190,6 +3941,19 @@ var SIBILANT_PLURAL = /(?:ch|sh|s|x|z)es$/i;
3190
3941
  var TRAILING_S_ANY_CASE2 = /s$/i;
3191
3942
  var VOWEL_OPENING = /^[aeiouàâäéèêëîïôöûü]/i;
3192
3943
  var X_ENDING = /x$/i;
3944
+ function equipmentIngredient(line, language = "auto") {
3945
+ return {
3946
+ text: line.trim(),
3947
+ original: line,
3948
+ scaling: "unscaled",
3949
+ amount: null,
3950
+ amountMax: null,
3951
+ unit: null,
3952
+ language: language === "auto" ? detectLanguage(line) : language,
3953
+ isEquipment: true,
3954
+ note: EQUIPMENT_NOTE
3955
+ };
3956
+ }
3193
3957
  function roundTo(value, step) {
3194
3958
  return Math.round(Math.round(value / step) * step * 100) / 100;
3195
3959
  }
@@ -3352,11 +4116,11 @@ var PORTION_SIZED_ITEM = new RegExp(
3352
4116
  "iu"
3353
4117
  );
3354
4118
  var QUARTERED_ITEM = new RegExp(
3355
- "\\b(?:onions?|shallots?|potatoes|potato|carrots?|apples?|pears?|lemons?|limes?|oranges?|tomato(?:es)?|cucumbers?|courgettes?|zucchinis?|aubergines?|eggplants?|squash(?:es)?|pumpkins?|cabbages?|melons?|watermelons?|peppers?|beets?|turnips?|parsnips?|leeks?|bananas?|mango(?:e?s)?|avocados?|legs? of lamb|lamb legs?|baguettes?|camemberts?|cheeses?|chorizos?|pineapples?|peach(?:es)?|apricots?|milk|chickens?|guinea fowls?|roasts?|oignons?|\xE9chalotes?|echalotes?|pommes? de terre|carottes?|pommes?|poires?|citrons?|tomates?|concombres?|courgettes?|aubergines?|potirons?|choux?|chou|melons?|poivrons?|betteraves?|navets?|panais|poireaux?|bananes?|mangues?|avocats?|past\xE8ques?|pasteques?|gigots?|fromages?|ch\xE8vres?|chevres?|ananas|p\xEAches?|peches?|abricots?|laits?|poulets?|pintades?|reblochons?|b\xFBches?|buches?|r\xF4tis?|rotis?)\\b",
4119
+ "\\b(?:onions?|shallots?|potatoes|potato|carrots?|apples?|pears?|lemons?|limes?|oranges?|tomato(?:es)?|cucumbers?|courgettes?|zucchinis?|aubergines?|eggplants?|squash(?:es)?|pumpkins?|cabbages?|melons?|watermelons?|peppers?|beets?|turnips?|parsnips?|leeks?|bananas?|mango(?:e?s)?|avocados?|legs? of lamb|lamb legs?|baguettes?|camemberts?|cheeses?|chorizos?|pineapples?|peach(?:es)?|apricots?|milk|chickens?|guinea fowls?|roasts?|oignons?|\xE9chalotes?|echalotes?|pommes? de terre|carottes?|pommes?|poires?|citrons?|tomates?|concombres?|courgettes?|aubergines?|potirons?|choux?|chou|melons?|poivrons?|betteraves?|navets?|panais|poireaux?|bananes?|mangues?|avocats?|past\xE8ques?|pasteques?|gigots?|fromages?|ch\xE8vres?|chevres?|ananas|p\xEAches?|peches?|abricots?|laits?|poulets?|pintades?|reblochons?|b\xFBches?|buches?|r\xF4tis?|rotis?|cebollas?|cebolletas?|chalotas?|patatas?|papas?|zanahorias?|manzanas?|peras?|limones|lim\xF3n|limon|limas?|naranjas?|tomates?|pepinos?|calabacines|calabac\xEDn|calabacin|berenjenas?|calabazas?|coles|col|melones|mel\xF3n|melon|sand\xEDas?|sandias?|pimientos?|remolachas?|nabos?|chiriv\xEDas?|chirivias?|puerros?|pl\xE1tanos?|platanos?|mangos?|aguacates?|pi\xF1as?|pinas?|melocotones|melocot\xF3n|melocoton|albaricoques?|duraznos?|quesos?|pollos?|asados?|barras? de pan)\\b",
3356
4120
  "iu"
3357
4121
  );
3358
4122
  var HALVED_ITEM = /\b(?:jus|juices?)\b/iu;
3359
- var WHOLE_ITEM = /\b(?:eggs?|yolks?|egg\s+whites?|oeufs?|œufs?|jaunes?\s+d['e]|clous?|zestes?|zests?)\b/iu;
4123
+ var WHOLE_ITEM = /\b(?:eggs?|yolks?|egg\s+whites?|oeufs?|œufs?|jaunes?\s+d['e]|clous?|zestes?|zests?|huevos?|yemas?\s+de|claras?\s+de|clavos?\s+de)\b/iu;
3360
4124
  var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ailes?|pilons?|escalopes?|magrets?)\b/iu;
3361
4125
  function cloveDivisibility(unit, item) {
3362
4126
  const counted = unit ? unit.canonical === "clove" : CLOVE.test(item);
@@ -3720,6 +4484,9 @@ function agreeLeadingAdjective(word, amount, language) {
3720
4484
  if (language === "en") {
3721
4485
  return word;
3722
4486
  }
4487
+ if (language === "es") {
4488
+ return agreeSpanishAdjective(word, amount);
4489
+ }
3723
4490
  const wantsPlural = amount >= 2;
3724
4491
  const folded = foldWord2(word);
3725
4492
  const isPlural = folded.endsWith("s") && !AGREEABLE_ADJECTIVES.has(folded);
@@ -3733,12 +4500,57 @@ function agreeLeadingAdjective(word, amount, language) {
3733
4500
  return wantsPlural ? `${singular}s` : singular;
3734
4501
  }
3735
4502
  function agreeWithAmount(item, amount, language) {
3736
- return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
3737
- }
3738
- function agreeCountedContainer(item, amount, language) {
3739
4503
  if (language === "fr") {
3740
4504
  return agreeInFrench(item, amount);
3741
4505
  }
4506
+ return language === "es" ? agreeInSpanish(item, amount) : agreeInEnglish(item, amount);
4507
+ }
4508
+ function agreeInSpanish(item, amount) {
4509
+ if (!item) {
4510
+ return item;
4511
+ }
4512
+ const words = item.split(" ");
4513
+ const head = words[0] ?? "";
4514
+ if (head.length <= 2 || !WORD_LETTERS.test(head)) {
4515
+ return item;
4516
+ }
4517
+ const wantsPlural = amount > 1;
4518
+ const singular = spanishSingular(head);
4519
+ const isPlural = head.toLowerCase() !== singular.toLowerCase();
4520
+ if (wantsPlural === isPlural) {
4521
+ return item;
4522
+ }
4523
+ words[0] = wantsPlural ? spanishPlural(head) : singular;
4524
+ for (let index = 1; index < words.length; index += 1) {
4525
+ const word = words[index] ?? "";
4526
+ if (!WORD_LETTERS.test(word) || SPANISH_PARTITIVE.test(word)) {
4527
+ break;
4528
+ }
4529
+ const adjectiveSingular = spanishSingular(word);
4530
+ const adjectiveIsPlural = word.toLowerCase() !== adjectiveSingular.toLowerCase();
4531
+ if (wantsPlural !== adjectiveIsPlural) {
4532
+ words[index] = wantsPlural ? spanishPlural(word) : adjectiveSingular;
4533
+ }
4534
+ }
4535
+ return words.join(" ");
4536
+ }
4537
+ var SPANISH_PARTITIVE = /^(?:de|del|con|sin|para|al|y|en|o)$/i;
4538
+ function agreeSpanishAdjective(word, amount) {
4539
+ if (!WORD_LETTERS.test(word)) {
4540
+ return word;
4541
+ }
4542
+ const wantsPlural = amount > 1;
4543
+ const singular = spanishSingular(word);
4544
+ const isPlural = word.toLowerCase() !== singular.toLowerCase();
4545
+ if (wantsPlural === isPlural) {
4546
+ return word;
4547
+ }
4548
+ return wantsPlural ? spanishPlural(word) : singular;
4549
+ }
4550
+ function agreeCountedContainer(item, amount, language) {
4551
+ if (language !== "en") {
4552
+ return agreeWithAmount(item, amount, language);
4553
+ }
3742
4554
  const space = item.indexOf(" ");
3743
4555
  if (space < 0) {
3744
4556
  return agreeInEnglish(item, amount);
@@ -3746,19 +4558,23 @@ function agreeCountedContainer(item, amount, language) {
3746
4558
  return `${agreeInEnglish(item.slice(0, space), amount)}${item.slice(space)}`;
3747
4559
  }
3748
4560
  var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
3749
- function joinItem(item, language) {
4561
+ function joinItem(item, language, partitive) {
3750
4562
  if (!item) {
3751
4563
  return "";
3752
4564
  }
3753
- if (language === "en") {
4565
+ if (language === "en" || !partitive) {
3754
4566
  return ` ${item}`;
3755
4567
  }
4568
+ if (language === "es") {
4569
+ return ` de ${item}`;
4570
+ }
3756
4571
  const elides = VOWEL_OPENING.test(item) || MUTE_H_WORDS.test(item);
3757
4572
  return elides ? ` d'${item}` : ` de ${item}`;
3758
4573
  }
3759
4574
  var BRANCH_SEPARATORS = {
3760
4575
  en: /\s+or\s+/gi,
3761
- fr: /\s+ou\s+/gi
4576
+ fr: /\s+ou\s+/gi,
4577
+ es: /\s+o\s+/gi
3762
4578
  };
3763
4579
  function splitBranch(text2, parsed) {
3764
4580
  const itemStart = parsed.item ? text2.indexOf(parsed.item) : text2.length;
@@ -3781,6 +4597,9 @@ function splitBranch(text2, parsed) {
3781
4597
  }
3782
4598
  function scaleIngredient(line, options) {
3783
4599
  const { factor } = options;
4600
+ if (isEquipmentLine(line)) {
4601
+ return equipmentIngredient(line, options.language);
4602
+ }
3784
4603
  if (factor === 1) {
3785
4604
  return passthroughIngredient(line, options.language);
3786
4605
  }
@@ -3832,9 +4651,9 @@ function alternateLabel(count2, style, texts, bracketed) {
3832
4651
  }
3833
4652
  return bracketed;
3834
4653
  }
3835
- function itemLabelFor(named, item, counted, language) {
4654
+ function itemLabelFor(named, item, counted, language, partitive) {
3836
4655
  if (named) {
3837
- return joinItem(item, language);
4656
+ return joinItem(item, language, partitive);
3838
4657
  }
3839
4658
  if (counted) {
3840
4659
  return ` ${counted}`;
@@ -3917,7 +4736,8 @@ function noteForScaledLine(outcome) {
3917
4736
  function renderScaledLine(parsed, alternates, unit, shown, language) {
3918
4737
  const named = unit && !countsBarePieces(unit) ? unit : null;
3919
4738
  const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
3920
- const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
4739
+ const measure = named ? ` ${formatUnit(named, shown, language)}` : "";
4740
+ const unitLabel = parsed.measureAdjectiveFollows ? `${measure}${adjective}` : `${adjective}${measure}`;
3921
4741
  const alternateTexts = alternates.map((entry) => entry.text);
3922
4742
  const introduced = parsed.alternateIntro ? `${parsed.alternateIntro} ` : "";
3923
4743
  const bracketed = alternates.length === 0 ? "" : ` (${introduced}${alternateTexts.join(" / ")})`;
@@ -3930,7 +4750,7 @@ function renderScaledLine(parsed, alternates, unit, shown, language) {
3930
4750
  const trailingLabel = parsed.alternateStyle === "trailing" ? bracketed : "";
3931
4751
  const capacityLabel = parsed.capacity ? ` ${parsed.capacity}` : "";
3932
4752
  const counted = parsed.capacity ? agreeCountedContainer(parsed.item, shown, language) : agreeWithAmount(parsed.item, shown, language);
3933
- const itemLabel = itemLabelFor(named, parsed.item, counted, language);
4753
+ const itemLabel = itemLabelFor(named, parsed.item, counted, language, parsed.itemPartitive);
3934
4754
  return { named, unitLabel, capacityLabel, altLabel, trailingLabel, itemLabel };
3935
4755
  }
3936
4756
  var HELD_BACK_NOTE = {
@@ -3954,6 +4774,7 @@ function scaleSingleLine(line, options) {
3954
4774
  amountMax: null,
3955
4775
  unit: null,
3956
4776
  language,
4777
+ isEquipment: false,
3957
4778
  note: parsed.heldBack ? HELD_BACK_NOTE[parsed.heldBack] : "No quantity given; adjust to taste."
3958
4779
  };
3959
4780
  }
@@ -3993,7 +4814,8 @@ function scaleSingleLine(line, options) {
3993
4814
  amount: low.amount,
3994
4815
  amountMax: collapsed ? null : high?.amount ?? null,
3995
4816
  unit: named?.canonical ?? null,
3996
- language
4817
+ language,
4818
+ isEquipment: false
3997
4819
  };
3998
4820
  const note = noteForScaledLine({
3999
4821
  parsed,
@@ -4050,6 +4872,9 @@ function scaleIngredients(lines, options) {
4050
4872
  return lines.map((line) => scaleIngredient(line, options));
4051
4873
  }
4052
4874
  function passthroughIngredient(line, language = "auto") {
4875
+ if (isEquipmentLine(line)) {
4876
+ return equipmentIngredient(line, language);
4877
+ }
4053
4878
  const parsed = parseIngredient(line, language);
4054
4879
  const held = parsed.amount === null || parsed.heldBack !== null;
4055
4880
  const result = {
@@ -4059,7 +4884,8 @@ function passthroughIngredient(line, language = "auto") {
4059
4884
  amount: held ? null : parsed.amount,
4060
4885
  amountMax: held ? null : parsed.amountMax,
4061
4886
  unit: held ? null : parsed.unit?.canonical ?? null,
4062
- language: parsed.language
4887
+ language: parsed.language,
4888
+ isEquipment: false
4063
4889
  };
4064
4890
  if (parsed.heldBack) {
4065
4891
  result.note = HELD_BACK_NOTE[parsed.heldBack];
@@ -4112,6 +4938,9 @@ var reportSchema = z.object({
4112
4938
  preferred_by_name: z.boolean().describe(
4113
4939
  "Whether this source's rows were arranged so the ones whose title names the dish come first. An order over one source's own rows, never a score against another source."
4114
4940
  ),
4941
+ names_the_dish: z.number().int().describe(
4942
+ "How many of this source's rows in this answer carry the dish in their title, out of 'count'. A row is what the source returned for the words it was handed, and one of these indexes answers any wording with something, so zero means the source offered rows and none of them is the dish."
4943
+ ),
4115
4944
  wordings: z.array(
4116
4945
  z.object({
4117
4946
  query: z.string().describe("What was sent to this source, or would have been."),
@@ -4141,12 +4970,15 @@ var ingredientSchema = z.object({
4141
4970
  text: z.string().describe("The line as it now reads."),
4142
4971
  original: z.string().describe("The line as published."),
4143
4972
  scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
4144
- "'scaled' means the arithmetic landed on the exact product, 'rounded' that something had to move for the line to stay usable or that the line carries a second quantity whose match is the page's claim, 'unscaled' that there is nothing to multiply."
4973
+ "'scaled' means the arithmetic landed on the exact product, 'rounded' that something had to move for the line to stay usable or that the line carries a second quantity whose match is the page's claim, 'unscaled' that the line was left as given. Read 'is_equipment' beside it: a tool line is 'unscaled' whether or not it carries a figure."
4145
4974
  ),
4146
4975
  amount: z.number().nullable().describe("The scaled quantity, in 'unit'. Read the two together, never the number alone."),
4147
4976
  amount_max: z.number().nullable().describe("Upper bound when the line gives a range."),
4148
4977
  unit: z.string().nullable().describe("The unit 'amount' is in, which may differ from the page's."),
4149
- language: z.enum(["fr", "en"]).describe("The language the line was read and rewritten in."),
4978
+ language: z.enum(["fr", "en", "es"]).describe("The language the line was read and rewritten in."),
4979
+ is_equipment: z.boolean().describe(
4980
+ "True for a line naming a tool, which some sites write among the ingredients. Such a line is never multiplied: a recipe made for more people uses the same pan."
4981
+ ),
4150
4982
  note: z.string().optional().describe("What happened to this line, when anything did.")
4151
4983
  });
4152
4984
  function toIngredientPayload(entry) {
@@ -4158,6 +4990,7 @@ function toIngredientPayload(entry) {
4158
4990
  amount_max: entry.amountMax,
4159
4991
  unit: entry.unit,
4160
4992
  language: entry.language,
4993
+ is_equipment: entry.isEquipment,
4161
4994
  ...entry.note ? { note: entry.note } : {}
4162
4995
  };
4163
4996
  }
@@ -4186,6 +5019,7 @@ function toReportPayload(report) {
4186
5019
  skipped: report.skipped,
4187
5020
  cached: report.cached,
4188
5021
  preferred_by_name: report.preferredByName,
5022
+ names_the_dish: report.namesTheDish,
4189
5023
  wordings: report.wordings.map((attempt) => ({
4190
5024
  query: attempt.query,
4191
5025
  derivation: attempt.derivation,
@@ -4221,6 +5055,47 @@ function labelNote(label, note) {
4221
5055
  const said = note.text;
4222
5056
  return mustKeep(`${label}: ${said}`);
4223
5057
  }
5058
+ function answeredNotes(report) {
5059
+ const notes = [];
5060
+ const sent = report.wordings.filter((attempt) => attempt.ran && attempt.error === null);
5061
+ if (report.count === 0) {
5062
+ notes.push(nothingOfferedNote(report, sent));
5063
+ }
5064
+ if (sent.length > 1) {
5065
+ notes.push(
5066
+ `${report.name} was asked ${sent.length} wordings and these rows are their union: ${sent.map((attempt) => `"${quoteForeign(attempt.query)}" (${attempt.count ?? 0})`).join(", ")}. Those counts are per wording and are never added.`
5067
+ );
5068
+ }
5069
+ if (report.skipped > 0) {
5070
+ notes.push(
5071
+ mustKeep(
5072
+ `${report.name} sent ${report.skipped} row(s) in a shape this server could not read, and they were left out.`
5073
+ )
5074
+ );
5075
+ }
5076
+ if (report.reportedTotal !== null && report.reportedTotalMeans !== null) {
5077
+ const forWording = sent.length > 1 ? ` That number belongs to "${quoteForeign(sent[0]?.query ?? "")}" alone.` : "";
5078
+ notes.push(
5079
+ `${report.name} reported ${report.reportedTotal}: ${report.reportedTotalMeans}.${forWording}`
5080
+ );
5081
+ } else {
5082
+ notes.push(
5083
+ `${report.name} publishes no count of what a search matched, so a short list here is not evidence that little exists.`
5084
+ );
5085
+ }
5086
+ return notes;
5087
+ }
5088
+ function listNames(names2) {
5089
+ if (names2.length <= 1) {
5090
+ return names2.join("");
5091
+ }
5092
+ return `${names2.slice(0, -1).join(", ")} and ${names2.at(-1)}`;
5093
+ }
5094
+ function nothingOfferedNote(report, sent) {
5095
+ return mustKeep(
5096
+ sent.length > 1 ? `${report.name} answered and offered no row for any of the ${sent.length} wordings it was sent (${sent.map((attempt) => `"${quoteForeign(attempt.query)}"`).join(", ")}). Each of those is a statement about a wording; together they are the closest this server comes to saying the corpus holds nothing. Try a main ingredient, or the dish's name in the language that source publishes in.` : `${report.name} answered and offered no row for this wording. That is a statement about the wording as much as about the corpus: try the dish's name in another language, or a main ingredient.`
5097
+ );
5098
+ }
4224
5099
  function reportNotes(reports) {
4225
5100
  const notes = [];
4226
5101
  const answered = reports.filter((report) => report.status === "answered");
@@ -4234,51 +5109,34 @@ function reportNotes(reports) {
4234
5109
  );
4235
5110
  }
4236
5111
  for (const report of answered) {
4237
- const sent = report.wordings.filter((attempt) => attempt.ran && attempt.error === null);
4238
- if (report.count === 0) {
4239
- notes.push(
4240
- mustKeep(
4241
- sent.length > 1 ? `${report.name} answered and offered no row for any of the ${sent.length} wordings it was sent (${sent.map((attempt) => `"${attempt.query}"`).join(", ")}). Each of those is a statement about a wording; together they are the closest this server comes to saying the corpus holds nothing. Try a main ingredient, or the dish's name in the language that source publishes in.` : `${report.name} answered and offered no row for this wording. That is a statement about the wording as much as about the corpus: try the dish's name in another language, or a main ingredient.`
4242
- )
4243
- );
4244
- }
4245
- if (sent.length > 1) {
4246
- notes.push(
4247
- `${report.name} was asked ${sent.length} wordings and these rows are their union: ${sent.map((attempt) => `"${attempt.query}" (${attempt.count ?? 0})`).join(", ")}. Those counts are per wording and are never added.`
4248
- );
4249
- }
4250
- if (report.preferredByName) {
4251
- notes.push(
4252
- `${report.name}'s rows are arranged with the ones naming the dish first, so a wording that returned near-misses cannot crowd out what another wording found. That is an order over one source's own rows and not a score against any other source.`
4253
- );
4254
- }
4255
- if (report.skipped > 0) {
4256
- notes.push(
4257
- mustKeep(
4258
- `${report.name} sent ${report.skipped} row(s) in a shape this server could not read, and they were left out.`
4259
- )
4260
- );
4261
- }
4262
- if (report.reportedTotal !== null && report.reportedTotalMeans !== null) {
4263
- const forWording = sent.length > 1 ? ` That number belongs to "${sent[0]?.query}" alone.` : "";
4264
- notes.push(
4265
- `${report.name} reported ${report.reportedTotal}: ${report.reportedTotalMeans}.${forWording}`
4266
- );
4267
- } else {
4268
- notes.push(
4269
- `${report.name} states no total and offers no second page, so a short list here is not evidence that little exists.`
4270
- );
4271
- }
5112
+ notes.push(...answeredNotes(report));
4272
5113
  }
4273
- if (answered.some((report) => report.mixesReferencePages && report.count > 0)) {
4274
- const mixing = answered.filter((report) => report.mixesReferencePages && report.count > 0);
5114
+ const offTopic = answered.filter((report) => report.count > 0 && report.namesTheDish === 0);
5115
+ const onTopic = answered.some((report) => report.namesTheDish > 0);
5116
+ if (offTopic.length > 0 && onTopic) {
4275
5117
  notes.push(
4276
- `${mixing.map((report) => report.name).join(" and ")} files pages about an ingredient beside recipes using it, so a row here can be a page about an ingredient rather than a recipe. get_recipe says what it read off the page.`
5118
+ mustKeep(
5119
+ `No row from ${listNames(offTopic.map((report) => report.name))} carries this dish in its title. Those indexes answer the words they are handed, so rows came back for a dish they may not hold. Ask in the language that source publishes in, or open a row with get_recipe before calling it the dish.`
5120
+ )
4277
5121
  );
4278
5122
  }
5123
+ if (answered.some((report) => report.count > 0)) {
5124
+ notes.push(
5125
+ "Each source's rows are arranged with the ones naming the dish first. That is an order over one source's own rows and not a score against any other source."
5126
+ );
5127
+ }
5128
+ for (const report of answered) {
5129
+ if (report.rowsThatAreNotRecipes !== null && report.count > 0) {
5130
+ notes.push(
5131
+ `A row from ${report.name} can be ${report.rowsThatAreNotRecipes}. get_recipe says what it read off the page.`
5132
+ );
5133
+ }
5134
+ }
4279
5135
  if (answered.length > 1) {
4280
5136
  notes.push(
4281
- "Each count above measures something different, and they are never added together into one total."
5137
+ mustKeep(
5138
+ "Each count above measures something different, and they are never added together into one total."
5139
+ )
4282
5140
  );
4283
5141
  }
4284
5142
  if (reports.some((report) => report.cached)) {
@@ -4383,7 +5241,7 @@ function creditLine(contributors) {
4383
5241
  }
4384
5242
 
4385
5243
  // src/tools/recipeView.ts
4386
- var SERVING_WORD = /personne|people|serving|portion|part\b/i;
5244
+ var SERVING_WORD = /personne|people|serving|portion|part\b|racion|raciones|rac\b|persona|personas|comensal|comensales/i;
4387
5245
  function whatTheOtherPartHolds(part, recipe) {
4388
5246
  if (part === "ingredients") {
4389
5247
  return recipe.steps.length > 0 ? `${recipe.steps.length} step(s) of method were read from it` : null;
@@ -4409,7 +5267,7 @@ var recipeSchema = z2.object({
4409
5267
  id: z2.string(),
4410
5268
  source: z2.string(),
4411
5269
  source_name: z2.string(),
4412
- language: z2.enum(["fr", "en"]).describe("The language this recipe is written in."),
5270
+ language: z2.enum(["fr", "en", "es"]).describe("The language this recipe is written in."),
4413
5271
  title: z2.string(),
4414
5272
  url: z2.string(),
4415
5273
  image_url: z2.string().nullable(),
@@ -4446,9 +5304,41 @@ var recipeSchema = z2.object({
4446
5304
  scaling_summary: z2.object({
4447
5305
  scaled_count: z2.number().int(),
4448
5306
  rounded_count: z2.number().int(),
4449
- unscaled_count: z2.number().int()
4450
- })
5307
+ unscaled_count: z2.number().int(),
5308
+ equipment_count: z2.number().int().describe("Lines naming a tool, which some sites write among the ingredients.")
5309
+ }).describe("The four counts add up to the number of ingredient lines.")
5310
+ });
5311
+ var collectionSchema = z2.object({
5312
+ id: z2.string(),
5313
+ source: z2.string(),
5314
+ source_name: z2.string(),
5315
+ title: z2.string(),
5316
+ url: z2.string(),
5317
+ image_url: z2.string().nullable(),
5318
+ headings: z2.array(z2.string()).describe(
5319
+ "The headings the article is built from, in the order it prints them, up to 'max_gathered'."
5320
+ ),
5321
+ recipes: z2.array(rowSchema).describe(
5322
+ "The recipes it points at, each readable with get_recipe, up to 'max_gathered'. 'gathered_count' says how many the article holds."
5323
+ ),
5324
+ gathered_count: z2.number().int().describe("Recipes the article points at, whether or not they are all listed here."),
5325
+ attribution: z2.string().describe("Show this, with the url, when repeating anything from here.")
4451
5326
  });
5327
+ function buildCollectionView(recipe, limit) {
5328
+ const gathered = recipe.gathers?.rows ?? [];
5329
+ return {
5330
+ id: recipe.id,
5331
+ source: recipe.source,
5332
+ source_name: recipe.sourceName,
5333
+ title: recipe.title,
5334
+ url: recipe.url,
5335
+ image_url: recipe.imageUrl,
5336
+ headings: (recipe.gathers?.headings ?? []).slice(0, limit),
5337
+ recipes: gathered.slice(0, limit).map(toRowPayload),
5338
+ gathered_count: gathered.length,
5339
+ attribution: recipe.attribution
5340
+ };
5341
+ }
4452
5342
  var SECTIONS = [
4453
5343
  "ingredients",
4454
5344
  "steps",
@@ -4498,7 +5388,7 @@ function resolveFactor(recipe, servings) {
4498
5388
  const unit = recipe.yieldUnit;
4499
5389
  if (unit && !SERVING_WORD.test(unit)) {
4500
5390
  notes.push(
4501
- `This page states its yield in ${quoteForeign(unit)} rather than in servings, so asking for ${servings} multiplied the quantities by ${round(factor)} to give ${servings} ${quoteForeign(unit)}.`
5391
+ `This page counts its yield in ${quoteForeign(unit)}, which is not a number of eaters, so asking for ${servings} multiplied the quantities by ${round(factor)} to give ${servings} ${quoteForeign(unit)}.`
4502
5392
  );
4503
5393
  }
4504
5394
  return { factor, notes };
@@ -4555,6 +5445,11 @@ function partNotes(recipe, wants) {
4555
5445
  if (wants("steps") && recipe.steps.length === 0) {
4556
5446
  notes.push(emptyPartNote(recipe, METHOD_PART));
4557
5447
  }
5448
+ if (wants("equipment") && recipe.equipment.length === 0 && recipe.ingredients.length > 0) {
5449
+ notes.push(
5450
+ `${recipe.sourceName} keeps no equipment list of its own. What a recipe is cooked with is written among the ingredients, where each such line carries 'is_equipment'.`
5451
+ );
5452
+ }
4558
5453
  if (wants("steps") && recipe.stepsAsOneBlock === true && recipe.steps.length > 0) {
4559
5454
  notes.push(
4560
5455
  mustKeep(
@@ -4569,8 +5464,11 @@ function buildRecipeView(recipe, options) {
4569
5464
  const wants = (section) => options.sections.includes(section);
4570
5465
  const ingredients = factor === null || factor === 1 ? passthroughIngredients(recipe.ingredients, recipe.language) : scaleIngredients(recipe.ingredients, { factor, language: recipe.language });
4571
5466
  const notes = [...yieldNotes, ...partNotes(recipe, wants)];
4572
- const rounded = ingredients.filter((entry) => entry.scaling === "rounded");
4573
- const unscaled = ingredients.filter((entry) => entry.scaling === "unscaled");
5467
+ const rounded = ingredients.filter((entry) => entry.scaling === "rounded" && !entry.isEquipment);
5468
+ const equipment = ingredients.filter((entry) => entry.isEquipment);
5469
+ const unscaled = ingredients.filter(
5470
+ (entry) => entry.scaling === "unscaled" && !entry.isEquipment
5471
+ );
4574
5472
  if (rounded.length > 0) {
4575
5473
  notes.push(
4576
5474
  `${rounded.length} line(s) did not land on the exact product; each says in its own note what happened to it.`
@@ -4581,6 +5479,11 @@ function buildRecipeView(recipe, options) {
4581
5479
  `${unscaled.length} line(s) carry no quantity to multiply and are repeated as published.`
4582
5480
  );
4583
5481
  }
5482
+ if (equipment.length > 0) {
5483
+ notes.push(
5484
+ `${equipment.length} line(s) name a tool, which this site writes among them. They were left as published: a recipe made for more people uses the same one.`
5485
+ );
5486
+ }
4584
5487
  if (recipe.license) {
4585
5488
  notes.push(
4586
5489
  mustKeep(
@@ -4636,9 +5539,14 @@ function buildRecipeView(recipe, options) {
4636
5539
  sections_returned: [...options.sections],
4637
5540
  sections_omitted: omitted,
4638
5541
  scaling_summary: {
4639
- scaled_count: ingredients.filter((entry) => entry.scaling === "scaled").length,
5542
+ // A tool line is counted apart, so the four counts add up to the lines in
5543
+ // the list. It carries "unscaled" like a line with no figure on it, and
5544
+ // merging the two would say this server found no quantity where it found
5545
+ // one and declined it.
5546
+ scaled_count: ingredients.filter((entry) => entry.scaling === "scaled" && !entry.isEquipment).length,
4640
5547
  rounded_count: rounded.length,
4641
- unscaled_count: unscaled.length
5548
+ unscaled_count: unscaled.length,
5549
+ equipment_count: equipment.length
4642
5550
  }
4643
5551
  };
4644
5552
  if (options.label) {
@@ -4769,12 +5677,6 @@ var compareRecipesOutput = z4.object({
4769
5677
  });
4770
5678
  var TEXT_INGREDIENT_LINES = 8;
4771
5679
  var TEXT_DIFFERENCE_LINES = 4;
4772
- function listNames(names2) {
4773
- if (names2.length <= 1) {
4774
- return names2.join("");
4775
- }
4776
- return `${names2.slice(0, -1).join(", ")} and ${names2.at(-1)}`;
4777
- }
4778
5680
  function agrees(names2, singular, plural) {
4779
5681
  return names2.length === 1 ? singular : plural;
4780
5682
  }
@@ -4823,12 +5725,11 @@ function describeDifferences(recipes, payloads, sections) {
4823
5725
  `${stated.map((one) => `${one.name} yields ${quoteForeign(one.text)}`).join("; ")}.`
4824
5726
  );
4825
5727
  }
4826
- const counts = payloads.map((payload) => payload.ingredients.length);
5728
+ const foodLines = (payload) => payload.ingredients.filter((entry) => !entry.is_equipment).length;
5729
+ const counts = payloads.map(foodLines);
4827
5730
  if (new Set(counts).size > 1 && counts.every((value) => value > 0)) {
4828
5731
  differences.push(
4829
- `${payloads.map(
4830
- (payload) => `${payload.source_name} lists ${payload.ingredients.length} ingredient lines`
4831
- ).join("; ")}.`
5732
+ `${payloads.map((payload) => `${payload.source_name} lists ${foodLines(payload)} ingredient lines`).join("; ")}.`
4832
5733
  );
4833
5734
  }
4834
5735
  if (sections.includes("times")) {
@@ -4874,6 +5775,30 @@ function describeDifferences(recipes, payloads, sections) {
4874
5775
  }
4875
5776
  return differences;
4876
5777
  }
5778
+ function whyItIsMissing(report, dish, outcome) {
5779
+ if (outcome.article) {
5780
+ return mustKeep(
5781
+ `${report.name} is missing from this comparison because the page its search offered gathers other recipes: ${quoteForeign(outcome.article.title)}, ${quoteForeign(outcome.article.url)}. Read one of the recipes it lists with get_recipe.`
5782
+ );
5783
+ }
5784
+ if (report.status === "failed") {
5785
+ return mustKeep(
5786
+ `${report.name} is missing from this comparison because its search did not answer (${quoteForeign(
5787
+ `${report.error?.code ?? "unknown"}: ${report.error?.message ?? "unknown"}`
5788
+ )}). Nothing here is evidence about what it holds.`
5789
+ );
5790
+ }
5791
+ if (outcome.failedRead) {
5792
+ return mustKeep(
5793
+ `${report.name}'s search answered and offered a version, and that version could not be read (${quoteForeign(
5794
+ `${outcome.failedRead.code}: ${outcome.failedRead.message}`
5795
+ )}). The failure is in the reading, so nothing here says whether ${report.name} holds this dish.`
5796
+ );
5797
+ }
5798
+ return mustKeep(
5799
+ `${report.name} answered and offered nothing close enough to "${quoteForeign(dish)}" to compare.`
5800
+ );
5801
+ }
4877
5802
  async function runCompareRecipes(client, args) {
4878
5803
  try {
4879
5804
  const merged = await client.searchRecipes(
@@ -4891,6 +5816,7 @@ async function runCompareRecipes(client, args) {
4891
5816
  const reads = await Promise.allSettled(offered.map(([, id]) => client.getRecipe(id)));
4892
5817
  const recipes = [];
4893
5818
  const unread = /* @__PURE__ */ new Map();
5819
+ const gathered = /* @__PURE__ */ new Map();
4894
5820
  const opened = /* @__PURE__ */ new Set();
4895
5821
  reads.forEach((read, index) => {
4896
5822
  const source = offered[index]?.[0];
@@ -4899,6 +5825,10 @@ async function runCompareRecipes(client, args) {
4899
5825
  }
4900
5826
  opened.add(source);
4901
5827
  if (read.status === "fulfilled") {
5828
+ if (read.value.recipe.gathers) {
5829
+ gathered.set(source, read.value.recipe);
5830
+ return;
5831
+ }
4902
5832
  recipes.push(read.value.recipe);
4903
5833
  return;
4904
5834
  }
@@ -4935,30 +5865,12 @@ async function runCompareRecipes(client, args) {
4935
5865
  if (recipes.some((recipe) => recipe.source === report.source)) {
4936
5866
  continue;
4937
5867
  }
4938
- const failedRead = unread.get(report.source);
4939
- if (report.status === "failed") {
4940
- notes.push(
4941
- mustKeep(
4942
- `${report.name} is missing from this comparison because its search did not answer (${quoteForeign(
4943
- `${report.error?.code ?? "unknown"}: ${report.error?.message ?? "unknown"}`
4944
- )}). Nothing here is evidence about what it holds.`
4945
- )
4946
- );
4947
- } else if (failedRead) {
4948
- notes.push(
4949
- mustKeep(
4950
- `${report.name}'s search answered and offered a version, and that version could not be read (${quoteForeign(
4951
- `${failedRead.code}: ${failedRead.message}`
4952
- )}). The failure is in the reading, so nothing here says whether ${report.name} holds this dish.`
4953
- )
4954
- );
4955
- } else {
4956
- notes.push(
4957
- mustKeep(
4958
- `${report.name} answered and offered nothing close enough to "${quoteForeign(args.dish)}" to compare.`
4959
- )
4960
- );
4961
- }
5868
+ notes.push(
5869
+ whyItIsMissing(report, args.dish, {
5870
+ article: gathered.get(report.source),
5871
+ failedRead: unread.get(report.source)
5872
+ })
5873
+ );
4962
5874
  }
4963
5875
  const weighed = weighTitlesAgainstTheDish(payloads, args.dish, notes);
4964
5876
  const comparable = recipes.filter(
@@ -5047,42 +5959,60 @@ var getRecipeDescription = [
5047
5959
  "'id' must come from search_recipes. It names the source, so this reads the right one without guessing; an identifier no source would have minted is refused, because sending it anywhere would answer about the wrong dish.",
5048
5960
  "Pass 'servings' to rescale. Quantities land where a kitchen can follow them: an egg stays whole, anything that pours or cuts can halve, a small measurement moves to a smaller unit before it is rounded, and anything unmultipliable is flagged rather than scaled.",
5049
5961
  "A page that states no number of servings comes back as published and says so, because dividing by a yield nobody wrote would answer for a number of people the page never claimed.",
5962
+ "Read 'kind' first. Some sources publish articles gathering other recipes at the same kind of address as a recipe, and such an answer carries 'collection' with the recipes it points at and no 'recipe' at all: there is nothing to cook from that page, and the recipes it lists are read with get_recipe.",
5050
5963
  "A part this answer holds nothing for says which of two things happened: the page showed no sign of it, or this server failed to read what the page carries. An empty ingredient list is never evidence that an ingredient is absent from the dish.",
5051
5964
  "'sections' decides what comes back, and 'sections_omitted' names what was left out: a field belonging to an omitted section is empty because nobody asked for it, never because the page states nothing.",
5052
5965
  "A field a source does not publish is null, never zero. Credit the source and link the url when you repeat any of it."
5053
5966
  ].join(" ");
5967
+ var LISTING_SHARE = 2 / 3;
5054
5968
  var getRecipeInput = strictInput({
5055
5969
  id: z5.string().min(1).max(500).describe(
5056
- "From search_recipes, such as 'marmiton:44078', 'goodfood:recipes/carbonara' or 'cookbook:Cookbook:Carbonara'. Two sources address a recipe by a bare number, so spell an id with its source."
5970
+ "From search_recipes, such as 'marmiton:44078', 'pequerecetas:paella-de-marisco' or 'cookbook:Cookbook:Carbonara'. Two sources address a recipe by a bare number, so spell an id with its source."
5057
5971
  ),
5058
5972
  servings: z5.number().int().min(1).max(500).optional().describe("Rescale to this many. Left out, the quantities come back as published."),
5059
5973
  sections: z5.array(z5.enum(SECTIONS)).default(["ingredients", "steps"]).describe("Which parts to return. A full recipe is a lot of text, so this defaults to two."),
5060
5974
  max_steps: z5.number().int().min(1).max(100).default(20).describe("Steps to return. The answer says how many more there are."),
5975
+ max_gathered: z5.number().int().min(1).max(500).default(30).describe(
5976
+ "Recipes and headings to return from an address that gathers recipes. The answer says how many the article holds."
5977
+ ),
5061
5978
  max_step_chars: z5.number().int().min(80).max(4e3).default(600).describe("Characters kept per step. Raise it only if a step was cut mid-sentence.")
5062
5979
  });
5063
5980
  var getRecipeOutput = z5.object({
5064
- recipe: recipeSchema,
5981
+ kind: z5.enum(["recipe", "collection"]).describe(
5982
+ "What the address held. 'recipe' comes with 'recipe' and no 'collection'; 'collection' comes with 'collection' and no 'recipe', and is an article gathering other recipes, with no ingredients and no method of its own."
5983
+ ),
5984
+ recipe: recipeSchema.optional().describe("Present when 'kind' is 'recipe'."),
5985
+ collection: collectionSchema.optional().describe("Present when 'kind' is 'collection'."),
5065
5986
  id_read_as: z5.string().nullable().describe("How a raw identifier was routed, when it was not spelled with its source."),
5066
5987
  notes: z5.array(z5.string())
5067
5988
  });
5989
+ var ARGUMENTS_FOR_A_RECIPE = ["servings"];
5068
5990
  async function runGetRecipe(client, args) {
5069
5991
  try {
5070
5992
  const { recipe, cached, read } = await client.getRecipe(args.id);
5993
+ const routed = read.inferred ? [
5994
+ `"${quoteForeign(args.id)}" was read as ${read.source.name}'s, because ${read.inferred}. Spell an id with its source to leave nothing to infer.`
5995
+ ] : [];
5996
+ const served = cached ? ["Served from this server's short-lived in-memory cache."] : [];
5997
+ if (recipe.gathers) {
5998
+ const ignored = ARGUMENTS_FOR_A_RECIPE.filter((name) => args[name] !== void 0);
5999
+ const said = ignored.length > 0 ? [
6000
+ `This address held an article, so ${ignored.map((name) => `'${name}'`).join(" and ")} had nothing to act on. Call get_recipe on one of the recipes it lists.`
6001
+ ] : [];
6002
+ return gatheredAnswer(
6003
+ recipe,
6004
+ read.inferred,
6005
+ [...routed, ...served, ...said],
6006
+ args.max_gathered
6007
+ );
6008
+ }
5071
6009
  const view = buildRecipeView(recipe, {
5072
6010
  servings: args.servings ?? null,
5073
6011
  sections: args.sections,
5074
6012
  maxSteps: args.max_steps,
5075
6013
  maxStepChars: args.max_step_chars
5076
6014
  });
5077
- const notes = [...view.notes];
5078
- if (read.inferred) {
5079
- notes.unshift(
5080
- `"${quoteForeign(args.id)}" was read as ${read.source.name}'s, because ${read.inferred}. Spell an id with its source to leave nothing to infer.`
5081
- );
5082
- }
5083
- if (cached) {
5084
- notes.push("Served from this server's short-lived in-memory cache.");
5085
- }
6015
+ const notes = [...routed, ...view.notes, ...served];
5086
6016
  const payload = view.payload;
5087
6017
  const credit = creditLine([{ attribution: payload.attribution, url: payload.url }]);
5088
6018
  const head = [
@@ -5090,9 +6020,12 @@ async function runGetRecipe(client, args) {
5090
6020
  quoteForeign(payload.url),
5091
6021
  renderYield(payload)
5092
6022
  ];
5093
- const ingredientLines = payload.ingredients.map(
5094
- (entry) => entry.scaling === "unscaled" ? `- ${quoteForeign(entry.text)} (no quantity)` : `- ${quoteForeign(entry.text)}`
5095
- );
6023
+ const ingredientLines = payload.ingredients.map((entry) => {
6024
+ if (entry.is_equipment) {
6025
+ return `- ${quoteForeign(entry.text)} (a tool)`;
6026
+ }
6027
+ return entry.scaling === "unscaled" ? `- ${quoteForeign(entry.text)} (no quantity)` : `- ${quoteForeign(entry.text)}`;
6028
+ });
5096
6029
  const stepLines = payload.steps.map((step, index) => `${index + 1}. ${quoteForeign(step)}`);
5097
6030
  const room = roomForBody({ notes, credit }) - head.join("\n").length;
5098
6031
  const parts = [ingredientLines, stepLines].filter((part) => part.length > 0);
@@ -5115,7 +6048,7 @@ async function runGetRecipe(client, args) {
5115
6048
  }
5116
6049
  }
5117
6050
  return ok(
5118
- { recipe: payload, id_read_as: read.inferred, notes: noteTexts(notes) },
6051
+ { kind: "recipe", recipe: payload, id_read_as: read.inferred, notes: noteTexts(notes) },
5119
6052
  lines.join("\n"),
5120
6053
  { notes, credit }
5121
6054
  );
@@ -5123,11 +6056,54 @@ async function runGetRecipe(client, args) {
5123
6056
  return toToolError(error);
5124
6057
  }
5125
6058
  }
6059
+ function gatheredAnswer(recipe, inferred, earlier, limit) {
6060
+ const payload = buildCollectionView(recipe, limit);
6061
+ const notes = [
6062
+ ...earlier,
6063
+ ...payload.gathered_count > payload.recipes.length ? [
6064
+ `This article points at ${payload.gathered_count} recipes and ${payload.recipes.length} are here. Raise 'max_gathered' for the rest.`
6065
+ ] : [],
6066
+ mustKeep(
6067
+ `This address gathers other recipes, so there are no ingredients and no method to read. ${payload.source_name} publishes both at this kind of address. Call get_recipe on one of the recipes listed here.`
6068
+ )
6069
+ ];
6070
+ const credit = creditLine([{ attribution: payload.attribution, url: payload.url }]);
6071
+ const head = [
6072
+ `${quoteForeign(payload.title)} \xB7 ${quoteForeign(payload.source_name)}`,
6073
+ quoteForeign(payload.url),
6074
+ `An article that gathers ${payload.gathered_count} recipe(s).`
6075
+ ];
6076
+ const rows = payload.recipes.map(
6077
+ (row) => `- ${quoteForeign(row.title)} (${quoteForeign(row.id)})`
6078
+ );
6079
+ const headings = payload.headings.map(quoteForeign);
6080
+ const room = roomForBody({ notes, credit }) - head.join("\n").length;
6081
+ const fitted = fitLines(rows, room * LISTING_SHARE);
6082
+ const shownHeadings = fitLines(headings, room - fitted.lines.join("\n").length);
6083
+ const lines = [...head];
6084
+ if (rows.length > 0) {
6085
+ lines.push("", "Recipes it points at:", ...fitted.lines);
6086
+ if (fitted.hidden > 0) {
6087
+ lines.push(omittedLinesLine(fitted.lines.length, rows.length, "recipes"));
6088
+ }
6089
+ }
6090
+ if (headings.length > 0 && shownHeadings.lines.length > 0) {
6091
+ lines.push("", `Built from: ${shownHeadings.lines.join(", ")}`);
6092
+ if (shownHeadings.hidden > 0) {
6093
+ lines.push(omittedLinesLine(shownHeadings.lines.length, headings.length, "headings"));
6094
+ }
6095
+ }
6096
+ return ok(
6097
+ { kind: "collection", collection: payload, id_read_as: inferred, notes: noteTexts(notes) },
6098
+ lines.join("\n"),
6099
+ { notes, credit }
6100
+ );
6101
+ }
5126
6102
 
5127
6103
  // src/tools/scaleIngredients.ts
5128
6104
  import { z as z6 } from "zod";
5129
6105
  var scaleIngredientsDescription = [
5130
- "Multiply a list of ingredient lines, in French, in English, or in a list holding both.",
6106
+ "Multiply a list of ingredient lines, in French, in English, in Spanish, or in a list holding more than one of them.",
5131
6107
  "Give either 'factor', or 'from_servings' and 'to_servings' and the factor is worked out from them.",
5132
6108
  "Quantities land where a kitchen can follow them: an egg stays whole because half of one is not something a cook takes out of the shell, while anything that pours, weighs or cuts can halve, a spoonful shrinks into the smaller spoon before it is rounded, and a pinch keeps whatever size a hand gives it while its count is multiplied.",
5133
6109
  "Every line comes back with 'scaling': 'scaled' when the arithmetic landed on the exact product, 'rounded' when something had to move for the line to stay usable, 'unscaled' when the line carries nothing to multiply. A rounded line says what it was rounded from and in which direction.",
@@ -5142,8 +6118,8 @@ var scaleIngredientsInput = strictInput({
5142
6118
  ),
5143
6119
  from_servings: z6.number().int().min(1).max(500).optional().describe("What the list serves now. Give it with 'to_servings', and without 'factor'."),
5144
6120
  to_servings: z6.number().int().min(1).max(500).optional().describe("What it should serve. Give it with 'from_servings', and without 'factor'."),
5145
- language: z6.enum(["auto", "fr", "en"]).default("auto").describe(
5146
- "'auto' reads each line on its own, which is what a list holding both languages needs. Name a language to read every line that way."
6121
+ language: z6.enum(["auto", "fr", "en", "es"]).default("auto").describe(
6122
+ "'auto' reads each line on its own, which is what a list holding more than one language needs. Name a language to read every line that way."
5147
6123
  )
5148
6124
  });
5149
6125
  var scaleIngredientsOutput = z6.object({
@@ -5152,7 +6128,10 @@ var scaleIngredientsOutput = z6.object({
5152
6128
  ingredients: z6.array(ingredientSchema),
5153
6129
  scaled_count: z6.number().int().describe("Lines whose arithmetic came out exact."),
5154
6130
  rounded_count: z6.number().int().describe("Lines whose value moved to stay usable."),
5155
- unscaled_count: z6.number().int().describe("Lines carrying nothing that can be multiplied."),
6131
+ unscaled_count: z6.number().int().describe("Lines carrying nothing that can be multiplied, tools excepted."),
6132
+ equipment_count: z6.number().int().describe(
6133
+ "Lines naming a tool, which are never multiplied: a recipe made for more people uses the same pan."
6134
+ ),
5156
6135
  notes: z6.array(z6.string())
5157
6136
  });
5158
6137
  function resolveFactor2(args) {
@@ -5195,7 +6174,8 @@ function runScaleIngredients(args) {
5195
6174
  const counts = {
5196
6175
  scaled_count: scaled.filter((entry) => entry.scaling === "scaled").length,
5197
6176
  rounded_count: scaled.filter((entry) => entry.scaling === "rounded").length,
5198
- unscaled_count: scaled.filter((entry) => entry.scaling === "unscaled").length
6177
+ unscaled_count: scaled.filter((entry) => entry.scaling === "unscaled" && !entry.isEquipment).length,
6178
+ equipment_count: scaled.filter((entry) => entry.isEquipment).length
5199
6179
  };
5200
6180
  const notes = [];
5201
6181
  if (counts.rounded_count > 0) {
@@ -5208,11 +6188,16 @@ function runScaleIngredients(args) {
5208
6188
  `${counts.unscaled_count} line(s) carry no quantity to multiply and are repeated as given.`
5209
6189
  );
5210
6190
  }
6191
+ if (counts.equipment_count > 0) {
6192
+ notes.push(
6193
+ `${counts.equipment_count} line(s) name a tool and were left as given: a recipe made for more people uses the same one.`
6194
+ );
6195
+ }
5211
6196
  if (args.language === "auto") {
5212
6197
  const languages = new Set(scaled.map((entry) => entry.language));
5213
6198
  if (languages.size > 1) {
5214
6199
  notes.push(
5215
- "This list holds lines in both languages, and each was read and rewritten in its own."
6200
+ "This list holds lines in more than one language, and each was read and rewritten in its own."
5216
6201
  );
5217
6202
  }
5218
6203
  }
@@ -5261,7 +6246,7 @@ var searchRecipesDescription = [
5261
6246
  "Search every recipe source this server reads, at the same time, for a dish or an ingredient, and get one merged list.",
5262
6247
  "Each row carries the id get_recipe takes, and that id names the source it came from, so nothing has to be guessed afterwards.",
5263
6248
  "The sources are written in different languages and count their own results differently, so 'per_source' says what each one answered, what its own number means, and names any that failed. A short list is never evidence of what exists.",
5264
- "Some sources keep recipes and reference pages together, so a row can be a page about an ingredient rather than a recipe using it. Only get_recipe can tell them apart, and it says what it read off the page.",
6249
+ "Not every row opens onto a recipe. Some sources file pages about an ingredient beside the recipes using it, and some publish articles that gather recipes; 'per_source' says in each source's own words what one of its rows can be. Only get_recipe tells them apart, and it says what it read off the page.",
5265
6250
  "The query goes to each source's own search as free text. There is no filtering: a word naming a diet, a time or a calorie count matches only where that source's index happens to carry it.",
5266
6251
  "Ask in a whole sentence if that is the question. These indexes answer the words they are handed, so a sentence is also sent as the words naming the dish and as the dish word alone, and the rows are the union; 'per_source' lists every wording and what it returned.",
5267
6252
  "What the question says the recipe must not hold is set aside from those shorter wordings rather than searched for, and named back in the notes: a negation, an allergy stated as one, a diet named in one word, and the number of people at the table. No source filters on any of it, so open a row with get_recipe and read the ingredient list before calling it suitable.",
@@ -5378,7 +6363,8 @@ async function runSearchRecipes(client, args) {
5378
6363
  ];
5379
6364
  const alsoSent = derived.length > 0 ? `
5380
6365
  Also searched for: ${derived.map((wording) => `"${quoteForeign(wording)}"`).join(", ")}.` : "";
5381
- const body = results.length > 0 ? `${results.length} recipes for "${args.query}":
6366
+ const anyNamesTheDish = merged.reports.some((report) => report.namesTheDish > 0);
6367
+ const body = results.length > 0 ? `${results.length} ${anyNamesTheDish ? "recipes" : "rows"} for "${args.query}":
5382
6368
  ${renderRows(results)}${alsoSent}` : nothingCameBack(answered.length, args.query, alsoSent);
5383
6369
  return ok(
5384
6370
  {
@@ -5415,8 +6401,9 @@ function buildInstructions(sources) {
5415
6401
  `Tools for recipes, reading ${sources.length} sources: ${named}. No API key and no account are needed.`,
5416
6402
  "search_recipes asks every source at once and returns one list; every id it hands back names the source it came from, and get_recipe routes on that.",
5417
6403
  "Use compare_recipes when the question is how a dish differs between traditions: it reads each source's closest version and can rescale them all to the same number of servings so the lists stand comparison.",
6404
+ "get_recipe answers in two shapes and says which under 'kind': a recipe, or an article gathering other recipes, which some sources publish at the same kind of address and which carries no ingredients and no method of its own.",
5418
6405
  "Do not rescale quantities yourself. get_recipe and scale_ingredients keep an egg whole, halve what pours or cuts, move a small measurement to a smaller unit before rounding it, and flag what cannot be scaled, which is what stops answers like '2.4 eggs'.",
5419
- "scale_ingredients works offline on a list from anywhere and reads French and English lines in one call, each in its own language.",
6406
+ "scale_ingredients works offline on a list from anywhere and reads French, English and Spanish lines in one call, each in its own language.",
5420
6407
  "A source that fails is named as a source that failed. An answer holding rows from some sources is never evidence about what the others hold, and 'per_source' says which was which.",
5421
6408
  "The sources share no scale. Their result counts count different things and are never added; some carry reader ratings and some have no author and no rating by nature, so rows are interleaved rather than ranked.",
5422
6409
  "Nothing is converted between measuring systems, and a yield is repeated in the source's own words, because '4 \xE0 6 personnes' and '4 personnes' are different claims. A page stating no yield cannot be rescaled and says so.",
@@ -5457,7 +6444,7 @@ function createServer(options = {}) {
5457
6444
  server.registerTool(
5458
6445
  "scale_ingredients",
5459
6446
  {
5460
- title: "Scale an ingredient list, in either language",
6447
+ title: "Scale an ingredient list, in any language it reads",
5461
6448
  description: scaleIngredientsDescription,
5462
6449
  inputSchema: scaleIngredientsInput,
5463
6450
  outputSchema: scaleIngredientsOutput,