@peristyle/emdash-plugin-instagram-to-recipe 0.1.3 → 0.1.4

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
@@ -2,7 +2,7 @@
2
2
  function instagramToRecipePlugin(options = {}) {
3
3
  return {
4
4
  id: "peristyle-instagram-to-recipe",
5
- version: "0.1.3",
5
+ version: "0.1.4",
6
6
  format: "standard",
7
7
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
8
8
  options,
@@ -349,26 +349,51 @@ function extractTitleFromCaption(caption) {
349
349
  }
350
350
  return titleCase(firstSentence.slice(0, 68).trim());
351
351
  }
352
+ var _QTY_UNIT_SPLIT = new RegExp(
353
+ String.raw`(?<=\s)(?=(?:\d[\d/.\s]*|[¼½¾⅓⅔⅛]\s*)(?:lbs?|pounds?|cups?|tsp|tbsp|teaspoons?|tablespoons?|oz|ounces?|cloves?|ears?|heads?|cans?|bunch(?:es)?|sticks?|pinch|dash|g|kg|ml|l)\b)` + String.raw`|(?<=\s)(?=(?:zest|juice)\s+of\b)` + String.raw`|(?<=\s)(?<!\b(?:of|about|around|approx)\s)(?=[\d¼½¾⅓⅔⅛][\d/.¼½¾⅓⅔⅛]*\s+[a-zA-Z])`,
354
+ "giu"
355
+ );
356
+ function looksCollapsed(line) {
357
+ const starts = line.split(_QTY_UNIT_SPLIT);
358
+ return line.split(/\s+/).length > 8 && starts.length >= 3;
359
+ }
360
+ function resplitCollapsedIngredientLine(line) {
361
+ if (!looksCollapsed(line)) return [line];
362
+ return line.split(_QTY_UNIT_SPLIT).map((part) => part.trim()).filter(Boolean);
363
+ }
352
364
  function parseIngredientsFromCaption(caption) {
353
365
  const match = caption.match(/what you['']ll need:?|ingredients\s*:/i);
354
366
  if (!match || match.index == null) return [];
355
367
  const afterHeader = caption.slice(match.index + match[0].length);
356
368
  const stepsMatch = afterHeader.match(/(?:^|\n)\s*\d+\.\s+/);
357
- const ingredientsBlock = stepsMatch?.index ? afterHeader.slice(0, stepsMatch.index) : afterHeader;
369
+ let ingredientsBlock = stepsMatch?.index ? afterHeader.slice(0, stepsMatch.index) : afterHeader;
370
+ const instructionsHeader = ingredientsBlock.match(
371
+ /\b(?:instructions|directions|steps|method)\s*:/i
372
+ );
373
+ if (instructionsHeader?.index) {
374
+ ingredientsBlock = ingredientsBlock.slice(0, instructionsHeader.index);
375
+ }
358
376
  const lines = ingredientsBlock.split(/\n/);
359
377
  const ingredients = [];
360
378
  for (const raw of lines) {
361
- const line = raw.trim();
362
- if (!line) continue;
363
- if (/^\[[^\]]+\]$/.test(line)) continue;
364
- if (/^recipe below/i.test(line)) continue;
365
- ingredients.push(line);
379
+ for (const piece of resplitCollapsedIngredientLine(raw)) {
380
+ const line = piece.trim();
381
+ if (!line) continue;
382
+ if (/^\[[^\]]+\]$/.test(line)) continue;
383
+ if (/^recipe below/i.test(line)) continue;
384
+ ingredients.push(line);
385
+ }
366
386
  }
367
387
  return ingredients;
368
388
  }
369
389
  function parseInstructionsFromCaption(caption) {
370
390
  const matches = [...caption.matchAll(/(?:^|\n)\s*(\d+)\.\s*([^\n]+)/g)];
371
- return matches.map((m) => m[2]?.trim()).filter((s) => Boolean(s && s.length > 3));
391
+ const steps = matches.map((m) => m[2]?.trim()).filter((s) => Boolean(s && s.length > 3));
392
+ if (steps.length > 0) return steps;
393
+ const header = caption.match(/\b(?:instructions|directions|steps|method)\s*:/i);
394
+ if (!header || header.index == null) return [];
395
+ const block = caption.slice(header.index + header[0].length);
396
+ return block.split(/(?=\b\d{1,2}\.\s)/).map((s) => s.replace(/^\d{1,2}\.\s*/, "").trim()).filter((s) => s.length > 3);
372
397
  }
