@qkix/strapi-plugin-better-blocks 0.23.1 → 0.23.2

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
@@ -94,13 +94,13 @@
94
94
  - **To-do Lists** — Checkbox list items with click-to-toggle and strikethrough on checked items
95
95
  - **Tables** — Insert tables with header row, add/remove rows and columns via hover toolbar
96
96
  - **Embeds (generic iframe / URL)** &mdash; Insert any third-party embed from the `+` Insert menu, the `/embed` slash command, or the toolbar's media button. Two modes: **URL** (YouTube, Vimeo, Loom, Wistia, Dailymotion and api.video are converted to an iframe automatically) and **Embed code** (paste any platform's `<iframe>` snippet). The pasted markup is never stored verbatim &mdash; the iframe is _rebuilt_ from an attribute allowlist over an https-only `src`, so scripts, event handlers and unknown attributes cannot survive into your frontend. Live preview in the editor, plus aspect ratio (16:9 / 21:9 / 4:3 / 1:1 / custom), alignment, caption and an accessible title. Supersedes the older YouTube/Vimeo-only **media embed** block, which still renders existing content but is no longer inserted
97
- - **Video (provider-aware)** &mdash; Insert a video from the `+` Insert menu or the `/video` slash command. Source it from the **Media Library**, a **direct URL**, or a hosting provider &mdash; Mux, api.video and Cloudinary URLs are detected automatically, and a bare **Mux playback ID** fills in the stream URL and poster frame for you. When [`strapi-plugin-mux-video-uploader`](https://github.com/muxinc/strapi-plugin-mux-video-uploader) is installed and configured, a **Mux** button lists and searches your Mux assets inline with thumbnails. Set a poster image, title, caption, WebVTT captions/transcript, alignment, aspect ratio, and player behaviour (controls, autoplay, muted, loop)
97
+ - **Video (provider-aware)** &mdash; Insert a video from the `+` Insert menu or the `/video` slash command. Source it from the **Media Library**, a **direct URL**, or a hosting provider &mdash; Mux, api.video and Cloudinary URLs are detected automatically, and a bare **Mux playback ID** fills in the stream URL and poster frame for you. When [`strapi-plugin-mux-video-uploader`](https://github.com/muxinc/strapi-plugin-mux-video-uploader) is installed and configured, a **Mux** button lists and searches your Mux assets inline with thumbnails. Set a poster image, title, caption, WebVTT captions/transcript, alignment, aspect ratio, and player behavior (controls, autoplay, muted, loop)
98
98
  - **Math (LaTeX / KaTeX)** &mdash; Inline and block math rendered with KaTeX; insert from the toolbar, the `/math` slash command, the blocks selector, or by typing `$$ ` (block) / `$…$ ` (inline), then edit in a full-screen modal with a side-by-side source editor and live preview. Block math supports multi-line equations via `\\` and LaTeX environments such as `aligned` and `cases`
99
99
  - **Diagrams (Mermaid)** &mdash; Block-level [Mermaid](https://mermaid.js.org/) diagrams (flowcharts, sequence, class, state, ER, pie, and more) rendered to SVG; insert from the blocks selector, the `/mermaid` slash command, or by typing ` ```mermaid ` then a space, then edit the definition in a full-screen modal with live preview and zoom controls. Theme follows Strapi's light/dark mode
100
100
  - **Callouts / Admonitions** &mdash; GitHub-style callouts in five variants (`Note`, `Tip`, `Important`, `Warning`, `Caution`) with an optional custom title and nested rich-text content (paragraphs, lists, links). Insert from the blocks selector or the `/note`, `/tip`, `/important`, `/warning`, `/caution` slash commands; switch variant, edit the title, or remove from the header popover. Colors follow Strapi's design tokens and adapt to light/dark mode
101
101
  - **Details / Collapsible** &mdash; GitHub-style collapsible `<details>` / `<summary>` sections for managing content density. Insert from the blocks selector or the `/details` slash command; edit the summary label and toggle open/closed-by-default (`defaultOpen`) from the header. Holds full rich-text block content (paragraphs, lists, tables, images) and supports nesting. Admins can set the default summary text and choose a GitHub-minimal (default) or Custom (bordered + background) style. Stored as `{ "type": "details", "summary": "…", "defaultOpen": false, "children": [...] }`
102
102
  - **Button (WordPress-style CTA)** &mdash; Insert a styled call-to-action button from the blocks selector or by typing `[button]` then a space. Two modes: **Link** (URL + open-in-new-tab + ARIA label) and **File** (pick any asset from the Media Library to render a download button with optional file size and type icon, and choose direct download or preview-in-new-tab). A full-screen editor with live preview controls alignment, background/text colors (including hover colors), border radius, font size/weight, padding presets, border, and a custom CSS class. One-click **style presets** (Primary / Secondary / Outline / Filled) keep CTAs on-brand. Admins can set default button colors and customize the presets. Stored as `{ "type": "button", "buttonType": "link" | "file", "label": "…", "alignment": "center", "link": {…} | "file": {…}, "style": {…} }`
103
- - **Audio (HTML5 player)** &mdash; Insert an audio block from the `+` Insert menu, the `/audio` slash command, or by typing `[audio]` then a space. Pick a file from the Media Library (upload included) or paste a direct URL, with a live native `<audio>` preview in the editor. Set an optional title and caption, block alignment (left / center / right / none), and player behaviour: controls, autoplay, loop, and preload (none / metadata / auto). Stored as `{ "type": "audio", "file": {…}, "title": "…", "caption": "…", "player": {…}, "alignment": "center" }`
103
+ - **Audio (HTML5 player)** &mdash; Insert an audio block from the `+` Insert menu, the `/audio` slash command, or by typing `[audio]` then a space. Pick a file from the Media Library (upload included) or paste a direct URL, with a live native `<audio>` preview in the editor. Set an optional title and caption, block alignment (left / center / right / none), and player behavior: controls, autoplay, loop, and preload (none / metadata / auto). Stored as `{ "type": "audio", "file": {…}, "title": "…", "caption": "…", "player": {…}, "alignment": "center" }`
104
104
  - **Horizontal Line** &mdash; Insert `<hr>` dividers between content blocks
105
105
  - **Text Alignment** &mdash; Per-block left, center, right, and justify alignment
106
106
  - **Undo / Redo** &mdash; Toolbar buttons wired to Slate's built-in history
@@ -2,7 +2,7 @@ import { Editor } from 'slate';
2
2
  import { type BlocksStore } from '../BlocksEditor';
3
3
  import { createTableCell, createTableRow, insertTable } from './tableOperations';
4
4
  /**
5
- * Protects the table's shape from Slate's generic editing behaviours (deleting
5
+ * Protects the table's shape from Slate's generic editing behaviors (deleting
6
6
  * across cells, normalizing rows away, converting a cell to a paragraph, …).
7
7
  *
8
8
  * Structural edits are performed by tableOperations, which lifts the guard via
@@ -3,7 +3,7 @@ import { type AspectRatio, type EmbedProvider } from '../utils/types';
3
3
  export declare const EMBED_FRAME_HOSTS: string[];
4
4
  /** Human-readable provider name for a URL, e.g. "YouTube". */
5
5
  export declare const getProviderLabel: (provider?: EmbedProvider) => string;
6
- /** Detected provider for a share URL, or `generic` when unrecognised. */
6
+ /** Detected provider for a share URL, or `generic` when unrecognized. */
7
7
  export declare const detectProvider: (url: string) => EmbedProvider;
8
8
  /** Iframe `src` derived from a share URL, or null when not derivable. */
9
9
  export declare const getEmbedSrc: (url: string) => string | null;
@@ -32,7 +32,7 @@ export interface IframeError {
32
32
  */
33
33
  export declare const sanitizeIframe: (input: string) => SanitizedIframe | IframeError;
34
34
  /**
35
- * Build the iframe markup for a recognised share URL. Returns null when the URL
35
+ * Build the iframe markup for a recognized share URL. Returns null when the URL
36
36
  * isn't one of the known providers — the author then has to use embed-code mode.
37
37
  */
38
38
  export declare const buildUrlEmbed: (url: string, title?: string) => SanitizedIframe | null;
@@ -85,6 +85,6 @@ export declare const cellsInRange: (grid: TableGrid, range: GridRange) => GridCe
85
85
  * linear, so everything between r2c1 and r3c2 includes the whole tail of row 2
86
86
  * — taking the bounding box of every touched cell would turn a 2x2 pick into a
87
87
  * full-width 2x5 one. Treating anchor and focus as opposite corners gives the
88
- * 2D behaviour authors expect from a table.
88
+ * 2D behavior authors expect from a table.
89
89
  */
90
90
  export declare const getSelectedRange: (editor: Editor, grid: TableGrid) => GridRange | null;
@@ -20,7 +20,7 @@
20
20
  * scroll (defeating the purpose on macOS). We therefore do NOT set
21
21
  * `scrollbar-color`; Firefox falls back to its own always-visible bar.
22
22
  * - The track gets a real background so the channel stays visible even when the
23
- * content fits and the thumb spans the full height; the colours are
23
+ * content fits and the thumb spans the full height; the colors are
24
24
  * mid-range neutrals that contrast with the menu background (`neutral0`) in
25
25
  * both the light and dark themes.
26
26
  * - `!important` / `display: block` are required to beat the design-system
@@ -85,7 +85,7 @@ export interface ImageElement extends CustomElement {
85
85
  }
86
86
  export type AudioAlignment = 'left' | 'center' | 'right' | 'none';
87
87
  export type AudioPreload = 'none' | 'metadata' | 'auto';
88
- /** Player behaviour flags, mirrored 1:1 onto the HTML5 `<audio>` element. */
88
+ /** Player behavior flags, mirrored 1:1 onto the HTML5 `<audio>` element. */
89
89
  export interface AudioPlayerSettings {
90
90
  autoplay: boolean;
91
91
  loop: boolean;
@@ -95,7 +95,7 @@ export interface AudioPlayerSettings {
95
95
  /**
96
96
  * Audio block. A void block referencing a Strapi Media Library asset (or an
97
97
  * external URL). Stores the file metadata plus a display title, caption, player
98
- * behaviour flags and alignment. The frontend renderer turns this into a native
98
+ * behavior flags and alignment. The frontend renderer turns this into a native
99
99
  * HTML5 `<audio>` player — see issue #43 for the renderer contract.
100
100
  */
101
101
  export interface AudioElement extends CustomElement {
@@ -271,7 +271,7 @@ export declare const isButtonNode: (element: CustomElement) => element is Button
271
271
  export type SocialPlatform = 'twitter' | 'instagram' | 'facebook' | 'tiktok' | 'linkedin' | 'pinterest';
272
272
  export type SocialAlignment = 'left' | 'center' | 'right';
273
273
  /**
274
- * Normalised oEmbed payload fetched from the platform via the plugin's server
274
+ * Normalized oEmbed payload fetched from the platform via the plugin's server
275
275
  * oEmbed proxy (`GET /better-blocks/oembed`). Stored on the node so the frontend
276
276
  * renderer can render the embed without itself calling the platform at runtime.
277
277
  */
@@ -353,7 +353,7 @@ export interface EmbedElement extends CustomElement {
353
353
  export declare const isEmbedNode: (element: CustomElement) => element is EmbedElement;
354
354
  /** Video hosting providers the video block understands. */
355
355
  export type VideoProvider = 'local' | 'mux' | 'api-video' | 'cloudinary' | 'custom';
356
- /** Player behaviour flags, mirrored onto the HTML5 `<video>` element. */
356
+ /** Player behavior flags, mirrored onto the HTML5 `<video>` element. */
357
357
  export interface VideoPlayerSettings {
358
358
  autoplay: boolean;
359
359
  loop: boolean;
@@ -38,7 +38,7 @@ type UseDragAndDropReturn<E extends Element = HTMLElement> = [
38
38
  ];
39
39
  /**
40
40
  * A utility hook abstracting the general drag and drop hooks from react-dnd.
41
- * Centralising the same behaviours and by default offering keyboard support.
41
+ * Centralising the same behaviors and by default offering keyboard support.
42
42
  */
43
43
  declare const useDragAndDrop: <TIndex extends number | number[], TItem extends {
44
44
  [key: string]: unknown;
@@ -1 +1 @@
1
- "use strict";const g=({strapi:t})=>{},k=({strapi:t})=>{},y=({strapi:t})=>{t.customFields.register({name:"better-blocks",plugin:"better-blocks",type:"json",inputSize:{default:12,isResizable:!1}})},w={default:{details:{defaultSummary:"Click to expand",style:"github"},button:{defaultStyle:{backgroundColor:"#4945ff",textColor:"#ffffff",borderRadius:"4px",padding:"12px 24px",fontSize:"16px",fontWeight:"600"},presets:{primary:{backgroundColor:"#4945ff",textColor:"#ffffff",border:"none"},secondary:{backgroundColor:"#dcdce4",textColor:"#32324d",border:"none"},outline:{backgroundColor:"transparent",textColor:"#4945ff",border:"2px solid #4945ff"},filled:{backgroundColor:"#32324d",textColor:"#ffffff",border:"none"}}},social:{enabled:!0,platforms:["twitter","instagram","facebook","tiktok","linkedin","pinterest"],cache:!0,cacheTTL:86400,instagram:{accessToken:void 0},facebook:{accessToken:void 0}}},validator(t={}){const o=t?.details?.style;if(o&&o!=="github"&&o!=="custom")throw new Error(`[better-blocks] config.details.style must be "github" or "custom" (received "${o}")`)}},C={},v=({strapi:t})=>({index(o){o.body=t.plugin("better-blocks").service("service").getWelcomeMessage()},getConfig(o){o.body={details:t.plugin("better-blocks").config("details",{defaultSummary:"Click to expand",style:"github"}),button:t.plugin("better-blocks").config("button",{defaultStyle:{backgroundColor:"#4945ff",textColor:"#ffffff",borderRadius:"4px",padding:"12px 24px",fontSize:"16px",fontWeight:"600"},presets:{primary:{backgroundColor:"#4945ff",textColor:"#ffffff",border:"none"},secondary:{backgroundColor:"#dcdce4",textColor:"#32324d",border:"none"},outline:{backgroundColor:"transparent",textColor:"#4945ff",border:"2px solid #4945ff"},filled:{backgroundColor:"#32324d",textColor:"#ffffff",border:"none"}}}),social:(()=>{const e=t.plugin("better-blocks").config("social",{enabled:!0,platforms:["twitter","instagram","facebook","tiktok","linkedin","pinterest"]});return{enabled:e.enabled!==!1,platforms:e.platforms??[],instagramConfigured:!!e.instagram?.accessToken,facebookConfigured:!!e.facebook?.accessToken}})()}}}),x=["twitter","instagram","facebook","tiktok","linkedin","pinterest"],$=({strapi:t})=>({async fetch(o){const{url:e,platform:i}=o.query;if(!e||typeof e!="string")return o.badRequest('Missing required "url" query parameter.');const r=i&&x.includes(i)?i:void 0;try{o.body=await t.plugin("better-blocks").service("oembed").fetch(e,r)}catch(n){const a=n instanceof Error?n.message:"oEmbed request failed.";return o.badRequest(a)}}}),T={controller:v,oembed:$},E={},R={},L=[{method:"GET",path:"/",handler:"controller.index",config:{policies:[]}}],_=[{method:"GET",path:"/config",handler:"controller.getConfig",config:{policies:[]}},{method:"GET",path:"/oembed",handler:"oembed.fetch",config:{policies:["admin::isAuthenticatedAdmin"]}}],S={"content-api":{type:"content-api",routes:L},admin:{type:"admin",routes:_}},U=({strapi:t})=>({getWelcomeMessage(){return"Welcome to Strapi 🚀"}}),I=[{platform:"twitter",re:/(?:twitter\.com|x\.com)\/[^/]+\/status(?:es)?\/\d+/i},{platform:"instagram",re:/instagram\.com\/(?:p|reel|tv)\//i},{platform:"tiktok",re:/tiktok\.com\/@[^/]+\/video\/\d+/i},{platform:"facebook",re:/facebook\.com\//i},{platform:"linkedin",re:/linkedin\.com\//i},{platform:"pinterest",re:/(?:pinterest\.[a-z.]+|pin\.it)\//i}],b=t=>{const o=I.find(({re:e})=>e.test(t));return o?o.platform:null},A=(t,o,e)=>{const i=encodeURIComponent(o);switch(t){case"twitter":return`https://publish.twitter.com/oembed?url=${i}&omit_script=true&dnt=true`;case"tiktok":return`https://www.tiktok.com/oembed?url=${i}`;case"pinterest":return`https://www.pinterest.com/oembed.json?url=${i}`;case"instagram":{const r=e.instagram?.accessToken;return r?`https://graph.facebook.com/v17.0/instagram_oembed?url=${i}&access_token=${encodeURIComponent(r)}&omitscript=true`:null}case"facebook":{const r=e.facebook?.accessToken;return r?`https://graph.facebook.com/v17.0/oembed_post?url=${i}&access_token=${encodeURIComponent(r)}&omitscript=true`:null}case"linkedin":return null;default:return null}},P=async t=>{if(!/^https?:\/\/pin\.it\//i.test(t))return t;try{const e=(await fetch(t,{redirect:"follow"})).url||t;return/pinterest\.[a-z.]+\/pin\//i.test(e)?e:t}catch{return t}},q=t=>{const o=t.match(/(?:urn:li:)?(ugcPost|activity|share)[:-](\d{10,})/i);if(!o)return null;const i=`urn:li:${o[1].toLowerCase()==="ugcpost"?"ugcPost":o[1].toLowerCase()}:${o[2]}`,r=`https://www.linkedin.com/embed/feed/update/${encodeURIComponent(i)}`;return{platform:"linkedin",url:t,html:`<iframe src="${r}" height="600" width="100%" frameborder="0" allowfullscreen title="Embedded LinkedIn post" loading="lazy"></iframe>`,providerName:"LinkedIn",width:504,height:600}},z=t=>t.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi,"").trim(),M=(t,o,e)=>({platform:t,url:o,html:typeof e.html=="string"&&z(e.html)||null,title:typeof e.title=="string"?e.title:void 0,author:typeof e.author_name=="string"?e.author_name:void 0,authorUrl:typeof e.author_url=="string"?e.author_url:void 0,thumbnailUrl:typeof e.thumbnail_url=="string"?e.thumbnail_url:void 0,providerName:typeof e.provider_name=="string"?e.provider_name:void 0,width:typeof e.width=="number"?e.width:void 0,height:typeof e.height=="number"?e.height:void 0}),h=new Map,W=({strapi:t})=>{const o=()=>t.plugin("better-blocks").config("social",{});return{detectPlatform:b,async fetch(e,i){const r=o();if(r.enabled===!1)throw new Error("Social embeds are disabled in plugin config.");const n=i??b(e);if(!n)throw new Error(`Unsupported or unrecognised social URL: ${e}`);const a=r.platforms;if(Array.isArray(a)&&!a.includes(n))throw new Error(`Platform "${n}" is not enabled in plugin config.`);const f=r.cache!==!1,u=(r.cacheTTL??86400)*1e3,p=`${n}:${e}`;if(f){const c=h.get(p);if(c&&c.expires>Date.now())return c.data}let s=null;if(n==="linkedin"){if(s=q(e),!s)throw new Error("Could not derive a LinkedIn embed from this URL. Paste the post embed code instead.")}else{const c=await P(e),m=A(n,c,r);if(!m)throw new Error(`oEmbed for "${n}" requires an access token. Configure config.social.${n}.accessToken, or paste the embed code instead.`);const l=await fetch(m,{headers:{Accept:"application/json"}});if(!l.ok)throw new Error(`oEmbed request to ${n} failed (HTTP ${l.status}).`);const d=await l.json();if(typeof d.error=="string")throw new Error(`${n} could not embed this URL: ${d.error}`);if(s=M(n,c,d),!s.html)throw new Error(`${n} returned no embed markup for this URL. Check the post is public, or paste the embed code instead.`)}return f&&u>0&&h.set(p,{expires:Date.now()+u,data:s}),s}}},j={service:U,oembed:W},D={register:y,bootstrap:g,destroy:k,config:w,controllers:T,routes:S,services:j,contentTypes:C,policies:R,middlewares:E};module.exports=D;
1
+ "use strict";const k=({strapi:o})=>{},w=({strapi:o})=>{},y=({strapi:o})=>{o.customFields.register({name:"better-blocks",plugin:"better-blocks",type:"json",inputSize:{default:12,isResizable:!1}})},C={default:{details:{defaultSummary:"Click to expand",style:"github"},button:{defaultStyle:{backgroundColor:"#4945ff",textColor:"#ffffff",borderRadius:"4px",padding:"12px 24px",fontSize:"16px",fontWeight:"600"},presets:{primary:{backgroundColor:"#4945ff",textColor:"#ffffff",border:"none"},secondary:{backgroundColor:"#dcdce4",textColor:"#32324d",border:"none"},outline:{backgroundColor:"transparent",textColor:"#4945ff",border:"2px solid #4945ff"},filled:{backgroundColor:"#32324d",textColor:"#ffffff",border:"none"}}},social:{enabled:!0,platforms:["twitter","instagram","facebook","tiktok","linkedin","pinterest"],cache:!0,cacheTTL:86400,cacheMaxEntries:500,instagram:{accessToken:void 0},facebook:{accessToken:void 0}}},validator(o={}){const t=o?.details?.style;if(t&&t!=="github"&&t!=="custom")throw new Error(`[better-blocks] config.details.style must be "github" or "custom" (received "${t}")`)}},v={},x=({strapi:o})=>({index(t){t.body=o.plugin("better-blocks").service("service").getWelcomeMessage()},getConfig(t){t.body={details:o.plugin("better-blocks").config("details",{defaultSummary:"Click to expand",style:"github"}),button:o.plugin("better-blocks").config("button",{defaultStyle:{backgroundColor:"#4945ff",textColor:"#ffffff",borderRadius:"4px",padding:"12px 24px",fontSize:"16px",fontWeight:"600"},presets:{primary:{backgroundColor:"#4945ff",textColor:"#ffffff",border:"none"},secondary:{backgroundColor:"#dcdce4",textColor:"#32324d",border:"none"},outline:{backgroundColor:"transparent",textColor:"#4945ff",border:"2px solid #4945ff"},filled:{backgroundColor:"#32324d",textColor:"#ffffff",border:"none"}}}),social:(()=>{const e=o.plugin("better-blocks").config("social",{enabled:!0,platforms:["twitter","instagram","facebook","tiktok","linkedin","pinterest"]});return{enabled:e.enabled!==!1,platforms:e.platforms??[],instagramConfigured:!!e.instagram?.accessToken,facebookConfigured:!!e.facebook?.accessToken}})()}}}),E=["twitter","instagram","facebook","tiktok","linkedin","pinterest"],$=({strapi:o})=>({async fetch(t){const{url:e,platform:r}=t.query;if(!e||typeof e!="string")return t.badRequest('Missing required "url" query parameter.');const n=r&&E.includes(r)?r:void 0;try{t.body=await o.plugin("better-blocks").service("oembed").fetch(e,n)}catch(s){const c=s instanceof Error?s.message:"oEmbed request failed.";return t.badRequest(c)}}}),T={controller:x,oembed:$},R={},_={},L=[{method:"GET",path:"/",handler:"controller.index",config:{policies:[]}}],S=[{method:"GET",path:"/config",handler:"controller.getConfig",config:{policies:[]}},{method:"GET",path:"/oembed",handler:"oembed.fetch",config:{policies:["admin::isAuthenticatedAdmin"]}}],z={"content-api":{type:"content-api",routes:L},admin:{type:"admin",routes:S}},A=({strapi:o})=>({getWelcomeMessage(){return"Welcome to Strapi 🚀"}}),U=({max:o})=>{if(o<1)throw new Error(`ttlCache: max must be at least 1 (received ${o})`);const t=new Map;return{get(e){const r=t.get(e);if(r){if(r.expires<=Date.now()){t.delete(e);return}return t.delete(e),t.set(e,r),r.value}},set(e,r,n){if(t.delete(e),t.size>=o){const s=Date.now();for(const[c,i]of t)i.expires<=s&&t.delete(c)}for(;t.size>=o;){const s=t.keys().next();if(s.done)break;t.delete(s.value)}t.set(e,{expires:Date.now()+n,value:r})},delete:e=>t.delete(e),clear:()=>t.clear(),get size(){return t.size}}},I=[{platform:"twitter",re:/(?:twitter\.com|x\.com)\/[^/]+\/status(?:es)?\/\d+/i},{platform:"instagram",re:/instagram\.com\/(?:p|reel|tv)\//i},{platform:"tiktok",re:/tiktok\.com\/@[^/]+\/video\/\d+/i},{platform:"facebook",re:/facebook\.com\//i},{platform:"linkedin",re:/linkedin\.com\//i},{platform:"pinterest",re:/(?:pinterest\.[a-z.]+|pin\.it)\//i}],g=o=>{const t=I.find(({re:e})=>e.test(o));return t?t.platform:null},M=(o,t,e)=>{const r=encodeURIComponent(t);switch(o){case"twitter":return`https://publish.twitter.com/oembed?url=${r}&omit_script=true&dnt=true`;case"tiktok":return`https://www.tiktok.com/oembed?url=${r}`;case"pinterest":return`https://www.pinterest.com/oembed.json?url=${r}`;case"instagram":{const n=e.instagram?.accessToken;return n?`https://graph.facebook.com/v17.0/instagram_oembed?url=${r}&access_token=${encodeURIComponent(n)}&omitscript=true`:null}case"facebook":{const n=e.facebook?.accessToken;return n?`https://graph.facebook.com/v17.0/oembed_post?url=${r}&access_token=${encodeURIComponent(n)}&omitscript=true`:null}case"linkedin":return null;default:return null}},P=async o=>{if(!/^https?:\/\/pin\.it\//i.test(o))return o;try{const e=(await fetch(o,{redirect:"follow"})).url||o;return/pinterest\.[a-z.]+\/pin\//i.test(e)?e:o}catch{return o}},q=o=>{const t=o.match(/(?:urn:li:)?(ugcPost|activity|share)[:-](\d{10,})/i);if(!t)return null;const r=`urn:li:${t[1].toLowerCase()==="ugcpost"?"ugcPost":t[1].toLowerCase()}:${t[2]}`,n=`https://www.linkedin.com/embed/feed/update/${encodeURIComponent(r)}`;return{platform:"linkedin",url:o,html:`<iframe src="${n}" height="600" width="100%" frameborder="0" allowfullscreen title="Embedded LinkedIn post" loading="lazy"></iframe>`,providerName:"LinkedIn",width:504,height:600}},D=o=>o.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi,"").trim(),W=(o,t,e)=>({platform:o,url:t,html:typeof e.html=="string"&&D(e.html)||null,title:typeof e.title=="string"?e.title:void 0,author:typeof e.author_name=="string"?e.author_name:void 0,authorUrl:typeof e.author_url=="string"?e.author_url:void 0,thumbnailUrl:typeof e.thumbnail_url=="string"?e.thumbnail_url:void 0,providerName:typeof e.provider_name=="string"?e.provider_name:void 0,width:typeof e.width=="number"?e.width:void 0,height:typeof e.height=="number"?e.height:void 0}),j=500,F=({strapi:o})=>{const t=()=>o.plugin("better-blocks").config("social",{});let e=null;const r=()=>(e||(e=U({max:t().cacheMaxEntries??j})),e);return{detectPlatform:g,clearCache:()=>e?.clear(),async fetch(n,s){const c=t();if(c.enabled===!1)throw new Error("Social embeds are disabled in plugin config.");const i=s??g(n);if(!i)throw new Error(`Unsupported or unrecognized social URL: ${n}`);const u=c.platforms;if(Array.isArray(u)&&!u.includes(i))throw new Error(`Platform "${i}" is not enabled in plugin config.`);const p=c.cache!==!1,m=(c.cacheTTL??86400)*1e3,b=`${i}:${n}`;if(p){const l=r().get(b);if(l)return l}let a=null;if(i==="linkedin"){if(a=q(n),!a)throw new Error("Could not derive a LinkedIn embed from this URL. Paste the post embed code instead.")}else{const l=await P(n),h=M(i,l,c);if(!h)throw new Error(`oEmbed for "${i}" requires an access token. Configure config.social.${i}.accessToken, or paste the embed code instead.`);const d=await fetch(h,{headers:{Accept:"application/json"}});if(!d.ok)throw new Error(`oEmbed request to ${i} failed (HTTP ${d.status}).`);const f=await d.json();if(typeof f.error=="string")throw new Error(`${i} could not embed this URL: ${f.error}`);if(a=W(i,l,f),!a.html)throw new Error(`${i} returned no embed markup for this URL. Check the post is public, or paste the embed code instead.`)}return p&&m>0&&r().set(b,a,m),a}}},N={service:A,oembed:F},G={register:y,bootstrap:k,destroy:w,config:C,controllers:T,routes:z,services:N,contentTypes:v,policies:_,middlewares:R};module.exports=G;
@@ -1,7 +1,7 @@
1
- const g = ({ strapi: t }) => {
2
- }, k = ({ strapi: t }) => {
3
- }, y = ({ strapi: t }) => {
4
- t.customFields.register({
1
+ const k = ({ strapi: o }) => {
2
+ }, w = ({ strapi: o }) => {
3
+ }, y = ({ strapi: o }) => {
4
+ o.customFields.register({
5
5
  name: "better-blocks",
6
6
  plugin: "better-blocks",
7
7
  type: "json",
@@ -10,7 +10,7 @@ const g = ({ strapi: t }) => {
10
10
  isResizable: !1
11
11
  }
12
12
  });
13
- }, w = {
13
+ }, C = {
14
14
  default: {
15
15
  /**
16
16
  * Defaults for the collapsible Details / Summary block.
@@ -97,32 +97,36 @@ const g = ({ strapi: t }) => {
97
97
  cache: !0,
98
98
  cacheTTL: 86400,
99
99
  // seconds (24h)
100
+ // Upper bound on cached embeds, evicted least-recently-used. Without one
101
+ // the cache keeps an entry for every post ever embedded, for the life of
102
+ // the process — expiry alone never frees anything nobody asks for again.
103
+ cacheMaxEntries: 500,
100
104
  instagram: { accessToken: void 0 },
101
105
  facebook: { accessToken: void 0 }
102
106
  }
103
107
  },
104
- validator(t = {}) {
105
- const o = t?.details?.style;
106
- if (o && o !== "github" && o !== "custom")
108
+ validator(o = {}) {
109
+ const t = o?.details?.style;
110
+ if (t && t !== "github" && t !== "custom")
107
111
  throw new Error(
108
- `[better-blocks] config.details.style must be "github" or "custom" (received "${o}")`
112
+ `[better-blocks] config.details.style must be "github" or "custom" (received "${t}")`
109
113
  );
110
114
  }
111
- }, C = {}, v = ({ strapi: t }) => ({
112
- index(o) {
113
- o.body = t.plugin("better-blocks").service("service").getWelcomeMessage();
115
+ }, v = {}, x = ({ strapi: o }) => ({
116
+ index(t) {
117
+ t.body = o.plugin("better-blocks").service("service").getWelcomeMessage();
114
118
  },
115
119
  /**
116
120
  * Exposes the plugin config (config/plugins.js merged with server defaults) to
117
121
  * the admin panel, so blocks like Details can honour admin-level settings.
118
122
  */
119
- getConfig(o) {
120
- o.body = {
121
- details: t.plugin("better-blocks").config("details", {
123
+ getConfig(t) {
124
+ t.body = {
125
+ details: o.plugin("better-blocks").config("details", {
122
126
  defaultSummary: "Click to expand",
123
127
  style: "github"
124
128
  }),
125
- button: t.plugin("better-blocks").config("button", {
129
+ button: o.plugin("better-blocks").config("button", {
126
130
  defaultStyle: {
127
131
  backgroundColor: "#4945ff",
128
132
  textColor: "#ffffff",
@@ -155,7 +159,7 @@ const g = ({ strapi: t }) => {
155
159
  }
156
160
  }),
157
161
  social: (() => {
158
- const e = t.plugin("better-blocks").config("social", {
162
+ const e = o.plugin("better-blocks").config("social", {
159
163
  enabled: !0,
160
164
  platforms: [
161
165
  "twitter",
@@ -175,35 +179,35 @@ const g = ({ strapi: t }) => {
175
179
  })()
176
180
  };
177
181
  }
178
- }), x = [
182
+ }), E = [
179
183
  "twitter",
180
184
  "instagram",
181
185
  "facebook",
182
186
  "tiktok",
183
187
  "linkedin",
184
188
  "pinterest"
185
- ], $ = ({ strapi: t }) => ({
189
+ ], $ = ({ strapi: o }) => ({
186
190
  /**
187
191
  * GET /better-blocks/oembed?url=...&platform=...
188
- * Proxies the platform oEmbed request server-side and returns the normalised
192
+ * Proxies the platform oEmbed request server-side and returns the normalized
189
193
  * payload used by the social-embed block.
190
194
  */
191
- async fetch(o) {
192
- const { url: e, platform: i } = o.query;
195
+ async fetch(t) {
196
+ const { url: e, platform: r } = t.query;
193
197
  if (!e || typeof e != "string")
194
- return o.badRequest('Missing required "url" query parameter.');
195
- const r = i && x.includes(i) ? i : void 0;
198
+ return t.badRequest('Missing required "url" query parameter.');
199
+ const n = r && E.includes(r) ? r : void 0;
196
200
  try {
197
- o.body = await t.plugin("better-blocks").service("oembed").fetch(e, r);
198
- } catch (n) {
199
- const a = n instanceof Error ? n.message : "oEmbed request failed.";
200
- return o.badRequest(a);
201
+ t.body = await o.plugin("better-blocks").service("oembed").fetch(e, n);
202
+ } catch (s) {
203
+ const c = s instanceof Error ? s.message : "oEmbed request failed.";
204
+ return t.badRequest(c);
201
205
  }
202
206
  }
203
207
  }), T = {
204
- controller: v,
208
+ controller: x,
205
209
  oembed: $
206
- }, E = {}, R = {}, L = [
210
+ }, R = {}, _ = {}, L = [
207
211
  {
208
212
  method: "GET",
209
213
  path: "/",
@@ -213,7 +217,7 @@ const g = ({ strapi: t }) => {
213
217
  policies: []
214
218
  }
215
219
  }
216
- ], _ = [
220
+ ], S = [
217
221
  {
218
222
  method: "GET",
219
223
  path: "/config",
@@ -232,20 +236,54 @@ const g = ({ strapi: t }) => {
232
236
  policies: ["admin::isAuthenticatedAdmin"]
233
237
  }
234
238
  }
235
- ], S = {
239
+ ], z = {
236
240
  "content-api": {
237
241
  type: "content-api",
238
242
  routes: L
239
243
  },
240
244
  admin: {
241
245
  type: "admin",
242
- routes: _
246
+ routes: S
243
247
  }
244
- }, U = ({ strapi: t }) => ({
248
+ }, A = ({ strapi: o }) => ({
245
249
  getWelcomeMessage() {
246
250
  return "Welcome to Strapi 🚀";
247
251
  }
248
- }), I = [
252
+ }), U = ({ max: o }) => {
253
+ if (o < 1)
254
+ throw new Error(`ttlCache: max must be at least 1 (received ${o})`);
255
+ const t = /* @__PURE__ */ new Map();
256
+ return {
257
+ get(e) {
258
+ const r = t.get(e);
259
+ if (r) {
260
+ if (r.expires <= Date.now()) {
261
+ t.delete(e);
262
+ return;
263
+ }
264
+ return t.delete(e), t.set(e, r), r.value;
265
+ }
266
+ },
267
+ set(e, r, n) {
268
+ if (t.delete(e), t.size >= o) {
269
+ const s = Date.now();
270
+ for (const [c, i] of t)
271
+ i.expires <= s && t.delete(c);
272
+ }
273
+ for (; t.size >= o; ) {
274
+ const s = t.keys().next();
275
+ if (s.done) break;
276
+ t.delete(s.value);
277
+ }
278
+ t.set(e, { expires: Date.now() + n, value: r });
279
+ },
280
+ delete: (e) => t.delete(e),
281
+ clear: () => t.clear(),
282
+ get size() {
283
+ return t.size;
284
+ }
285
+ };
286
+ }, I = [
249
287
  {
250
288
  platform: "twitter",
251
289
  re: /(?:twitter\.com|x\.com)\/[^/]+\/status(?:es)?\/\d+/i
@@ -255,28 +293,28 @@ const g = ({ strapi: t }) => {
255
293
  { platform: "facebook", re: /facebook\.com\//i },
256
294
  { platform: "linkedin", re: /linkedin\.com\//i },
257
295
  { platform: "pinterest", re: /(?:pinterest\.[a-z.]+|pin\.it)\//i }
258
- ], b = (t) => {
259
- const o = I.find(({ re: e }) => e.test(t));
260
- return o ? o.platform : null;
261
- }, A = (t, o, e) => {
262
- const i = encodeURIComponent(o);
263
- switch (t) {
296
+ ], g = (o) => {
297
+ const t = I.find(({ re: e }) => e.test(o));
298
+ return t ? t.platform : null;
299
+ }, M = (o, t, e) => {
300
+ const r = encodeURIComponent(t);
301
+ switch (o) {
264
302
  case "twitter":
265
- return `https://publish.twitter.com/oembed?url=${i}&omit_script=true&dnt=true`;
303
+ return `https://publish.twitter.com/oembed?url=${r}&omit_script=true&dnt=true`;
266
304
  case "tiktok":
267
- return `https://www.tiktok.com/oembed?url=${i}`;
305
+ return `https://www.tiktok.com/oembed?url=${r}`;
268
306
  case "pinterest":
269
- return `https://www.pinterest.com/oembed.json?url=${i}`;
307
+ return `https://www.pinterest.com/oembed.json?url=${r}`;
270
308
  case "instagram": {
271
- const r = e.instagram?.accessToken;
272
- return r ? `https://graph.facebook.com/v17.0/instagram_oembed?url=${i}&access_token=${encodeURIComponent(
273
- r
309
+ const n = e.instagram?.accessToken;
310
+ return n ? `https://graph.facebook.com/v17.0/instagram_oembed?url=${r}&access_token=${encodeURIComponent(
311
+ n
274
312
  )}&omitscript=true` : null;
275
313
  }
276
314
  case "facebook": {
277
- const r = e.facebook?.accessToken;
278
- return r ? `https://graph.facebook.com/v17.0/oembed_post?url=${i}&access_token=${encodeURIComponent(
279
- r
315
+ const n = e.facebook?.accessToken;
316
+ return n ? `https://graph.facebook.com/v17.0/oembed_post?url=${r}&access_token=${encodeURIComponent(
317
+ n
280
318
  )}&omitscript=true` : null;
281
319
  }
282
320
  case "linkedin":
@@ -284,32 +322,32 @@ const g = ({ strapi: t }) => {
284
322
  default:
285
323
  return null;
286
324
  }
287
- }, P = async (t) => {
288
- if (!/^https?:\/\/pin\.it\//i.test(t)) return t;
325
+ }, P = async (o) => {
326
+ if (!/^https?:\/\/pin\.it\//i.test(o)) return o;
289
327
  try {
290
- const e = (await fetch(t, { redirect: "follow" })).url || t;
291
- return /pinterest\.[a-z.]+\/pin\//i.test(e) ? e : t;
328
+ const e = (await fetch(o, { redirect: "follow" })).url || o;
329
+ return /pinterest\.[a-z.]+\/pin\//i.test(e) ? e : o;
292
330
  } catch {
293
- return t;
331
+ return o;
294
332
  }
295
- }, q = (t) => {
296
- const o = t.match(
333
+ }, q = (o) => {
334
+ const t = o.match(
297
335
  /(?:urn:li:)?(ugcPost|activity|share)[:-](\d{10,})/i
298
336
  );
299
- if (!o) return null;
300
- const i = `urn:li:${o[1].toLowerCase() === "ugcpost" ? "ugcPost" : o[1].toLowerCase()}:${o[2]}`, r = `https://www.linkedin.com/embed/feed/update/${encodeURIComponent(i)}`;
337
+ if (!t) return null;
338
+ const r = `urn:li:${t[1].toLowerCase() === "ugcpost" ? "ugcPost" : t[1].toLowerCase()}:${t[2]}`, n = `https://www.linkedin.com/embed/feed/update/${encodeURIComponent(r)}`;
301
339
  return {
302
340
  platform: "linkedin",
303
- url: t,
304
- html: `<iframe src="${r}" height="600" width="100%" frameborder="0" allowfullscreen title="Embedded LinkedIn post" loading="lazy"></iframe>`,
341
+ url: o,
342
+ html: `<iframe src="${n}" height="600" width="100%" frameborder="0" allowfullscreen title="Embedded LinkedIn post" loading="lazy"></iframe>`,
305
343
  providerName: "LinkedIn",
306
344
  width: 504,
307
345
  height: 600
308
346
  };
309
- }, z = (t) => t.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "").trim(), M = (t, o, e) => ({
310
- platform: t,
311
- url: o,
312
- html: typeof e.html == "string" && z(e.html) || null,
347
+ }, D = (o) => o.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "").trim(), W = (o, t, e) => ({
348
+ platform: o,
349
+ url: t,
350
+ html: typeof e.html == "string" && D(e.html) || null,
313
351
  title: typeof e.title == "string" ? e.title : void 0,
314
352
  author: typeof e.author_name == "string" ? e.author_name : void 0,
315
353
  authorUrl: typeof e.author_url == "string" ? e.author_url : void 0,
@@ -317,77 +355,83 @@ const g = ({ strapi: t }) => {
317
355
  providerName: typeof e.provider_name == "string" ? e.provider_name : void 0,
318
356
  width: typeof e.width == "number" ? e.width : void 0,
319
357
  height: typeof e.height == "number" ? e.height : void 0
320
- }), h = /* @__PURE__ */ new Map(), W = ({ strapi: t }) => {
321
- const o = () => t.plugin("better-blocks").config("social", {});
358
+ }), j = 500, F = ({ strapi: o }) => {
359
+ const t = () => o.plugin("better-blocks").config("social", {});
360
+ let e = null;
361
+ const r = () => (e || (e = U({
362
+ max: t().cacheMaxEntries ?? j
363
+ })), e);
322
364
  return {
323
- detectPlatform: b,
365
+ detectPlatform: g,
366
+ /** Exposed for tests and for anyone who needs to drop cached embeds. */
367
+ clearCache: () => e?.clear(),
324
368
  /**
325
- * Fetch (and cache) the normalised oEmbed for `url`. `platform` may be
369
+ * Fetch (and cache) the normalized oEmbed for `url`. `platform` may be
326
370
  * passed to skip auto-detection. Throws on an unsupported/unconfigured
327
371
  * platform or a failed upstream request.
328
372
  */
329
- async fetch(e, i) {
330
- const r = o();
331
- if (r.enabled === !1)
373
+ async fetch(n, s) {
374
+ const c = t();
375
+ if (c.enabled === !1)
332
376
  throw new Error("Social embeds are disabled in plugin config.");
333
- const n = i ?? b(e);
334
- if (!n)
335
- throw new Error(`Unsupported or unrecognised social URL: ${e}`);
336
- const a = r.platforms;
337
- if (Array.isArray(a) && !a.includes(n))
377
+ const i = s ?? g(n);
378
+ if (!i)
379
+ throw new Error(`Unsupported or unrecognized social URL: ${n}`);
380
+ const u = c.platforms;
381
+ if (Array.isArray(u) && !u.includes(i))
338
382
  throw new Error(
339
- `Platform "${n}" is not enabled in plugin config.`
383
+ `Platform "${i}" is not enabled in plugin config.`
340
384
  );
341
- const f = r.cache !== !1, u = (r.cacheTTL ?? 86400) * 1e3, p = `${n}:${e}`;
342
- if (f) {
343
- const c = h.get(p);
344
- if (c && c.expires > Date.now()) return c.data;
385
+ const p = c.cache !== !1, m = (c.cacheTTL ?? 86400) * 1e3, b = `${i}:${n}`;
386
+ if (p) {
387
+ const l = r().get(b);
388
+ if (l) return l;
345
389
  }
346
- let s = null;
347
- if (n === "linkedin") {
348
- if (s = q(e), !s)
390
+ let a = null;
391
+ if (i === "linkedin") {
392
+ if (a = q(n), !a)
349
393
  throw new Error(
350
394
  "Could not derive a LinkedIn embed from this URL. Paste the post embed code instead."
351
395
  );
352
396
  } else {
353
- const c = await P(e), m = A(n, c, r);
354
- if (!m)
397
+ const l = await P(n), h = M(i, l, c);
398
+ if (!h)
355
399
  throw new Error(
356
- `oEmbed for "${n}" requires an access token. Configure config.social.${n}.accessToken, or paste the embed code instead.`
400
+ `oEmbed for "${i}" requires an access token. Configure config.social.${i}.accessToken, or paste the embed code instead.`
357
401
  );
358
- const l = await fetch(m, {
402
+ const d = await fetch(h, {
359
403
  headers: { Accept: "application/json" }
360
404
  });
361
- if (!l.ok)
405
+ if (!d.ok)
362
406
  throw new Error(
363
- `oEmbed request to ${n} failed (HTTP ${l.status}).`
407
+ `oEmbed request to ${i} failed (HTTP ${d.status}).`
364
408
  );
365
- const d = await l.json();
366
- if (typeof d.error == "string")
367
- throw new Error(`${n} could not embed this URL: ${d.error}`);
368
- if (s = M(n, c, d), !s.html)
409
+ const f = await d.json();
410
+ if (typeof f.error == "string")
411
+ throw new Error(`${i} could not embed this URL: ${f.error}`);
412
+ if (a = W(i, l, f), !a.html)
369
413
  throw new Error(
370
- `${n} returned no embed markup for this URL. Check the post is public, or paste the embed code instead.`
414
+ `${i} returned no embed markup for this URL. Check the post is public, or paste the embed code instead.`
371
415
  );
372
416
  }
373
- return f && u > 0 && h.set(p, { expires: Date.now() + u, data: s }), s;
417
+ return p && m > 0 && r().set(b, a, m), a;
374
418
  }
375
419
  };
376
- }, j = {
377
- service: U,
378
- oembed: W
379
- }, D = {
420
+ }, N = {
421
+ service: A,
422
+ oembed: F
423
+ }, G = {
380
424
  register: y,
381
- bootstrap: g,
382
- destroy: k,
383
- config: w,
425
+ bootstrap: k,
426
+ destroy: w,
427
+ config: C,
384
428
  controllers: T,
385
- routes: S,
386
- services: j,
387
- contentTypes: C,
388
- policies: R,
389
- middlewares: E
429
+ routes: z,
430
+ services: N,
431
+ contentTypes: v,
432
+ policies: _,
433
+ middlewares: R
390
434
  };
391
435
  export {
392
- D as default
436
+ G as default
393
437
  };
@@ -77,6 +77,7 @@ declare const _default: {
77
77
  platforms: string[];
78
78
  cache: boolean;
79
79
  cacheTTL: number;
80
+ cacheMaxEntries: number;
80
81
  instagram: {
81
82
  accessToken: any;
82
83
  };
@@ -4,7 +4,7 @@ declare const oembed: ({ strapi }: {
4
4
  }) => {
5
5
  /**
6
6
  * GET /better-blocks/oembed?url=...&platform=...
7
- * Proxies the platform oEmbed request server-side and returns the normalised
7
+ * Proxies the platform oEmbed request server-side and returns the normalized
8
8
  * payload used by the social-embed block.
9
9
  */
10
10
  fetch(ctx: any): Promise<any>;
@@ -51,6 +51,7 @@ declare const _default: {
51
51
  platforms: string[];
52
52
  cache: boolean;
53
53
  cacheTTL: number;
54
+ cacheMaxEntries: number;
54
55
  instagram: {
55
56
  accessToken: any;
56
57
  };
@@ -115,6 +116,7 @@ declare const _default: {
115
116
  strapi: import("@strapi/types/dist/core").Strapi;
116
117
  }) => {
117
118
  detectPlatform: (url: string) => import("./services/oembed").SocialPlatform;
119
+ clearCache: () => void;
118
120
  fetch(url: string, platform?: import("./services/oembed").SocialPlatform): Promise<import("./services/oembed").NormalisedOEmbed>;
119
121
  };
120
122
  };
@@ -8,6 +8,7 @@ declare const _default: {
8
8
  strapi: import("@strapi/types/dist/core").Strapi;
9
9
  }) => {
10
10
  detectPlatform: (url: string) => import("./oembed").SocialPlatform;
11
+ clearCache: () => void;
11
12
  fetch(url: string, platform?: import("./oembed").SocialPlatform): Promise<import("./oembed").NormalisedOEmbed>;
12
13
  };
13
14
  };
@@ -12,13 +12,56 @@ export interface NormalisedOEmbed {
12
12
  width?: number;
13
13
  height?: number;
14
14
  }
15
+ export interface SocialConfig {
16
+ enabled?: boolean;
17
+ platforms?: SocialPlatform[];
18
+ cache?: boolean;
19
+ cacheTTL?: number;
20
+ cacheMaxEntries?: number;
21
+ instagram?: {
22
+ accessToken?: string;
23
+ };
24
+ facebook?: {
25
+ accessToken?: string;
26
+ };
27
+ }
15
28
  export declare const detectPlatform: (url: string) => SocialPlatform | null;
29
+ /**
30
+ * Returns the platform oEmbed endpoint to call, or null when the platform has
31
+ * no public oEmbed API (e.g. LinkedIn, which we render via a constructed iframe).
32
+ */
33
+ export declare const buildEndpoint: (platform: SocialPlatform, url: string, config: SocialConfig) => string | null;
34
+ /**
35
+ * `pin.it` short links are 30x redirects that Pinterest's oEmbed endpoint
36
+ * rejects outright, so expand them to the canonical `/pin/<id>/` URL first.
37
+ * Returns the original URL when it isn't a short link or can't be resolved.
38
+ */
39
+ export declare const resolveShortUrl: (url: string) => Promise<string>;
40
+ /** LinkedIn has no oEmbed API but exposes a stable iframe embed by URN. */
41
+ export declare const buildLinkedInIframe: (url: string) => NormalisedOEmbed | null;
42
+ /**
43
+ * Platform oEmbed markup often ships the widget script inline (TikTok always
44
+ * does; Twitter/Instagram only when `omit_script` isn't honoured). Renderers
45
+ * inject that script themselves, and a `<script>` added through `innerHTML`
46
+ * never executes anyway — worse, its inert tag makes a renderer believe the
47
+ * widget is already loaded. Strip it so the stored html is markup only.
48
+ */
49
+ export declare const stripScripts: (html: string) => string;
50
+ export declare const normalize: (platform: SocialPlatform, url: string, raw: Record<string, unknown>) => NormalisedOEmbed;
51
+ /**
52
+ * Bounded so a long-running instance cannot accumulate one entry per social post
53
+ * ever embedded. 500 posts is far more than an editor touches in a TTL window,
54
+ * and `cacheMaxEntries` raises it for anyone who disagrees.
55
+ */
56
+ export declare const DEFAULT_CACHE_MAX_ENTRIES = 500;
16
57
  declare const service: ({ strapi }: {
17
58
  strapi: Core.Strapi;
18
59
  }) => {
19
60
  detectPlatform: (url: string) => SocialPlatform | null;
61
+ /** Exposed for tests and for anyone who needs to drop cached embeds. */
62
+ clearCache: () => void;
20
63
  /**
21
- * Fetch (and cache) the normalised oEmbed for `url`. `platform` may be
64
+ * Fetch (and cache) the normalized oEmbed for `url`. `platform` may be
22
65
  * passed to skip auto-detection. Throws on an unsupported/unconfigured
23
66
  * platform or a failed upstream request.
24
67
  */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A bounded cache with per-entry expiry.
3
+ *
4
+ * The oEmbed service used a bare `Map` keyed by URL, which meant one entry for
5
+ * every social post ever embedded, for the life of the process. Entries expired
6
+ * logically — a stale hit was discarded on read — but nothing ever removed them,
7
+ * so a long-running instance kept the memory whether or not anyone asked for
8
+ * that post again.
9
+ *
10
+ * Eviction is least-recently-used: a hit moves its entry to the end, and a write
11
+ * past `max` drops the entry at the front. `Map` iterates in insertion order and
12
+ * re-inserting moves a key to the end, which is the whole implementation.
13
+ */
14
+ export interface TtlCache<T> {
15
+ get(key: string): T | undefined;
16
+ set(key: string, value: T, ttlMs: number): void;
17
+ delete(key: string): boolean;
18
+ clear(): void;
19
+ readonly size: number;
20
+ }
21
+ export declare const createTtlCache: <T>({ max }: {
22
+ max: number;
23
+ }) => TtlCache<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qkix/strapi-plugin-better-blocks",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "An enhanced Rich Text (Blocks) field for Strapi with inline color picker and more",
5
5
  "strapi": {
6
6
  "kind": "plugin",
@@ -55,7 +55,7 @@
55
55
  "remark-parse": "^11.0.0",
56
56
  "shiki": "^4.3.0",
57
57
  "unified": "^11.0.5",
58
- "@qkix/better-blocks-core": "^0.2.1"
58
+ "@qkix/better-blocks-core": "^0.2.2"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@strapi/sdk-plugin": "^5.3.2",
@@ -73,8 +73,9 @@
73
73
  "styled-components": "^6.1.17",
74
74
  "typescript": "^5.8.3",
75
75
  "vitest": "^3.2.4",
76
- "@qkix/tsconfig": "0.0.0",
77
- "@qkix/eslint-config": "0.0.0"
76
+ "@qkix/eslint-config": "0.0.0",
77
+ "@qkix/strapi-test-harness": "0.0.0",
78
+ "@qkix/tsconfig": "0.0.0"
78
79
  },
79
80
  "peerDependencies": {
80
81
  "@radix-ui/react-toolbar": "^1.0.0",
@@ -110,7 +111,15 @@
110
111
  "nx": {
111
112
  "tags": [
112
113
  "scope:plugin"
113
- ]
114
+ ],
115
+ "targets": {
116
+ "test": {
117
+ "dependsOn": [
118
+ "build",
119
+ "^build"
120
+ ]
121
+ }
122
+ }
114
123
  },
115
124
  "publishConfig": {
116
125
  "access": "public"
@@ -127,6 +136,6 @@
127
136
  "verify": "strapi-plugin verify",
128
137
  "watch": "strapi-plugin watch",
129
138
  "watch:link": "strapi-plugin watch:link",
130
- "typecheck": "tsc -p admin/tsconfig.json && tsc -p server/tsconfig.json"
139
+ "typecheck": "tsc -p admin/tsconfig.json --noEmit && tsc -p server/tsconfig.json --noEmit"
131
140
  }
132
141
  }