@peristyle/emdash-plugin-instagram-to-recipe 0.1.2 → 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.2",
5
+ version: "0.1.4",
6
6
  format: "standard",
7
7
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
8
8
  options,
@@ -24,6 +24,8 @@ type ParsedInstagramRecipe = {
24
24
  id: string;
25
25
  src: string;
26
26
  mimeType?: string;
27
+ width?: number;
28
+ height?: number;
27
29
  meta?: {
28
30
  storageKey: string;
29
31
  };
@@ -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;
@@ -466,6 +491,72 @@ function buildParsedRecipeFromPostLenient(post) {
466
491
  return recipe;
467
492
  }
468
493
 
494
+ // src/image-dimensions.ts
495
+ function decodeImageDimensions(buffer) {
496
+ const b = new Uint8Array(buffer);
497
+ const view = new DataView(buffer);
498
+ if (b.length >= 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) {
499
+ return valid(view.getUint32(16), view.getUint32(20));
500
+ }
501
+ if (b.length >= 10 && b[0] === 71 && b[1] === 73 && b[2] === 70) {
502
+ return valid(view.getUint16(6, true), view.getUint16(8, true));
503
+ }
504
+ if (b.length >= 30 && b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80) {
505
+ const fourcc = String.fromCharCode(b[12], b[13], b[14], b[15]);
506
+ if (fourcc === "VP8X") {
507
+ const w = 1 + (b[24] | b[25] << 8 | b[26] << 16);
508
+ const h = 1 + (b[27] | b[28] << 8 | b[29] << 16);
509
+ return valid(w, h);
510
+ }
511
+ if (fourcc === "VP8 ") {
512
+ if (b[23] === 157 && b[24] === 1 && b[25] === 42) {
513
+ return valid(
514
+ view.getUint16(26, true) & 16383,
515
+ view.getUint16(28, true) & 16383
516
+ );
517
+ }
518
+ return null;
519
+ }
520
+ if (fourcc === "VP8L") {
521
+ if (b[20] !== 47) return null;
522
+ const bits = view.getUint32(21, true);
523
+ return valid((bits & 16383) + 1, (bits >> 14 & 16383) + 1);
524
+ }
525
+ return null;
526
+ }
527
+ if (b.length >= 4 && b[0] === 255 && b[1] === 216) {
528
+ let off = 2;
529
+ while (off + 9 < b.length) {
530
+ if (b[off] !== 255) {
531
+ off++;
532
+ continue;
533
+ }
534
+ const marker = b[off + 1];
535
+ if (marker === 255) {
536
+ off++;
537
+ continue;
538
+ }
539
+ if (marker === 1 || marker >= 208 && marker <= 217) {
540
+ off += 2;
541
+ continue;
542
+ }
543
+ const len = view.getUint16(off + 2);
544
+ if (len < 2) return null;
545
+ const isSOF = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
546
+ if (isSOF) {
547
+ if (off + 9 > b.length) return null;
548
+ return valid(view.getUint16(off + 7), view.getUint16(off + 5));
549
+ }
550
+ off += 2 + len;
551
+ }
552
+ return null;
553
+ }
554
+ return null;
555
+ }
556
+ function valid(width, height) {
557
+ return width > 0 && height > 0 ? { width, height } : null;
558
+ }
559
+
469
560
  // src/instagram-profile-image.ts
470
561
  var IG_APP_ID = "936619743392459";
471
562
  var BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@@ -1028,12 +1119,14 @@ async function uploadFeaturedImageBytes(ctx, recipe, bytes, contentType) {
1028
1119
  return void 0;
1029
1120
  }
1030
1121
  const filename = recipe.featured_image.filename || `${recipe.suggested_slug}.jpg`;
1122
+ const dims = decodeImageDimensions(bytes);
1031
1123
  const uploaded = await ctx.media.upload(filename, contentType, bytes);
1032
1124
  return {
1033
1125
  provider: "local",
1034
1126
  id: uploaded.mediaId,
1035
1127
  src: uploaded.url,
1036
1128
  mimeType: contentType,
1129
+ ...dims ?? {},
1037
1130
  meta: { storageKey: uploaded.storageKey }
1038
1131
  };
1039
1132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peristyle/emdash-plugin-instagram-to-recipe",
3
- "version": "0.1.2",
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": {
@@ -21,6 +21,15 @@
21
21
  "dist",
22
22
  "src"
23
23
  ],
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "dev": "tsup --watch",
27
+ "typecheck": "tsc --noEmit",
28
+ "preversion": "pnpm typecheck",
29
+ "version": "pnpm build",
30
+ "postversion": "git push --follow-tags",
31
+ "prepublishOnly": "pnpm build"
32
+ },
24
33
  "peerDependencies": {
25
34
  "astro": ">=6.0.0-beta.0",
26
35
  "emdash": "^0.14.0"
@@ -29,13 +38,5 @@
29
38
  "emdash": "^0.14.0",
30
39
  "tsup": "^8.0.0",
31
40
  "typescript": "^5.0.0"
32
- },
33
- "scripts": {
34
- "build": "tsup",
35
- "dev": "tsup --watch",
36
- "typecheck": "tsc --noEmit",
37
- "preversion": "pnpm typecheck",
38
- "version": "pnpm build",
39
- "postversion": "git push --follow-tags"
40
41
  }
