@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 +1 -1
- package/dist/sandbox-entry.d.ts +2 -0
- package/dist/sandbox-entry.js +100 -7
- package/package.json +11 -10
- package/src/image-dimensions.ts +113 -0
- package/src/instagram-recipes.ts +63 -7
- package/src/recipe-content.ts +2 -0
- package/src/sandbox-entry.ts +7 -0
package/dist/index.js
CHANGED
package/dist/sandbox-entry.d.ts
CHANGED
package/dist/sandbox-entry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/instagram-recipes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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): {
|
package/src/recipe-content.ts
CHANGED
package/src/sandbox-entry.ts
CHANGED
|
@@ -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
|
}
|