373
398
  function parseRecipeCaption(caption) {
374
399
  if (!isLikelyRecipeCaption(caption)) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peristyle/emdash-plugin-instagram-to-recipe",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Import Instagram post URLs as recipe drafts in the EmDash admin",
6
6
  "publishConfig": {
@@ -117,25 +117,69 @@ export function extractTitleFromCaption(caption: string): string {
117
117
  return titleCase(firstSentence.slice(0, 68).trim());
118
118
  }
119
119
 
120
+ // A quantity token starting a new ingredient: "1 lb", "1/3 cup", "½ tsp", "3
121
+ // cloves"... Used to re-split caption lines whose newlines were lost upstream
122
+ // (Instagram's og:description meta tag flattens single line breaks, so a whole
123
+ // ingredient section can arrive as one long line).
124
+ const _QTY_UNIT_SPLIT = new RegExp(
125
+ String.raw`(?<=\s)(?=(?:\d[\d/.\s]*|[¼½¾⅓⅔⅛]\s*)(?:lbs?|pounds?|cups?|tsp|tbsp|teaspoons?|tablespoons?|oz|ounces?|cloves?|ears?|heads?|cans?|bunch(?:es)?|sticks?|pinch|dash|g|kg|ml|l)\b)` +
126
+ String.raw`|(?<=\s)(?=(?:zest|juice)\s+of\b)` +
127
+ String.raw`|(?<=\s)(?<!\b(?:of|about|around|approx)\s)(?=[\d¼½¾⅓⅔⅛][\d/.¼½¾⅓⅔⅛]*\s+[a-zA-Z])`,
128
+ "giu",
129
+ );
130
+
131
+ /** Count of quantity-like tokens — how many ingredients a line appears to hold. */
132
+ function looksCollapsed(line: string): boolean {
133
+ const starts = line.split(_QTY_UNIT_SPLIT);
134
+ return line.split(/\s+/).length > 8 && starts.length >= 3;
135
+ }
136
+
137
+ /**
138
+ * Split a line that holds several ingredients back into one line each.
139
+ *
140
+ * Lossy-input recovery only: applied when a line clearly contains multiple
141
+ * quantity tokens (see ``looksCollapsed``), never to normal one-ingredient
142
+ * lines. A trailing "Section name:" prefix is kept as its own line so the
143
+ * downstream header filters can drop it.
144
+ */
145
+ export function resplitCollapsedIngredientLine(line: string): string[] {
146
+ if (!looksCollapsed(line)) return [line];
147
+ return line
148
+ .split(_QTY_UNIT_SPLIT)
149
+ .map((part) => part.trim())
150
+ .filter(Boolean);
151
+ }
152
+
120
153
  export function parseIngredientsFromCaption(caption: string): string[] {
121
154
  const match = caption.match(/what you['']ll need:?|ingredients\s*:/i);
122
155
  if (!match || match.index == null) return [];
123
156
 
124
157
  const afterHeader = caption.slice(match.index + match[0].length);
125
158
  const stepsMatch = afterHeader.match(/(?:^|\n)\s*\d+\.\s+/);
126
- const ingredientsBlock = stepsMatch?.index
159
+ let ingredientsBlock = stepsMatch?.index
127
160
  ? afterHeader.slice(0, stepsMatch.index)
128
161
  : afterHeader;
162
+ // With a collapsed caption the numbered steps carry no leading newline, so
163
+ // the cut above misses. Cut again at an explicit instructions header so the
164
+ // steps can't leak into the ingredient list.
165
+ const instructionsHeader = ingredientsBlock.match(
166
+ /\b(?:instructions|directions|steps|method)\s*:/i,
167
+ );
168
+ if (instructionsHeader?.index) {
169
+ ingredientsBlock = ingredientsBlock.slice(0, instructionsHeader.index);
170
+ }
129
171
 
130
172
  const lines = ingredientsBlock.split(/\n/);
131
173
  const ingredients: string[] = [];
132
174
 
133
175
  for (const raw of lines) {
134
- const line = raw.trim();
135
- if (!line) continue;
136
- if (/^\[[^\]]+\]$/.test(line)) continue;
137
- if (/^recipe below/i.test(line)) continue;
138
- ingredients.push(line);
176
+ for (const piece of resplitCollapsedIngredientLine(raw)) {
177
+ const line = piece.trim();
178
+ if (!line) continue;
179
+ if (/^\[[^\]]+\]$/.test(line)) continue;
180
+ if (/^recipe below/i.test(line)) continue;
181
+ ingredients.push(line);
182
+ }
139
183
  }
140
184
 
141
185
  return ingredients;
@@ -143,9 +187,19 @@ export function parseIngredientsFromCaption(caption: string): string[] {
143
187
 
144
188
  export function parseInstructionsFromCaption(caption: string): string[] {
145
189
  const matches = [...caption.matchAll(/(?:^|\n)\s*(\d+)\.\s*([^\n]+)/g)];
146
- return matches
190
+ const steps = matches
147
191
  .map((m) => m[2]?.trim())
148
192
  .filter((s): s is string => Boolean(s && s.length > 3));
193
+ if (steps.length > 0) return steps;
194
+ // Collapsed caption (og:description strips newlines): fall back to slicing
195
+ // the text after an instructions header on the "1." "2." markers themselves.
196
+ const header = caption.match(/\b(?:instructions|directions|steps|method)\s*:/i);
197
+ if (!header || header.index == null) return [];
198
+ const block = caption.slice(header.index + header[0].length);
199
+ return block
200
+ .split(/(?=\b\d{1,2}\.\s)/)
201
+ .map((s) => s.replace(/^\d{1,2}\.\s*/, "").trim())
202
+ .filter((s) => s.length > 3);
149
203
  }
150
204
 
151
205
  export function parseRecipeCaption(caption: string): {