@peristyle/emdash-plugin-instagram-to-recipe 0.1.0

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.
@@ -0,0 +1,116 @@
1
+ import type { ParsedInstagramRecipe } from "./instagram-recipes.js";
2
+
3
+ type PortableTextBlock = {
4
+ _type: "block";
5
+ _key: string;
6
+ style: string;
7
+ listItem?: "bullet" | "number";
8
+ level?: number;
9
+ children: Array<{
10
+ _type: "span";
11
+ _key: string;
12
+ text: string;
13
+ }>;
14
+ };
15
+
16
+ function listBlocks(
17
+ items: string[],
18
+ prefix: string,
19
+ listItem: "bullet" | "number",
20
+ ): PortableTextBlock[] {
21
+ return items.map((text, index) => ({
22
+ _type: "block",
23
+ _key: `${prefix}${index}`,
24
+ style: "normal",
25
+ listItem,
26
+ level: 1,
27
+ children: [
28
+ {
29
+ _type: "span",
30
+ _key: `${prefix}${index}s`,
31
+ text,
32
+ },
33
+ ],
34
+ }));
35
+ }
36
+
37
+ export function recipeToPortableTextFields(recipe: ParsedInstagramRecipe): {
38
+ ingredients: PortableTextBlock[];
39
+ recipe_instructions: PortableTextBlock[];
40
+ } {
41
+ const ingredients = listBlocks(recipe.ingredients, "ing", "bullet");
42
+
43
+ const recipe_instructions: PortableTextBlock[] = [
44
+ {
45
+ _type: "block",
46
+ _key: "ri-heading",
47
+ style: "h3",
48
+ children: [
49
+ {
50
+ _type: "span",
51
+ _key: "ri-heading-s",
52
+ text: "Instructions",
53
+ },
54
+ ],
55
+ },
56
+ ...listBlocks(recipe.instructions, "ri", "number"),
57
+ ];
58
+
59
+ return { ingredients, recipe_instructions };
60
+ }
61
+
62
+ export type RecipePostWriteData = {
63
+ title: string;
64
+ excerpt: string;
65
+ ingredients: PortableTextBlock[];
66
+ recipe_instructions: PortableTextBlock[];
67
+ featured_image?: {
68
+ provider?: string;
69
+ id: string;
70
+ src: string;
71
+ alt?: string;
72
+ filename?: string;
73
+ mimeType?: string;
74
+ meta?: { storageKey: string };
75
+ };
76
+ difficulty?: string;
77
+ };
78
+
79
+ export function recipeToPostWriteData(
80
+ recipe: ParsedInstagramRecipe,
81
+ featuredMedia?: RecipePostWriteData["featured_image"],
82
+ ): RecipePostWriteData {
83
+ const { ingredients, recipe_instructions } = recipeToPortableTextFields(recipe);
84
+
85
+ const data: RecipePostWriteData = {
86
+ title: recipe.title,
87
+ excerpt: recipe.excerpt,
88
+ ingredients,
89
+ recipe_instructions,
90
+ difficulty: "easy",
91
+ };
92
+
93
+ if (featuredMedia) {
94
+ data.featured_image = {
95
+ ...featuredMedia,
96
+ alt: recipe.featured_image?.alt ?? featuredMedia.alt,
97
+ filename: recipe.featured_image?.filename ?? featuredMedia.filename,
98
+ };
99
+ }
100
+
101
+ return data;
102
+ }
103
+
104
+ export function previewLines(recipe: ParsedInstagramRecipe): string[] {
105
+ const lines = [
106
+ `Title: ${recipe.title}`,
107
+ `Suggested slug: ${recipe.suggested_slug}`,
108
+ `Ingredients: ${recipe.ingredients.length}`,
109
+ `Steps: ${recipe.instructions.length}`,
110
+ `Source: ${recipe.source.instagram_url}`,
111
+ ];
112
+ if (recipe.featured_image) {
113
+ lines.push("Featured image: yes");
114
+ }
115
+ return lines;
116
+ }
@@ -0,0 +1,371 @@
1
+ import type { PluginContext } from "emdash";
2
+ import { z } from "astro/zod";
3
+
4
+ import {
5
+ errorBlocks,
6
+ importFormBlocks,
7
+ previewBlocks,
8
+ } from "./admin-blocks.js";
9
+ import {
10
+ buildParsedRecipeFromPost,
11
+ buildParsedRecipeFromPostLenient,
12
+ } from "./instagram-recipes.js";
13
+ import {
14
+ fetchInstagramPost,
15
+ instagramBrowserUserAgent,
16
+ parseInstagramPostUrl,
17
+ } from "./parse-instagram-post.js";
18
+ import { recipeToPostWriteData } from "./recipe-content.js";
19
+
20
+ const DEFAULT_COLLECTION = "posts";
21
+ const PARSE_CACHE_PREFIX = "parse:";
22
+ const IMPORT_MAP_PREFIX = "imported:";
23
+
24
+ type AdminInteraction = {
25
+ type: string;
26
+ page?: string;
27
+ action_id?: string;
28
+ block_id?: string;
29
+ values?: Record<string, string | boolean | number>;
30
+ value?: unknown;
31
+ };
32
+
33
+ async function collectionSlug(ctx: PluginContext): Promise<string> {
34
+ return (await ctx.kv.get<string>("config:collection")) ?? DEFAULT_COLLECTION;
35
+ }
36
+
37
+ function parseCacheKey(shortcode: string): string {
38
+ return `${PARSE_CACHE_PREFIX}${shortcode}`;
39
+ }
40
+
41
+ function importMapKey(shortcode: string): string {
42
+ return `${IMPORT_MAP_PREFIX}${shortcode}`;
43
+ }
44
+
45
+ async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
46
+ if (!ctx.http) {
47
+ throw new Error("Network access is not available for this plugin.");
48
+ }
49
+ return ctx.http.fetch(url, init);
50
+ }
51
+
52
+ async function convertInstagramUrl(
53
+ ctx: PluginContext,
54
+ instagramUrl: string,
55
+ ): Promise<{
56
+ recipe: NonNullable<ReturnType<typeof buildParsedRecipeFromPost>>;
57
+ existingContentId?: string;
58
+ }> {
59
+ const fetchFn = (input: string, init?: RequestInit) =>
60
+ httpFetch(ctx, input, init);
61
+
62
+ const instagramHandle = await ctx.kv.get<string>("config:instagram_handle");
63
+ const post = await fetchInstagramPost(instagramUrl, fetchFn, {
64
+ instagramHandle: instagramHandle ?? undefined,
65
+ });
66
+ const recipe =
67
+ buildParsedRecipeFromPost({
68
+ shortcode: post.shortcode,
69
+ caption: post.caption,
70
+ displayUrl: post.displayUrl,
71
+ likes: 0,
72
+ isVideo: post.isVideo,
73
+ postUrl: post.postUrl,
74
+ }) ??
75
+ buildParsedRecipeFromPostLenient({
76
+ shortcode: post.shortcode,
77
+ caption: post.caption,
78
+ displayUrl: post.displayUrl,
79
+ likes: 0,
80
+ isVideo: post.isVideo,
81
+ postUrl: post.postUrl,
82
+ });
83
+
84
+ if (!recipe) {
85
+ throw new Error(
86
+ "Caption does not look like a recipe (needs ingredients and numbered steps). Try a post with a “What you'll need” section and numbered instructions.",
87
+ );
88
+ }
89
+
90
+ if (post.imageNotice) {
91
+ recipe.image_notice = post.imageNotice;
92
+ }
93
+
94
+ if (recipe.featured_image) {
95
+ const uploaded = await uploadFeaturedImageForRecipe(
96
+ ctx,
97
+ recipe,
98
+ post.imageBytes,
99
+ post.imageContentType,
100
+ );
101
+ if (uploaded) {
102
+ recipe.featured_image.emdash_media = uploaded;
103
+ } else if (recipe.featured_image.url) {
104
+ recipe.featured_image.upload_failed = true;
105
+ }
106
+ }
107
+
108
+ await ctx.kv.set(parseCacheKey(post.shortcode), recipe);
109
+
110
+ const existingContentId =
111
+ (await ctx.kv.get<string>(importMapKey(post.shortcode))) ?? undefined;
112
+
113
+ return { recipe, existingContentId };
114
+ }
115
+
116
+ type UploadedFeaturedImage = NonNullable<
117
+ NonNullable<ReturnType<typeof buildParsedRecipeFromPost>>["featured_image"]
118
+ >["emdash_media"];
119
+
120
+ async function uploadFeaturedImageBytes(
121
+ ctx: PluginContext,
122
+ recipe: NonNullable<ReturnType<typeof buildParsedRecipeFromPost>>,
123
+ bytes: ArrayBuffer,
124
+ contentType: string,
125
+ ): Promise<UploadedFeaturedImage | undefined> {
126
+ if (!ctx.media?.upload || !recipe.featured_image) {
127
+ return undefined;
128
+ }
129
+
130
+ const filename =
131
+ recipe.featured_image.filename || `${recipe.suggested_slug}.jpg`;
132
+
133
+ const uploaded = await ctx.media.upload(filename, contentType, bytes);
134
+
135
+ return {
136
+ provider: "local",
137
+ id: uploaded.mediaId,
138
+ src: uploaded.url,
139
+ mimeType: contentType,
140
+ meta: { storageKey: uploaded.storageKey },
141
+ };
142
+ }
143
+
144
+ /** Upload during convert (preferred) or re-fetch from Instagram on create draft. */
145
+ async function uploadFeaturedImageForRecipe(
146
+ ctx: PluginContext,
147
+ recipe: NonNullable<ReturnType<typeof buildParsedRecipeFromPost>>,
148
+ imageBytes?: ArrayBuffer,
149
+ imageContentType?: string,
150
+ ): Promise<UploadedFeaturedImage | undefined> {
151
+ if (!recipe.featured_image || !ctx.media?.upload) {
152
+ return undefined;
153
+ }
154
+
155
+ if (imageBytes && imageContentType) {
156
+ return uploadFeaturedImageBytes(
157
+ ctx,
158
+ recipe,
159
+ imageBytes,
160
+ imageContentType,
161
+ );
162
+ }
163
+
164
+ if (!recipe.featured_image.url) {
165
+ return undefined;
166
+ }
167
+
168
+ const res = await httpFetch(ctx, recipe.featured_image.url, {
169
+ headers: {
170
+ "User-Agent": instagramBrowserUserAgent,
171
+ Referer: recipe.source.instagram_url,
172
+ Accept: "image/*,*/*;q=0.8",
173
+ },
174
+ });
175
+
176
+ if (!res.ok) {
177
+ ctx.log.warn(`Featured image download failed: HTTP ${res.status}`);
178
+ return undefined;
179
+ }
180
+
181
+ const contentType = res.headers.get("content-type") ?? "image/jpeg";
182
+ if (!contentType.startsWith("image/")) {
183
+ ctx.log.warn(
184
+ `Featured image download returned non-image content-type: ${contentType}`,
185
+ );
186
+ return undefined;
187
+ }
188
+
189
+ return uploadFeaturedImageBytes(
190
+ ctx,
191
+ recipe,
192
+ await res.arrayBuffer(),
193
+ contentType,
194
+ );
195
+ }
196
+
197
+ async function createDraftFromRecipe(
198
+ ctx: PluginContext,
199
+ shortcode: string,
200
+ ): Promise<{ contentId: string; title: string; collection: string }> {
201
+ if (!ctx.content?.create) {
202
+ throw new Error("This plugin does not have permission to create content.");
203
+ }
204
+
205
+ const recipe = await ctx.kv.get<
206
+ NonNullable<ReturnType<typeof buildParsedRecipeFromPost>>
207
+ >(parseCacheKey(shortcode));
208
+
209
+ if (!recipe) {
210
+ throw new Error(
211
+ "Parsed recipe expired. Convert the Instagram URL again before creating a draft.",
212
+ );
213
+ }
214
+
215
+ const collection = await collectionSlug(ctx);
216
+ let featuredImage = recipe.featured_image?.emdash_media;
217
+ if (!featuredImage && recipe.featured_image?.url) {
218
+ featuredImage = await uploadFeaturedImageForRecipe(ctx, recipe);
219
+ }
220
+ const data = recipeToPostWriteData(recipe, featuredImage);
221
+
222
+ const item = await ctx.content.create(collection, data);
223
+ await ctx.kv.set(importMapKey(shortcode), item.id);
224
+
225
+ return {
226
+ contentId: item.id,
227
+ title: recipe.title,
228
+ collection,
229
+ };
230
+ }
231
+
232
+ export default {
233
+ hooks: {
234
+ "plugin:install": {
235
+ handler: async (_event: unknown, ctx: PluginContext) => {
236
+ ctx.log.info("Instagram to Recipe plugin installed");
237
+ const existing = await ctx.kv.get("config:collection");
238
+ if (!existing) {
239
+ await ctx.kv.set("config:collection", DEFAULT_COLLECTION);
240
+ }
241
+ },
242
+ },
243
+ },
244
+
245
+ routes: {
246
+ parse: {
247
+ input: z.object({
248
+ url: z.string().min(1),
249
+ }),
250
+ handler: async (routeCtx: { input: { url: string } }, ctx: PluginContext) => {
251
+ const { recipe, existingContentId } = await convertInstagramUrl(
252
+ ctx,
253
+ routeCtx.input.url,
254
+ );
255
+ return { ok: true, recipe, existingContentId };
256
+ },
257
+ },
258
+
259
+ "create-draft": {
260
+ input: z.object({
261
+ shortcode: z.string().min(1),
262
+ }),
263
+ handler: async (
264
+ routeCtx: { input: { shortcode: string } },
265
+ ctx: PluginContext,
266
+ ) => {
267
+ const result = await createDraftFromRecipe(ctx, routeCtx.input.shortcode);
268
+ return { ok: true, ...result };
269
+ },
270
+ },
271
+
272
+ admin: {
273
+ handler: async (routeCtx: { input?: AdminInteraction }, ctx: PluginContext) => {
274
+ const interaction = routeCtx.input ?? { type: "page_load" };
275
+
276
+ if (interaction.type === "page_load") {
277
+ return { blocks: importFormBlocks() };
278
+ }
279
+
280
+ const instagramUrl = String(
281
+ interaction.values?.instagram_url ?? "",
282
+ ).trim();
283
+
284
+ if (
285
+ interaction.type === "form_submit" &&
286
+ interaction.action_id === "convert"
287
+ ) {
288
+ try {
289
+ if (!instagramUrl) {
290
+ return {
291
+ blocks: errorBlocks("Paste an Instagram post URL."),
292
+ toast: { message: "URL required", type: "error" },
293
+ };
294
+ }
295
+
296
+ parseInstagramPostUrl(instagramUrl);
297
+ const { recipe, existingContentId } = await convertInstagramUrl(
298
+ ctx,
299
+ instagramUrl,
300
+ );
301
+
302
+ return {
303
+ blocks: previewBlocks(recipe, { existingContentId }),
304
+ toast: { message: "Recipe parsed", type: "success" },
305
+ };
306
+ } catch (error) {
307
+ const message =
308
+ error instanceof Error ? error.message : String(error);
309
+ return {
310
+ blocks: errorBlocks(message, instagramUrl),
311
+ toast: { message: message, type: "error" },
312
+ };
313
+ }
314
+ }
315
+
316
+ if (
317
+ (interaction.type === "block_action" ||
318
+ interaction.type === "button_click") &&
319
+ interaction.action_id === "create_draft"
320
+ ) {
321
+ const shortcode = String(interaction.value ?? "").trim();
322
+ if (!shortcode) {
323
+ return {
324
+ blocks: errorBlocks(
325
+ "Missing post reference. Convert the URL again.",
326
+ instagramUrl,
327
+ ),
328
+ toast: { message: "Try converting again", type: "error" },
329
+ };
330
+ }
331
+
332
+ try {
333
+ const { contentId, title, collection } =
334
+ await createDraftFromRecipe(ctx, shortcode);
335
+
336
+ const recipe = await ctx.kv.get<
337
+ NonNullable<ReturnType<typeof buildParsedRecipeFromPost>>
338
+ >(parseCacheKey(shortcode));
339
+
340
+ return {
341
+ blocks: recipe
342
+ ? [
343
+ ...previewBlocks(recipe, { existingContentId: contentId }),
344
+ {
345
+ type: "banner",
346
+ title: "Draft created",
347
+ description: `“${title}” was saved to ${collection}. Open Posts in the admin to review and publish.`,
348
+ variant: "default",
349
+ },
350
+ ]
351
+ : importFormBlocks(),
352
+ toast: {
353
+ message: `Draft created: ${title}`,
354
+ type: "success",
355
+ },
356
+ };
357
+ } catch (error) {
358
+ const message =
359
+ error instanceof Error ? error.message : String(error);
360
+ return {
361
+ blocks: errorBlocks(message, instagramUrl),
362
+ toast: { message, type: "error" },
363
+ };
364
+ }
365
+ }
366
+
367
+ return { blocks: importFormBlocks(instagramUrl) };
368
+ },
369
+ },
370
+ },
371
+ };