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

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.3",
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
  };
@@ -466,6 +466,72 @@ function buildParsedRecipeFromPostLenient(post) {
466
466
  return recipe;
467
467
  }
468
468
 
469
+ // src/image-dimensions.ts
470
+ function decodeImageDimensions(buffer) {
471
+ const b = new Uint8Array(buffer);
472
+ const view = new DataView(buffer);
473
+ if (b.length >= 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) {
474
+ return valid(view.getUint32(16), view.getUint32(20));
475
+ }
476
+ if (b.length >= 10 && b[0] === 71 && b[1] === 73 && b[2] === 70) {
477
+ return valid(view.getUint16(6, true), view.getUint16(8, true));
478
+ }
479
+ 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) {
480
+ const fourcc = String.fromCharCode(b[12], b[13], b[14], b[15]);
481
+ if (fourcc === "VP8X") {
482
+ const w = 1 + (b[24] | b[25] << 8 | b[26] << 16);
483
+ const h = 1 + (b[27] | b[28] << 8 | b[29] << 16);
484
+ return valid(w, h);
485
+ }
486
+ if (fourcc === "VP8 ") {
487
+ if (b[23] === 157 && b[24] === 1 && b[25] === 42) {
488
+ return valid(
489
+ view.getUint16(26, true) & 16383,
490
+ view.getUint16(28, true) & 16383
491
+ );
492
+ }
493
+ return null;
494
+ }
495
+ if (fourcc === "VP8L") {
496
+ if (b[20] !== 47) return null;
497
+ const bits = view.getUint32(21, true);
498
+ return valid((bits & 16383) + 1, (bits >> 14 & 16383) + 1);
499
+ }
500
+ return null;
501
+ }
502
+ if (b.length >= 4 && b[0] === 255 && b[1] === 216) {
503
+ let off = 2;
504
+ while (off + 9 < b.length) {
505
+ if (b[off] !== 255) {
506
+ off++;
507
+ continue;
508
+ }
509
+ const marker = b[off + 1];
510
+ if (marker === 255) {
511
+ off++;
512
+ continue;
513
+ }
514
+ if (marker === 1 || marker >= 208 && marker <= 217) {
515
+ off += 2;
516
+ continue;
517
+ }
518
+ const len = view.getUint16(off + 2);
519
+ if (len < 2) return null;
520
+ const isSOF = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
521
+ if (isSOF) {
522
+ if (off + 9 > b.length) return null;
523
+ return valid(view.getUint16(off + 7), view.getUint16(off + 5));
524
+ }
525
+ off += 2 + len;
526
+ }
527
+ return null;
528
+ }
529
+ return null;
530
+ }
531
+ function valid(width, height) {
532
+ return width > 0 && height > 0 ? { width, height } : null;
533
+ }
534
+
469
535
  // src/instagram-profile-image.ts
470
536
  var IG_APP_ID = "936619743392459";
471
537
  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 +1094,14 @@ async function uploadFeaturedImageBytes(ctx, recipe, bytes, contentType) {
1028
1094
  return void 0;
1029
1095
  }
1030
1096
  const filename = recipe.featured_image.filename || `${recipe.suggested_slug}.jpg`;
1097
+ const dims = decodeImageDimensions(bytes);
1031
1098
  const uploaded = await ctx.media.upload(filename, contentType, bytes);
1032
1099
  return {
1033
1100
  provider: "local",
1034
1101
  id: uploaded.mediaId,
1035
1102
  src: uploaded.url,
1036
1103
  mimeType: contentType,
1104
+ ...dims ?? {},
1037
1105
  meta: { storageKey: uploaded.storageKey }
1038
1106
  };
1039
1107
  }
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.3",
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. */
@@ -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
  }