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

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.5",
6
6
  format: "standard",
7
7
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
8
8
  options,
@@ -60,6 +60,12 @@ declare const _default: {
60
60
  "plugin:install": {
61
61
  handler: (_event: unknown, ctx: PluginContext) => Promise<void>;
62
62
  };
63
+ "content:afterDelete": {
64
+ handler: (event: {
65
+ id: string;
66
+ collection: string;
67
+ }, ctx: PluginContext) => Promise<void>;
68
+ };
63
69
  };
64
70
  routes: {
65
71
  parse: {
@@ -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;
@@ -1015,6 +1040,7 @@ var instagramBrowserUserAgent = BROWSER_UA2;
1015
1040
  var DEFAULT_COLLECTION = "posts";
1016
1041
  var PARSE_CACHE_PREFIX = "parse:";
1017
1042
  var IMPORT_MAP_PREFIX = "imported:";
1043
+ var IMPORT_BY_ID_PREFIX = "imported-by-id:";
1018
1044
  var PROFILE_CACHE_PREFIX = "igprofile:";
1019
1045
  var BULK_CREATE_DELAY_MS = 600;
1020
1046
  function sleep2(ms) {
@@ -1029,6 +1055,9 @@ function parseCacheKey(shortcode) {
1029
1055
  function importMapKey(shortcode) {
1030
1056
  return `${IMPORT_MAP_PREFIX}${shortcode}`;
1031
1057
  }
1058
+ function importByIdKey(contentId) {
1059
+ return `${IMPORT_BY_ID_PREFIX}${contentId}`;
1060
+ }
1032
1061
  async function httpFetch(ctx, url, init) {
1033
1062
  if (!ctx.http) {
1034
1063
  throw new Error("Network access is not available for this plugin.");
@@ -1163,6 +1192,7 @@ async function createDraftFromRecipe(ctx, shortcode) {
1163
1192
  const data = recipeToPostWriteData(recipe, featuredImage);
1164
1193
  const item = await ctx.content.create(collection, data);
1165
1194
  await ctx.kv.set(importMapKey(shortcode), item.id);
1195
+ await ctx.kv.set(importByIdKey(item.id), shortcode);
1166
1196
  return {
1167
1197
  contentId: item.id,
1168
1198
  title: recipe.title,
@@ -1245,6 +1275,19 @@ var sandbox_entry_default = {
1245
1275
  await ctx.kv.set("config:collection", DEFAULT_COLLECTION);
1246
1276
  }
1247
1277
  }
1278
+ },
1279
+ // Deleting an imported recipe post doesn't otherwise touch this plugin's
1280
+ // own "imported:<shortcode>" bookkeeping, so the bulk-scan UI keeps
1281
+ // treating the source Instagram post as already imported forever.
1282
+ "content:afterDelete": {
1283
+ handler: async (event, ctx) => {
1284
+ const collection = await collectionSlug(ctx);
1285
+ if (event.collection !== collection) return;
1286
+ const shortcode = await ctx.kv.get(importByIdKey(event.id));
1287
+ if (!shortcode) return;
1288
+ await ctx.kv.delete(importByIdKey(event.id));
1289
+ await ctx.kv.delete(importMapKey(shortcode));
1290
+ }
1248
1291
  }
1249
1292
  },
1250
1293
  routes: {
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.5",
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): {
@@ -34,6 +34,7 @@ import { recipeToPostWriteData } from "./recipe-content.js";
34
34
  const DEFAULT_COLLECTION = "posts";
35
35
  const PARSE_CACHE_PREFIX = "parse:";
36
36
  const IMPORT_MAP_PREFIX = "imported:";
37
+ const IMPORT_BY_ID_PREFIX = "imported-by-id:";
37
38
  const PROFILE_CACHE_PREFIX = "igprofile:";
38
39
 
39
40
  /** Delay between draft creations so per-post image downloads stay polite. */
@@ -64,6 +65,10 @@ function importMapKey(shortcode: string): string {
64
65
  return `${IMPORT_MAP_PREFIX}${shortcode}`;
65
66
  }
66
67
 
68
+ function importByIdKey(contentId: string): string {
69
+ return `${IMPORT_BY_ID_PREFIX}${contentId}`;
70
+ }
71
+
67
72
  async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
68
73
  if (!ctx.http) {
69
74
  throw new Error("Network access is not available for this plugin.");
@@ -261,6 +266,7 @@ async function createDraftFromRecipe(
261
266
 
262
267
  const item = await ctx.content.create(collection, data);
263
268
  await ctx.kv.set(importMapKey(shortcode), item.id);
269
+ await ctx.kv.set(importByIdKey(item.id), shortcode);
264
270
 
265
271
  return {
266
272
  contentId: item.id,
@@ -397,6 +403,24 @@ export default {
397
403
  }
398
404
  },
399
405
  },
406
+ // Deleting an imported recipe post doesn't otherwise touch this plugin's
407
+ // own "imported:<shortcode>" bookkeeping, so the bulk-scan UI keeps
408
+ // treating the source Instagram post as already imported forever.
409
+ "content:afterDelete": {
410
+ handler: async (
411
+ event: { id: string; collection: string },
412
+ ctx: PluginContext,
413
+ ) => {
414
+ const collection = await collectionSlug(ctx);
415
+ if (event.collection !== collection) return;
416
+
417
+ const shortcode = await ctx.kv.get<string>(importByIdKey(event.id));
418
+ if (!shortcode) return;
419
+
420
+ await ctx.kv.delete(importByIdKey(event.id));
421
+ await ctx.kv.delete(importMapKey(shortcode));
422
+ },
423
+ },
400
424
  },
401
425
 
402
426
  routes: {