41
- }
42
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Decode intrinsic pixel dimensions from raw image bytes.
3
+ *
4
+ * Sandboxed plugins can't use sharp or canvas, so this reads the container
5
+ * headers directly. Supports the formats Instagram serves (JPEG) plus PNG,
6
+ * GIF, and WebP for robustness. Returns null for anything it can't parse —
7
+ * callers should treat dimensions as best-effort metadata.
8
+ */
9
+ export function decodeImageDimensions(
10
+ buffer: ArrayBuffer,
11
+ ): { width: number; height: number } | null {
12
+ const b = new Uint8Array(buffer);
13
+ const view = new DataView(buffer);
14
+
15
+ // PNG: 8-byte signature, then IHDR chunk — width/height at offsets 16/20
16
+ if (
17
+ b.length >= 24 &&
18
+ b[0] === 0x89 &&
19
+ b[1] === 0x50 &&
20
+ b[2] === 0x4e &&
21
+ b[3] === 0x47
22
+ ) {
23
+ return valid(view.getUint32(16), view.getUint32(20));
24
+ }
25
+
26
+ // GIF87a / GIF89a: little-endian u16 logical screen size at offsets 6/8
27
+ if (b.length >= 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) {
28
+ return valid(view.getUint16(6, true), view.getUint16(8, true));
29
+ }
30
+
31
+ // WebP: RIFF container with WEBP form type
32
+ if (
33
+ b.length >= 30 &&
34
+ b[0] === 0x52 &&
35
+ b[1] === 0x49 &&
36
+ b[2] === 0x46 &&
37
+ b[3] === 0x46 &&
38
+ b[8] === 0x57 &&
39
+ b[9] === 0x45 &&
40
+ b[10] === 0x42 &&
41
+ b[11] === 0x50
42
+ ) {
43
+ const fourcc = String.fromCharCode(b[12], b[13], b[14], b[15]);
44
+ if (fourcc === "VP8X") {
45
+ // Extended header: 24-bit little-endian canvas size minus one
46
+ const w = 1 + (b[24] | (b[25] << 8) | (b[26] << 16));
47
+ const h = 1 + (b[27] | (b[28] << 8) | (b[29] << 16));
48
+ return valid(w, h);
49
+ }
50
+ if (fourcc === "VP8 ") {
51
+ // Lossy: frame start code 9d 01 2a, then 14-bit dimensions
52
+ if (b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
53
+ return valid(
54
+ view.getUint16(26, true) & 0x3fff,
55
+ view.getUint16(28, true) & 0x3fff,
56
+ );
57
+ }
58
+ return null;
59
+ }
60
+ if (fourcc === "VP8L") {
61
+ // Lossless: signature byte 0x2f, then 14+14 bits, each minus one
62
+ if (b[20] !== 0x2f) return null;
63
+ const bits = view.getUint32(21, true);
64
+ return valid((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1);
65
+ }
66
+ return null;
67
+ }
68
+
69
+ // JPEG: scan markers for a start-of-frame (SOF0–SOF15, excluding
70
+ // DHT/JPG/DAC which share the 0xC0 range but aren't frames)
71
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) {
72
+ let off = 2;
73
+ while (off + 9 < b.length) {
74
+ if (b[off] !== 0xff) {
75
+ off++;
76
+ continue;
77
+ }
78
+ const marker = b[off + 1];
79
+ if (marker === 0xff) {
80
+ off++;
81
+ continue;
82
+ }
83
+ // Standalone markers (no length field)
84
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
85
+ off += 2;
86
+ continue;
87
+ }
88
+ const len = view.getUint16(off + 2);
89
+ if (len < 2) return null;
90
+ const isSOF =
91
+ marker >= 0xc0 &&
92
+ marker <= 0xcf &&
93
+ marker !== 0xc4 &&
94
+ marker !== 0xc8 &&
95
+ marker !== 0xcc;
96
+ if (isSOF) {
97
+ if (off + 9 > b.length) return null;
98
+ return valid(view.getUint16(off + 7), view.getUint16(off + 5));
99
+ }
100
+ off += 2 + len;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ function valid(
109
+ width: number,
110
+ height: number,
111
+ ): { width: number; height: number } | null {
112
+ return width > 0 && height > 0 ? { width, height } : null;
113
+ }
@@ -31,6 +31,8 @@ export type ParsedInstagramRecipe = {
31
31
  id: string;
32
32
  src: string;
33
33
  mimeType?: string;
34
+ width?: number;
35
+ height?: number;
34
36
  meta?: { storageKey: string };
35
37
  };
36
38
  /** True when convert could not upload the image to media library. */
@@ -115,25 +117,69 @@ export function extractTitleFromCaption(caption: string): string {
115
117
  return titleCase(firstSentence.slice(0, 68).trim());
116
118
  }
117
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
+
118
153
  export function parseIngredientsFromCaption(caption: string): string[] {
119
154
  const match = caption.match(/what you['']ll need:?|ingredients\s*:/i);
120
155
  if (!match || match.index == null) return [];
121
156
 
122
157
  const afterHeader = caption.slice(match.index + match[0].length);
123
158
  const stepsMatch = afterHeader.match(/(?:^|\n)\s*\d+\.\s+/);
124
- const ingredientsBlock = stepsMatch?.index
159
+ let ingredientsBlock = stepsMatch?.index
125
160
  ? afterHeader.slice(0, stepsMatch.index)
126
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
+ }
127
171
 
128
172
  const lines = ingredientsBlock.split(/\n/);
129
173
  const ingredients: string[] = [];
130
174
 
131
175
  for (const raw of lines) {
132
- const line = raw.trim();
133
- if (!line) continue;
134
- if (/^\[[^\]]+\]$/.test(line)) continue;
135
- if (/^recipe below/i.test(line)) continue;
136
- 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
+ }
137
183
  }
