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

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/README.md CHANGED
@@ -1,22 +1,19 @@
1
1
  # @peristyle/emdash-plugin-instagram-to-recipe
2
2
 
3
- Import a public Instagram post into your food blog as a draft recipe from the EmDash admin.
3
+ Import public Instagram posts into your [EmDash](https://www.npmjs.com/package/emdash) site as draft recipe posts one at a time by URL, or in bulk by scanning a profile.
4
4
 
5
- Paste a post URL such as [instagram.com/p/DXkhLGbEcOY](https://www.instagram.com/p/DXkhLGbEcOY/) and the plugin will:
5
+ For a post URL such as [instagram.com/p/DXkhLGbEcOY](https://www.instagram.com/p/DXkhLGbEcOY/) the plugin will:
6
6
 
7
- 1. Fetch the post page and read the caption from Open Graph metadata
8
- 2. Parse ingredients (`What you'll need` / `Ingredients:`) and numbered steps
9
- 3. Download the post **feed image** (`display_url` from embedded JSON — same source as brand-bootstrap — not the OG share-card with a play button)
10
- 4. Create a **draft** post in your `posts` collection
11
-
12
- Parsing logic matches [brand-bootstrap](https://github.com/time-to-eat/init-brand-resources) Instagram recipe extraction.
7
+ 1. Fetch the post and read the caption (profile API first, HTML Open Graph metadata as fallback)
8
+ 2. Parse ingredients (`What you'll need` / `Ingredients:`) and numbered steps — including captions Instagram has collapsed onto one line
9
+ 3. Download the post **feed image** (the embedded `display_url`, not the OG share-card with a play button) and store its dimensions for responsive images
10
+ 4. Create a **draft** post in your configured collection
13
11
 
14
12
  ## Install
15
13
 
16
- From your EmDash site (e.g. `food-blog-base`):
17
-
18
14
  ```bash
19
- pnpm add ../plugins/instagram-to-recipe
15
+ pnpm add @peristyle/emdash-plugin-instagram-to-recipe
16
+ # or: npm install / yarn add
20
17
  ```
21
18
 
22
19
  Register in `astro.config.mjs`:
@@ -27,12 +24,7 @@ import { instagramToRecipePlugin } from "@peristyle/emdash-plugin-instagram-to-r
27
24
  export default defineConfig({
28
25
  integrations: [
29
26
  emdash({
30
- plugins: [
31
- recipesPlugin(),
32
- instagramToRecipePlugin(),
33
- // optional: custom collection slug
34
- // instagramToRecipePlugin({ collection: "posts" }),
35
- ],
27
+ plugins: [instagramToRecipePlugin()],
36
28
  }),
37
29
  ],
38
30
  });
@@ -42,51 +34,74 @@ Rebuild or restart the dev server after adding the plugin.
42
34
 
43
35
  ## Admin usage
44
36
 
45
- 1. Open **Plugins → Instagram to Recipe Import from Instagram**
46
- 2. Paste an Instagram post URL
47
- 3. Click **Convert to recipe** and review the preview
48
- 4. Click **Create draft post**
49
- 5. Edit the draft under **Posts** (slug, categories, times, publish)
37
+ Open **Plugins → Import from Instagram** in the EmDash admin. The page has three sections:
38
+
39
+ ### Single post
40
+
41
+ 1. Paste an Instagram post URL and click **Convert to recipe**
42
+ 2. Review the parsed preview (title, ingredients, steps, featured image)
43
+ 3. Click **Create draft post**
44
+ 4. Edit the draft under **Posts** (slug, categories, times, publish)
45
+
46
+ ### Bulk import from a profile
47
+
48
+ 1. Enter a public Instagram handle and click **Scan profile**
49
+ 2. The profile grid is fetched in a single request and every caption that parses as a recipe is listed, sorted by likes
50
+ 3. Check the recipes you want (already-imported posts are unchecked and labeled) and click **Create selected drafts**
51
+
52
+ Bulk import is idempotent — posts already imported are skipped. Deleting an imported draft clears the bookkeeping, so the post can be imported again later.
53
+
54
+ ### Settings
55
+
56
+ - **Content collection** — where drafts are created (default: `posts`)
57
+ - **Instagram handle** (optional) — pre-fills the bulk scan and lets single-post imports go straight to the profile API; when empty, the post owner's handle is detected automatically
58
+
59
+ > Standard-format EmDash plugins are configured here (settings are stored in the plugin's KV store), not via constructor options.
50
60
 
51
61
  ## Capabilities
52
62
 
53
- | Capability | Purpose |
54
- | ---------------- | -------------------------------------------- |
55
- | `network:fetch` | Fetch Instagram HTML and CDN images |
56
- | `read:content` | Check for prior imports |
57
- | `write:content` | Create draft posts |
58
- | `write:media` | Upload featured images |
63
+ | Capability | Purpose |
64
+ | ----------------- | ----------------------------------- |
65
+ | `network:request` | Fetch Instagram HTML and CDN images |
66
+ | `content:read` | Check for prior imports |
67
+ | `content:write` | Create draft posts |
68
+ | `media:write` | Upload featured images |
59
69
 
60
70
  Allowed hosts: `www.instagram.com`, `*.cdninstagram.com`, `*.fbcdn.net`.
61
71
 
62
72
  ## API routes
63
73
 
64
- For scripting or custom admin UI:
74
+ For scripting or a custom admin UI:
65
75
 
66
- | Route | Method | Body |
67
- | -------------- | ------ | ----------------------------- |
68
- | `parse` | POST | `{ "url": "https://..." }` |
69
- | `create-draft` | POST | `{ "shortcode": "DXkhLGbEcOY" }` |
76
+ | Route | Method | Body |
77
+ | -------------- | ------ | ------------------------------------- |
78
+ | `parse` | POST | `{ "url": "https://..." }` |
79
+ | `create-draft` | POST | `{ "shortcode": "DXkhLGbEcOY" }` |
80
+ | `bulk-scan` | POST | `{ "handle": "somefoodblogger" }` |
81
+ | `bulk-create` | POST | `{ "shortcodes": ["DXkhLGbEcOY"] }` |
70
82
 
71
83
  Base path: `/_emdash/api/plugins/peristyle-instagram-to-recipe/<route>`
72
84
 
73
85
  ## Limitations
74
86
 
75
87
  - **Public posts only** — private or login-walled posts cannot be read
76
- - **Caption format** — works best with “What you'll need” + numbered steps (same as brand-bootstrap)
77
- - **Instagram rate limits** — repeated imports may hit HTTP 429; wait and retry
78
- - **No video frames** — uses the OG thumbnail/image, not in-video text
79
- - **Cloudflare sandbox** — uses `ctx.http.fetch` and `ctx.media.upload` (no Node `curl`)
88
+ - **Caption format** — works best with a “What you'll need” / “Ingredients:” section plus numbered steps
89
+ - **Bulk scan depth** — one profile request returns roughly the 12 most recent posts; older posts can still be imported individually by URL
90
+ - **Instagram rate limits** — repeated fetches may hit HTTP 429; responses are cached for 24 hours and stale cache is used as a fallback, but you may need to wait and retry
91
+ - **No video frames** — video posts use the feed thumbnail, not in-video text
92
+
93
+ ## Building from source
80
94
 
81
- ## Development
95
+ The TypeScript source ships in the package under `src/`. From an unpacked copy:
82
96
 
83
97
  ```bash
84
- cd plugins/instagram-to-recipe
85
98
  pnpm install
86
99
  pnpm build
87
100
  pnpm typecheck
88
101
  ```
89
102
 
103
+ The development repository is not currently public.
104
+
90
105
  ## License
91
106
 
92
107
  MIT
package/dist/index.d.ts CHANGED
@@ -1,14 +1,10 @@
1
1
  import { PluginDescriptor } from 'emdash';
2
2
 
3
- type InstagramToRecipePluginOptions = {
4
- /** Content collection slug for imported recipes (default: posts). */
5
- collection?: string;
6
- /**
7
- * Instagram username for web_profile_info (e.g. cookingwithashhhh).
8
- * When omitted, the post page's owner username is used automatically.
9
- */
10
- instagramHandle?: string;
11
- };
12
- declare function instagramToRecipePlugin(options?: InstagramToRecipePluginOptions): PluginDescriptor<InstagramToRecipePluginOptions>;
3
+ /**
4
+ * Standard-format EmDash plugins are configured via the admin UI (KV
5
+ * settings), not constructor options — set the target collection and
6
+ * Instagram handle under Plugins → Import from Instagram → Settings.
7
+ */
8
+ declare function instagramToRecipePlugin(): PluginDescriptor;
13
9
 
14
- export { type InstagramToRecipePluginOptions, instagramToRecipePlugin };
10
+ export { instagramToRecipePlugin };
package/dist/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  // src/index.ts
2
- function instagramToRecipePlugin(options = {}) {
2
+ function instagramToRecipePlugin() {
3
3
  return {
4
4
  id: "peristyle-instagram-to-recipe",
5
- version: "0.1.5",
5
+ version: "0.1.6",
6
6
  format: "standard",
7
7
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
8
- options,
9
8
  capabilities: [
10
9
  "content:read",
11
10
  "content:write",
@@ -96,8 +96,47 @@ function importFormBlocks(initialUrl = "") {
96
96
  }
97
97
  ];
98
98
  }
99
- function pageBlocks() {
100
- return [...importFormBlocks(), { type: "divider" }, ...bulkFormBlocks()];
99
+ function settingsFormBlocks(current) {
100
+ return [
101
+ {
102
+ type: "header",
103
+ text: "Settings"
104
+ },
105
+ {
106
+ type: "context",
107
+ text: "Drafts are created in the collection below. The Instagram handle is optional \u2014 it pre-fills the bulk scan and speeds up single-post imports; when empty, the post owner's handle is used automatically."
108
+ },
109
+ {
110
+ type: "form",
111
+ block_id: "settings-form",
112
+ fields: [
113
+ {
114
+ type: "text_input",
115
+ action_id: "collection",
116
+ label: "Content collection",
117
+ placeholder: "posts",
118
+ initial_value: current.collection
119
+ },
120
+ {
121
+ type: "text_input",
122
+ action_id: "instagram_handle",
123
+ label: "Instagram handle (optional)",
124
+ placeholder: "@yourhandle",
125
+ initial_value: current.instagramHandle
126
+ }
127
+ ],
128
+ submit: { label: "Save settings", action_id: "save_settings" }
129
+ }
130
+ ];
131
+ }
132
+ function pageBlocks(settings) {
133
+ return [
134
+ ...importFormBlocks(),
135
+ { type: "divider" },
136
+ ...bulkFormBlocks(settings.instagramHandle),
137
+ { type: "divider" },
138
+ ...settingsFormBlocks(settings)
139
+ ];
101
140
  }
102
141
  function previewBlocks(recipe, options) {
103
142
  const ingredientPreview = recipe.ingredients.slice(0, 8).join("\n");
@@ -1049,6 +1088,12 @@ function sleep2(ms) {
1049
1088
  async function collectionSlug(ctx) {
1050
1089
  return await ctx.kv.get("config:collection") ?? DEFAULT_COLLECTION;
1051
1090
  }
1091
+ async function pluginSettings(ctx) {
1092
+ return {
1093
+ collection: await collectionSlug(ctx),
1094
+ instagramHandle: await ctx.kv.get("config:instagram_handle") ?? ""
1095
+ };
1096
+ }
1052
1097
  function parseCacheKey(shortcode) {
1053
1098
  return `${PARSE_CACHE_PREFIX}${shortcode}`;
1054
1099
  }
@@ -1337,11 +1382,44 @@ var sandbox_entry_default = {
1337
1382
  handler: async (routeCtx, ctx) => {
1338
1383
  const interaction = routeCtx.input ?? { type: "page_load" };
1339
1384
  if (interaction.type === "page_load") {
1340
- return { blocks: pageBlocks() };
1385
+ return { blocks: pageBlocks(await pluginSettings(ctx)) };
1341
1386
  }
1342
1387
  const instagramHandle = String(
1343
1388
  interaction.values?.instagram_handle ?? ""
1344
1389
  ).trim();
1390
+ if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
1391
+ const collection = String(interaction.values?.collection ?? "").trim().toLowerCase();
1392
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(collection)) {
1393
+ return {
1394
+ blocks: pageBlocks(await pluginSettings(ctx)),
1395
+ toast: {
1396
+ message: "Collection must be a slug (letters, numbers, dashes).",
1397
+ type: "error"
1398
+ }
1399
+ };
1400
+ }
1401
+ let handle = "";
1402
+ if (instagramHandle) {
1403
+ try {
1404
+ handle = normalizeInstagramHandle(instagramHandle);
1405
+ } catch {
1406
+ return {
1407
+ blocks: pageBlocks(await pluginSettings(ctx)),
1408
+ toast: { message: "Invalid Instagram handle.", type: "error" }
1409
+ };
1410
+ }
1411
+ }
1412
+ await ctx.kv.set("config:collection", collection);
1413
+ if (handle) {
1414
+ await ctx.kv.set("config:instagram_handle", handle);
1415
+ } else {
1416
+ await ctx.kv.delete("config:instagram_handle");
1417
+ }
1418
+ return {
1419
+ blocks: pageBlocks({ collection, instagramHandle: handle }),
1420
+ toast: { message: "Settings saved", type: "success" }
1421
+ };
1422
+ }
1345
1423
  if (interaction.type === "form_submit" && interaction.action_id === "bulk_scan") {
1346
1424
  try {
1347
1425
  const { handle, candidates, scanned, rateLimited, fromCache } = await scanProfileRecipes(ctx, instagramHandle);
@@ -1360,7 +1438,7 @@ var sandbox_entry_default = {
1360
1438
  const message = error instanceof Error ? error.message : String(error);
1361
1439
  return {
1362
1440
  blocks: [
1363
- ...pageBlocks(),
1441
+ ...pageBlocks(await pluginSettings(ctx)),
1364
1442
  {
1365
1443
  type: "banner",
1366
1444
  title: "Could not scan profile",
@@ -1377,7 +1455,7 @@ var sandbox_entry_default = {
1377
1455
  const shortcodes = Array.isArray(raw) ? raw.map((s) => String(s).trim()).filter(Boolean) : [];
1378
1456
  if (shortcodes.length === 0) {
1379
1457
  return {
1380
- blocks: pageBlocks(),
1458
+ blocks: pageBlocks(await pluginSettings(ctx)),
1381
1459
  toast: { message: "Select at least one recipe", type: "error" }
1382
1460
  };
1383
1461
  }
@@ -1397,7 +1475,7 @@ var sandbox_entry_default = {
1397
1475
  } catch (error) {
1398
1476
  const message = error instanceof Error ? error.message : String(error);
1399
1477
  return {
1400
- blocks: pageBlocks(),
1478
+ blocks: pageBlocks(await pluginSettings(ctx)),
1401
1479
  toast: { message, type: "error" }
1402
1480
  };
1403
1481
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peristyle/emdash-plugin-instagram-to-recipe",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Import Instagram post URLs as recipe drafts in the EmDash admin",
6
6
  "publishConfig": {
@@ -30,9 +30,54 @@ export function importFormBlocks(initialUrl = ""): Block[] {
30
30
  ];
31
31
  }
32
32
 
33
- /** Landing view: single-post import on top, bulk profile import below. */
34
- export function pageBlocks(): Block[] {
35
- return [...importFormBlocks(), { type: "divider" }, ...bulkFormBlocks()];
33
+ export type PluginSettings = {
34
+ collection: string;
35
+ instagramHandle: string;
36
+ };
37
+
38
+ export function settingsFormBlocks(current: PluginSettings): Block[] {
39
+ return [
40
+ {
41
+ type: "header",
42
+ text: "Settings",
43
+ },
44
+ {
45
+ type: "context",
46
+ text: "Drafts are created in the collection below. The Instagram handle is optional — it pre-fills the bulk scan and speeds up single-post imports; when empty, the post owner's handle is used automatically.",
47
+ },
48
+ {
49
+ type: "form",
50
+ block_id: "settings-form",
51
+ fields: [
52
+ {
53
+ type: "text_input",
54
+ action_id: "collection",
55
+ label: "Content collection",
56
+ placeholder: "posts",
57
+ initial_value: current.collection,
58
+ },
59
+ {
60
+ type: "text_input",
61
+ action_id: "instagram_handle",
62
+ label: "Instagram handle (optional)",
63
+ placeholder: "@yourhandle",
64
+ initial_value: current.instagramHandle,
65
+ },
66
+ ],
67
+ submit: { label: "Save settings", action_id: "save_settings" },
68
+ },
69
+ ];
70
+ }
71
+
72
+ /** Landing view: single-post import, bulk profile import, settings. */
73
+ export function pageBlocks(settings: PluginSettings): Block[] {
74
+ return [
75
+ ...importFormBlocks(),
76
+ { type: "divider" },
77
+ ...bulkFormBlocks(settings.instagramHandle),
78
+ { type: "divider" },
79
+ ...settingsFormBlocks(settings),
80
+ ];
36
81
  }
37
82
 
38
83
  export function previewBlocks(
package/src/index.ts CHANGED
@@ -3,25 +3,17 @@ import type { PluginDescriptor } from "emdash";
3
3
  /** Replaced at build time by tsup `define` from package.json — single source of truth. */
4
4
  declare const __PLUGIN_VERSION__: string;
5
5
 
6
- export type InstagramToRecipePluginOptions = {
7
- /** Content collection slug for imported recipes (default: posts). */
8
- collection?: string;
9
- /**
10
- * Instagram username for web_profile_info (e.g. cookingwithashhhh).
11
- * When omitted, the post page's owner username is used automatically.
12
- */
13
- instagramHandle?: string;
14
- };
15
-
16
- export function instagramToRecipePlugin(
17
- options: InstagramToRecipePluginOptions = {},
18
- ): PluginDescriptor<InstagramToRecipePluginOptions> {
6
+ /**
7
+ * Standard-format EmDash plugins are configured via the admin UI (KV
8
+ * settings), not constructor options — set the target collection and
9
+ * Instagram handle under Plugins → Import from Instagram → Settings.
10
+ */
11
+ export function instagramToRecipePlugin(): PluginDescriptor {
19
12
  return {
20
13
  id: "peristyle-instagram-to-recipe",
21
14
  version: __PLUGIN_VERSION__,
22
15
  format: "standard",
23
16
  entrypoint: "@peristyle/emdash-plugin-instagram-to-recipe/sandbox",
24
- options,
25
17
  capabilities: [
26
18
  "content:read",
27
19
  "content:write",
@@ -17,8 +17,8 @@ function cookieJarPath(handle: string): string {
17
17
  }
18
18
 
19
19
  /**
20
- * Same curl + cookie-jar flow as init-brand-resources brand-bootstrap.
21
- * Used when fetch() to web_profile_info fails (often HTTP 429).
20
+ * curl + cookie-jar fallback: prime cookies on the profile page, then call
21
+ * web_profile_info. Used when fetch() fails (often HTTP 429).
22
22
  */
23
23
  export async function fetchInstagramProfileViaCurl(
24
24
  handle: string,
@@ -43,7 +43,7 @@ export function extractOwnerUsername(html: string): string | undefined {
43
43
  return undefined;
44
44
  }
45
45
 
46
- /** Post data resolved from the profile grid — same fields brand-bootstrap maps. */
46
+ /** Post data resolved from the profile grid. */
47
47
  export type ProfilePostNode = {
48
48
  shortcode: string;
49
49
  caption: string;
@@ -53,7 +53,7 @@ export type ProfilePostNode = {
53
53
  isVideo: boolean;
54
54
  };
55
55
 
56
- /** Same shape brand-bootstrap persists under `.cache/instagram/<handle>.json`. */
56
+ /** Cached web_profile_info payload for one handle. */
57
57
  export type CachedInstagramProfile = {
58
58
  fetchedAt: string;
59
59
  handle: string;
@@ -106,7 +106,7 @@ export function postNodeFromProfileUser(
106
106
  return postsFromProfileUser(user).find((p) => p.shortcode === shortcode);
107
107
  }
108
108
 
109
- /** Same field brand-bootstrap uses: node.display_url from the profile grid. */
109
+ /** node.display_url from the profile grid — the feed image URL. */
110
110
  export function displayUrlFromProfileUser(
111
111
  user: unknown,
112
112
  shortcode: string,
@@ -127,7 +127,7 @@ function profileApiHeaders(handle: string): HeadersInit {
127
127
  };
128
128
  }
129
129
 
130
- // Match brand-bootstrap: 3 retries with exponential backoff (2.5s, 5s, 10s).
130
+ // 3 retries with exponential backoff (2.5s, 5s, 10s).
131
131
  const PROFILE_FETCH_RETRIES = 3;
132
132
  const PROFILE_RETRY_BASE_DELAY_MS = 2500;
133
133
 
@@ -155,8 +155,7 @@ export type ProfileDisplayUrlResult = {
155
155
  };
156
156
 
157
157
  /**
158
- * Resolve display_url via web_profile_info same API and field as
159
- * init-brand-resources brand-bootstrap (postsFromProfile → displayUrl).
158
+ * Resolve display_url via one web_profile_info request for the handle.
160
159
  */
161
160
  async function fetchProfileUserOnce(
162
161
  fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
@@ -228,9 +227,9 @@ async function cacheSet(
228
227
  }
229
228
 
230
229
  /**
231
- * Fetch the post's grid node via web_profile_info, mirroring brand-bootstrap:
232
- * fresh cache first, then live fetch with backoff, then stale cache on
233
- * failure, then the curl cookie-jar fallback.
230
+ * Fetch the post's grid node via web_profile_info: fresh cache first, then
231
+ * live fetch with backoff, then stale cache on failure, then the curl
232
+ * cookie-jar fallback.
234
233
  */
235
234
  export async function fetchProfilePostNode(
236
235
  fetchFn: (url: string, init?: RequestInit) => Promise<Response>,
@@ -275,7 +274,7 @@ export async function fetchProfilePostNode(
275
274
  if (!result.retryable) break;
276
275
  }
277
276
 
278
- // Stale cache beats no image same trade-off brand-bootstrap makes on 429.
277
+ // Stale cache beats no image when Instagram is rate-limiting.
279
278
  if (cached) {
280
279
  const node = postNodeFromProfileUser(cached.user, shortcode);
281
280
  if (node) {
@@ -146,8 +146,9 @@ export function extractEmbeddedImageUrl(html: string): string | undefined {
146
146
  }
147
147
 
148
148
  /**
149
- * Resolve the post's feed image (same class of URL as brand-bootstrap display_url).
150
- * Never uses og:video. Uses og:image only when no embedded display_url exists.
149
+ * Resolve the post's feed image (the embedded display_url, not the OG
150
+ * share-card). Never uses og:video. Uses og:image only when no embedded
151
+ * display_url exists.
151
152
  */
152
153
  export function resolvePostImageUrl(
153
154
  html: string,
@@ -300,7 +301,7 @@ export type FetchedInstagramPost = {
300
301
  };
301
302
 
302
303
  export type FetchInstagramPostOptions = {
303
- /** Override owner handle for web_profile_info lookup (same as brand-bootstrap handle). */
304
+ /** Override owner handle for the web_profile_info lookup. */
304
305
  instagramHandle?: string;
305
306
  /** Profile payload cache (24h TTL, stale fallback on 429) — back with ctx.kv. */
306
307
  profileCache?: InstagramProfileCache;
@@ -358,10 +359,10 @@ export async function fetchInstagramPost(
358
359
  const { shortcode } = parseInstagramPostUrl(postUrl);
359
360
  const canonicalUrl = `https://www.instagram.com/p/${shortcode}/`;
360
361
 
361
- // Profile-API-first, like init-brand-resources brand-bootstrap: a single
362
- // (cached) web_profile_info request supplies caption, display_url, and
363
- // is_video. Anonymous HTML page loads are what trip Instagram's rate
364
- // limiter, so they are reserved for the fallback below.
362
+ // Profile-API-first: a single (cached) web_profile_info request supplies
363
+ // caption, display_url, and is_video. Anonymous HTML page loads are what
364
+ // trip Instagram's rate limiter, so they are reserved for the fallback
365
+ // below.
365
366
  const configuredHandle = options.instagramHandle
366
367
  ? normalizeInstagramHandle(options.instagramHandle)
367
368
  : undefined;
@@ -412,8 +413,8 @@ export async function fetchInstagramPost(
412
413
  const cookieHeader = cookieHeaderFromResponse(res);
413
414
  const ownerHandle = extractOwnerUsername(html);
414
415
 
415
- // Same image source as init-brand-resources: web_profile_info → node.display_url.
416
- // Skip the lookup when the configured handle already answered for this owner.
416
+ // Image source: web_profile_info → node.display_url. Skip the lookup when
417
+ // the configured handle already answered for this owner.
417
418
  if (ownerHandle && ownerHandle !== configuredHandle) {
418
419
  profileResult = await fetchProfilePostNode(
419
420
  fetchFn,
@@ -9,6 +9,7 @@ import {
9
9
  pageBlocks,
10
10
  previewBlocks,
11
11
  type BulkCandidate,
12
+ type PluginSettings,
12
13
  } from "./admin-blocks.js";
13
14
  import {
14
15
  buildBulkRecipesFromPosts,
@@ -57,6 +58,14 @@ async function collectionSlug(ctx: PluginContext): Promise<string> {
57
58
  return (await ctx.kv.get<string>("config:collection")) ?? DEFAULT_COLLECTION;
58
59
  }
59
60
 
61
+ async function pluginSettings(ctx: PluginContext): Promise<PluginSettings> {
62
+ return {
63
+ collection: await collectionSlug(ctx),
64
+ instagramHandle:
65
+ (await ctx.kv.get<string>("config:instagram_handle")) ?? "",
66
+ };
67
+ }
68
+
60
69
  function parseCacheKey(shortcode: string): string {
61
70
  return `${PARSE_CACHE_PREFIX}${shortcode}`;
62
71
  }
@@ -76,7 +85,7 @@ async function httpFetch(ctx: PluginContext, url: string, init?: RequestInit) {
76
85
  return ctx.http.fetch(url, init);
77
86
  }
78
87
 
79
- /** Same role as brand-bootstrap's `.cache/instagram/` files, backed by KV. */
88
+ /** Profile payload cache backed by plugin KV (24h TTL, stale fallback). */
80
89
  function profileCacheForCtx(ctx: PluginContext): InstagramProfileCache {
81
90
  return {
82
91
  async get(handle) {
@@ -484,13 +493,57 @@ export default {
484
493
  const interaction = routeCtx.input ?? { type: "page_load" };
485
494
 
486
495
  if (interaction.type === "page_load") {
487
- return { blocks: pageBlocks() };
496
+ return { blocks: pageBlocks(await pluginSettings(ctx)) };
488
497
  }
489
498
 
490
499
  const instagramHandle = String(
491
500
  interaction.values?.instagram_handle ?? "",
492
501
  ).trim();
493
502
 
503
+ if (
504
+ interaction.type === "form_submit" &&
505
+ interaction.action_id === "save_settings"
506
+ ) {
507
+ const collection = String(interaction.values?.collection ?? "")
508
+ .trim()
509
+ .toLowerCase();
510
+
511
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(collection)) {
512
+ return {
513
+ blocks: pageBlocks(await pluginSettings(ctx)),
514
+ toast: {
515
+ message:
516
+ "Collection must be a slug (letters, numbers, dashes).",
517
+ type: "error",
518
+ },
519
+ };
520
+ }
521
+
522
+ let handle = "";
523
+ if (instagramHandle) {
524
+ try {
525
+ handle = normalizeInstagramHandle(instagramHandle);
526
+ } catch {
527
+ return {
528
+ blocks: pageBlocks(await pluginSettings(ctx)),
529
+ toast: { message: "Invalid Instagram handle.", type: "error" },
530
+ };
531
+ }
532
+ }
533
+
534
+ await ctx.kv.set("config:collection", collection);
535
+ if (handle) {
536
+ await ctx.kv.set("config:instagram_handle", handle);
537
+ } else {
538
+ await ctx.kv.delete("config:instagram_handle");
539
+ }
540
+
541
+ return {
542
+ blocks: pageBlocks({ collection, instagramHandle: handle }),
543
+ toast: { message: "Settings saved", type: "success" },
544
+ };
545
+ }
546
+
494
547
  if (
495
548
  interaction.type === "form_submit" &&
496
549
  interaction.action_id === "bulk_scan"
@@ -516,7 +569,7 @@ export default {
516
569
  error instanceof Error ? error.message : String(error);
517
570
  return {
518
571
  blocks: [
519
- ...pageBlocks(),
572
+ ...pageBlocks(await pluginSettings(ctx)),
520
573
  {
521
574
  type: "banner",
522
575
  title: "Could not scan profile",
@@ -540,7 +593,7 @@ export default {
540
593
 
541
594
  if (shortcodes.length === 0) {
542
595
  return {
543
- blocks: pageBlocks(),
596
+ blocks: pageBlocks(await pluginSettings(ctx)),
544
597
  toast: { message: "Select at least one recipe", type: "error" },
545
598
  };
546
599
  }
@@ -569,7 +622,7 @@ export default {
569
622
  const message =
570
623
  error instanceof Error ? error.message : String(error);
571
624
  return {
572
- blocks: pageBlocks(),
625
+ blocks: pageBlocks(await pluginSettings(ctx)),
573
626
  toast: { message, type: "error" },
574
627
  };
575
628
  }