138
184
 
139
185
  return ingredients;
@@ -141,9 +187,19 @@ export function parseIngredientsFromCaption(caption: string): string[] {
141
187
 
142
188
  export function parseInstructionsFromCaption(caption: string): string[] {
143
189
  const matches = [...caption.matchAll(/(?:^|\n)\s*(\d+)\.\s*([^\n]+)/g)];
144
- return matches
190
+ const steps = matches
145
191
  .map((m) => m[2]?.trim())
146
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);
147
203
  }
148
204
 
149
205
  export function parseRecipeCaption(caption: string): {
@@ -71,6 +71,8 @@ export type RecipePostWriteData = {
71
71
  alt?: string;
72
72
  filename?: string;
73
73
  mimeType?: string;
74
+ width?: number;
75
+ height?: number;
74
76
  meta?: { storageKey: string };
75
77
  };
76
78
  difficulty?: string;
@@ -16,6 +16,7 @@ import {
16
16
  buildParsedRecipeFromPostLenient,
17
17
  type InstagramPostForRecipes,
18
18
  } from "./instagram-recipes.js";
19
+ import { decodeImageDimensions } from "./image-dimensions.js";
19
20
  import {
20
21
  fetchInstagramPost,
21
22
  instagramBrowserUserAgent,
@@ -163,6 +164,11 @@ async function uploadFeaturedImageBytes(
163
164
  const filename =
164
165
  recipe.featured_image.filename || `${recipe.suggested_slug}.jpg`;
165
166
 
167
+ // Decode intrinsic dimensions before upload so the MediaValue stored in
168
+ // content carries width/height — the site needs them for aspect-ratio
169
+ // (CLS protection) and responsive srcset generation.
170
+ const dims = decodeImageDimensions(bytes);
171
+
166
172
  const uploaded = await ctx.media.upload(filename, contentType, bytes);
167
173
 
168
174
  return {
@@ -170,6 +176,7 @@ async function uploadFeaturedImageBytes(
170
176
  id: uploaded.mediaId,
171
177
  src: uploaded.url,
172
178
  mimeType: contentType,
179
+ ...(dims ?? {}),
173
180
  meta: { storageKey: uploaded.storageKey },
174
181
  };
175
182
  }