@blocklify/preview 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blocklify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,21 @@
1
- # Temporary Holding Version
1
+ # @blocklify/preview
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Frontend SDK for [Blocklify](https://github.com/): Content API client + live-preview React helpers for your theme.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @blocklify/preview
9
+ # or: npm install @blocklify/preview
10
+ ```
11
+
12
+ Peer dependencies: `react`, `react-dom` (≥18).
13
+
14
+ ## Live preview (theme blocks)
15
+
16
+ 1. Mount `BlocklifyPreview` on a `/preview` route with your `blockMap`.
17
+ 2. In each theme block, accept `BlocklifyBlockProps` and spread `{...BlocklifyEditable}` on the root element.
18
+ 3. Read CMS custom fields from `block` (e.g. `block.headline`).
19
+ 4. Register that preview URL under Space → Live preview URLs.
20
+
21
+ See the in-app guide at `/docs/getting-started#live-preview`.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Lightweight client for the Blocklify Content API.
3
+ * Use from your website or app to fetch entries and components with an access token.
4
+ *
5
+ * @example
6
+ * const client = createBlocklifyClient({
7
+ * baseUrl: process.env.BLOCKLIFY_API_URL!,
8
+ * accessToken: process.env.BLOCKLIFY_ACCESS_TOKEN!,
9
+ * });
10
+ * const entries = await client.getEntries({ published: true });
11
+ * const home = await client.getEntry("home");
12
+ */
13
+ export type BlocklifyClientConfig = {
14
+ /** Blocklify app base URL (e.g. https://your-blocklify-app.com, no trailing slash) */
15
+ baseUrl: string;
16
+ /** Access token (from Admin → Access tokens). Prefix: blocklify_... */
17
+ accessToken: string;
18
+ };
19
+ export type BlocklifyEntry = {
20
+ id: string;
21
+ spaceId: string;
22
+ slug: string;
23
+ name: string;
24
+ content: Record<string, unknown> & {
25
+ body?: Array<{
26
+ type: string;
27
+ [k: string]: unknown;
28
+ }>;
29
+ blocks?: Array<{
30
+ type: string;
31
+ [k: string]: unknown;
32
+ }>;
33
+ title?: string;
34
+ };
35
+ isPublished: boolean;
36
+ publishedAt: string | null;
37
+ contentType: {
38
+ id: string;
39
+ type: string;
40
+ name?: string;
41
+ schema?: string;
42
+ };
43
+ createdAt: string;
44
+ updatedAt: string;
45
+ };
46
+ export type BlocklifyComponent = {
47
+ id: string;
48
+ spaceId: string;
49
+ name: string;
50
+ type: string;
51
+ schema: {
52
+ fields?: Array<{
53
+ name: string;
54
+ type: string;
55
+ [k: string]: unknown;
56
+ }>;
57
+ };
58
+ isRoot: boolean;
59
+ isNestable: boolean;
60
+ };
61
+ export type BlocklifySiteAsset = {
62
+ id: string;
63
+ url: string;
64
+ filename: string;
65
+ mimeType: string;
66
+ alt: string | null;
67
+ };
68
+ export type BlocklifySiteSettings = {
69
+ defaultPageTitle: string | null;
70
+ favicon: BlocklifySiteAsset | null;
71
+ logo: BlocklifySiteAsset | null;
72
+ custom: Array<{
73
+ key: string;
74
+ value: string;
75
+ }>;
76
+ };
77
+ export declare function createBlocklifyClient(config: BlocklifyClientConfig): {
78
+ /** List entries. Use published: true for production; omit for preview (drafts + published). */
79
+ getEntries(options?: {
80
+ published?: boolean;
81
+ spaceId?: string;
82
+ }): Promise<BlocklifyEntry[]>;
83
+ /** Get one entry by slug. Use for live preview. Returns null when entry is not found (404). */
84
+ getEntry(slug: string, options?: {
85
+ spaceId?: string;
86
+ }): Promise<BlocklifyEntry | null>;
87
+ /** List components (block definitions) for the space. */
88
+ getComponents(options?: {
89
+ spaceId?: string;
90
+ }): Promise<BlocklifyComponent[]>;
91
+ /** Site settings (default title, favicon, logo, custom key–value pairs). */
92
+ getSiteSettings(options?: {
93
+ spaceId?: string;
94
+ }): Promise<BlocklifySiteSettings>;
95
+ };
package/dist/client.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Lightweight client for the Blocklify Content API.
3
+ * Use from your website or app to fetch entries and components with an access token.
4
+ *
5
+ * @example
6
+ * const client = createBlocklifyClient({
7
+ * baseUrl: process.env.BLOCKLIFY_API_URL!,
8
+ * accessToken: process.env.BLOCKLIFY_ACCESS_TOKEN!,
9
+ * });
10
+ * const entries = await client.getEntries({ published: true });
11
+ * const home = await client.getEntry("home");
12
+ */
13
+ function buildHeaders(accessToken) {
14
+ return {
15
+ Authorization: `Bearer ${accessToken}`,
16
+ "Content-Type": "application/json",
17
+ };
18
+ }
19
+ function ensureNoTrailingSlash(url) {
20
+ return url.replace(/\/+$/, "");
21
+ }
22
+ export function createBlocklifyClient(config) {
23
+ const baseUrl = ensureNoTrailingSlash(config.baseUrl);
24
+ const headers = buildHeaders(config.accessToken);
25
+ async function request(path, options) {
26
+ const url = new URL(path.startsWith("http") ? path : baseUrl + path);
27
+ if (options?.searchParams) {
28
+ Object.entries(options.searchParams).forEach(([k, v]) => url.searchParams.set(k, v));
29
+ }
30
+ const res = await fetch(url.toString(), {
31
+ method: options?.method ?? "GET",
32
+ headers,
33
+ cache: "no-store",
34
+ });
35
+ if (!res.ok) {
36
+ const err = (await res.json().catch(() => ({})));
37
+ throw new Error(err.error ?? `API error ${res.status}`);
38
+ }
39
+ return res.json();
40
+ }
41
+ return {
42
+ /** List entries. Use published: true for production; omit for preview (drafts + published). */
43
+ getEntries(options) {
44
+ const searchParams = {};
45
+ if (options?.published === true)
46
+ searchParams.published = "true";
47
+ if (options?.spaceId)
48
+ searchParams.space = options.spaceId;
49
+ return request("/api/entries", { searchParams });
50
+ },
51
+ /** Get one entry by slug. Use for live preview. Returns null when entry is not found (404). */
52
+ async getEntry(slug, options) {
53
+ const searchParams = {};
54
+ if (options?.spaceId)
55
+ searchParams.space = options.spaceId;
56
+ const query = new URLSearchParams(searchParams).toString();
57
+ const path = `/api/entries/${encodeURIComponent(slug)}${query ? `?${query}` : ""}`;
58
+ const url = new URL(path.startsWith("http") ? path : baseUrl + path);
59
+ const res = await fetch(url.toString(), { method: "GET", headers, cache: "no-store" });
60
+ if (res.status === 404)
61
+ return null;
62
+ if (!res.ok) {
63
+ const err = (await res.json().catch(() => ({})));
64
+ throw new Error(err.error ?? `API error ${res.status}`);
65
+ }
66
+ return res.json();
67
+ },
68
+ /** List components (block definitions) for the space. */
69
+ getComponents(options) {
70
+ const searchParams = {};
71
+ if (options?.spaceId)
72
+ searchParams.space = options.spaceId;
73
+ return request("/api/components", { searchParams });
74
+ },
75
+ /** Site settings (default title, favicon, logo, custom key–value pairs). */
76
+ getSiteSettings(options) {
77
+ const searchParams = {};
78
+ if (options?.spaceId)
79
+ searchParams.space = options.spaceId;
80
+ return request("/api/site-settings", { searchParams });
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @blocklify/preview — install in your frontend / theme to fetch content and enable live preview.
3
+ *
4
+ * @example
5
+ * import {
6
+ * BlocklifyPreview,
7
+ * createBlocklifyClient,
8
+ * type BlocklifyBlockProps,
9
+ * } from "@blocklify/preview";
10
+ */
11
+ export { createBlocklifyClient, type BlocklifyClientConfig, type BlocklifyComponent, type BlocklifyEntry, type BlocklifySiteAsset, type BlocklifySiteSettings, } from "./client.js";
12
+ export { BlocklifyEditable, BlocklifyPreview, BlocklifyPreviewRenderer, BLOCKLIFY_PEER_FOCUS, BLOCKLIFY_PREVIEW_STYLES, BLOCKLIFY_SCROLL_TO_BLOCK, getDataBlokC, PREVIEW_EDITABLE_CSS, useBlocklifyPreview, type BlocklifyBlock, type BlocklifyBlockProps, type BlocklifyPreviewProps, type BlockMap, type DataBlokCPayload, } from "./preview.js";
13
+ export { default } from "./preview.js";
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @blocklify/preview — install in your frontend / theme to fetch content and enable live preview.
3
+ *
4
+ * @example
5
+ * import {
6
+ * BlocklifyPreview,
7
+ * createBlocklifyClient,
8
+ * type BlocklifyBlockProps,
9
+ * } from "@blocklify/preview";
10
+ */
11
+ export { createBlocklifyClient, } from "./client.js";
12
+ export { BlocklifyEditable, BlocklifyPreview, BlocklifyPreviewRenderer, BLOCKLIFY_PEER_FOCUS, BLOCKLIFY_PREVIEW_STYLES, BLOCKLIFY_SCROLL_TO_BLOCK, getDataBlokC, PREVIEW_EDITABLE_CSS, useBlocklifyPreview, } from "./preview.js";
13
+ export { default } from "./preview.js";
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ /**
3
+ * Blocklify live-preview connector. Editable wrapper styles (border, hover) are defined here (PREVIEW_EDITABLE_CSS)
4
+ * and sent from the admin to the iframe via postMessage (blocklify-preview-styles). The other app only needs
5
+ * to use this lib and listen for that message; it will receive and inject the same styles so no duplication.
6
+ */
7
+ /** Block shape sent by Blocklify (draft content or API). Your block components receive this. */
8
+ export type BlocklifyBlock = {
9
+ type: string;
10
+ _uid?: string;
11
+ [k: string]: unknown;
12
+ };
13
+ /** Props passed to block components: block, optional BlocklifyEditable (spread on root in iframe), optional blockMap (for nested blocks). */
14
+ export type BlocklifyBlockProps = {
15
+ block: BlocklifyBlock;
16
+ BlocklifyEditable?: Record<string, unknown>;
17
+ blockMap?: BlockMap;
18
+ };
19
+ /** Map block type string → component that receives { block, BlocklifyEditable? }. Spread BlocklifyEditable on the root element for click-to-focus in the preview iframe. */
20
+ export type BlockMap = Record<string, React.ComponentType<BlocklifyBlockProps>>;
21
+ /** Storyblok-style payload for data-blok-c (JSON string). */
22
+ export type DataBlokCPayload = {
23
+ name: string;
24
+ uid: string;
25
+ space?: string;
26
+ id?: string;
27
+ };
28
+ /**
29
+ * Builds the value for the data-blok-c attribute (Storyblok-style JSON string).
30
+ * Use when you need to set data-blok-c yourself; otherwise BlocklifyEditable includes it.
31
+ */
32
+ export declare function getDataBlokC(block: BlocklifyBlock, options?: {
33
+ spaceId?: string;
34
+ blockId?: string;
35
+ }): string;
36
+ /**
37
+ * Returns props to spread on the root element of a block so it becomes clickable in the preview iframe (like Storyblok’s storyblokEditable).
38
+ * Sets data-blok-c (Storyblok-style JSON: name, uid, space?, id?) and data-blok-uid for compatibility.
39
+ * Only returns non-empty when in iframe with onBlockClick; otherwise returns {} so components work unchanged outside preview.
40
+ * Use in the preview renderer; components just spread the BlocklifyEditable prop they receive.
41
+ * Clicks send the block's _uid so the editor can open the form for the correct block.
42
+ */
43
+ export declare function BlocklifyEditable(block: BlocklifyBlock, index: number, onBlockClick: ((uid: string) => void) | undefined, options?: {
44
+ spaceId?: string;
45
+ blockId?: string;
46
+ }): Record<string, unknown>;
47
+ /** Message type the admin sends to the iframe with editable wrapper CSS (single source of truth in Blocklify). */
48
+ export declare const BLOCKLIFY_PREVIEW_STYLES = "blocklify-preview-styles";
49
+ /** Message type the admin sends when peer-focused blocks change, so the preview can render colored borders. */
50
+ export declare const BLOCKLIFY_PEER_FOCUS = "blocklify-peer-focus";
51
+ /** Message type the admin sends to scroll a block into view in the preview iframe (e.g. when focusing a field in the editor). */
52
+ export declare const BLOCKLIFY_SCROLL_TO_BLOCK = "blocklify-scroll-to-block";
53
+ /** Single source of truth for editable block styling in the preview iframe. Sent by the admin via postMessage so the other app doesn’t need to duplicate it. */
54
+ export declare const PREVIEW_EDITABLE_CSS = "\n:root {\n --radius: 0.5rem;\n}\n[data-blocklify-iframe-editable],\n[data-blok-uid] {\n cursor: pointer !important;\n border: 2px solid #2563eb !important;\n border-radius: var(--radius) !important;\n padding: 6px !important;\n margin-bottom: 10px !important;\n box-sizing: border-box !important;\n transition: border-color 0.2s ease, background-color 0.2s ease !important;\n position: relative !important;\n min-height: 24px !important;\n}\n[data-blocklify-iframe-editable]:hover,\n[data-blok-uid]:hover {\n border-color: #1d4ed8 !important;\n background-color: rgba(37, 99, 235, 0.06) !important;\n}\n@keyframes blocklify-preview-focus-blink {\n 0% {\n box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);\n border-color: #2563eb !important;\n }\n 35% {\n box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.45);\n border-color: #1d4ed8 !important;\n }\n 100% {\n box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);\n border-color: #2563eb !important;\n }\n}\n.blocklify-preview-focus-blink {\n animation: blocklify-preview-focus-blink 0.55s ease-out 1 !important;\n}\n@media (prefers-reduced-motion: reduce) {\n .blocklify-preview-focus-blink {\n animation: none !important;\n }\n}\n";
55
+ /**
56
+ * Hook that connects your preview page to the Blocklify admin iframe:
57
+ * - Listens for draft content via postMessage (type: "blocklify-draft").
58
+ * - Sends block clicks back to the editor (type: "blocklify-block-click").
59
+ * Use this when your preview page is loaded inside the Blocklify admin iframe.
60
+ */
61
+ export declare function useBlocklifyPreview(blocksFieldName?: string): {
62
+ blocks: BlocklifyBlock[];
63
+ isInIframe: boolean;
64
+ onBlockClick: (uid: string) => void;
65
+ showEmptyMessage: boolean;
66
+ /** Space id from the draft message (for data-blok-c when admin sends spaceId). */
67
+ spaceId: string | null;
68
+ /** Use this when rendering editable wrappers: parent-sent CSS or PREVIEW_EDITABLE_CSS. */
69
+ editableCss: string;
70
+ /** Peer-focused blocks: uid → { name, color }. Inject as colored borders to show who is editing what. */
71
+ peerFocusedBlocks: Record<string, {
72
+ name: string;
73
+ color: string;
74
+ }>;
75
+ };
76
+ type BlocklifyPreviewRendererProps = {
77
+ blocks: BlocklifyBlock[];
78
+ blockMap: BlockMap;
79
+ onBlockClick?: (uid: string) => void;
80
+ /** Space id for data-blok-c (Storyblok-style). Pass from useBlocklifyPreview().spaceId when admin sends it. */
81
+ spaceId?: string | null;
82
+ /** CSS for editable wrappers. When in iframe, pass editableCss from useBlocklifyPreview (from parent or default). */
83
+ editableCss?: string;
84
+ /** Optional: pass to each component so nested blocks (e.g. Page, Grid) can render. */
85
+ blockMapForNested?: BlockMap;
86
+ };
87
+ /**
88
+ * Renders blocks using your blockMap. Passes BlocklifyEditable (from BlocklifyEditable()) so components can spread it on their root for click-to-focus in the iframe.
89
+ * Styles are injected from editableCss (sent by admin via postMessage). No wrapper div – components apply {...BlocklifyEditable} themselves.
90
+ */
91
+ export declare function BlocklifyPreviewRenderer({ blocks, blockMap, onBlockClick, spaceId, editableCss, blockMapForNested, }: BlocklifyPreviewRendererProps): import("react/jsx-runtime").JSX.Element;
92
+ export type BlocklifyPreviewProps = {
93
+ /** Map of block type → component that receives { block }. Define your blocks in your app and pass this. */
94
+ blockMap: BlockMap;
95
+ /** Key in draft content that holds the blocks array (default "body"). */
96
+ blocksFieldName?: string;
97
+ /** Message shown when the preview iframe has not received draft content yet. */
98
+ emptyMessage?: string;
99
+ /** Optional className for the root wrapper. */
100
+ className?: string;
101
+ };
102
+ /**
103
+ * Preview connector for Blocklify. Use this on your frontend’s preview page (e.g. /preview).
104
+ * You only need to define your block components and a blockMap; this component handles:
105
+ * - Receiving draft content from the Blocklify admin via postMessage
106
+ * - Sending block clicks back to the editor
107
+ * - Empty state message and editable borders when embedded in the admin iframe
108
+ */
109
+ export declare function BlocklifyPreview({ blockMap, blocksFieldName, emptyMessage, className, }: BlocklifyPreviewProps): import("react/jsx-runtime").JSX.Element;
110
+ /** Default export so both "import BlocklifyPreview from '...'" and "import { BlocklifyPreview } from '...'" work. */
111
+ export default BlocklifyPreview;
@@ -0,0 +1,256 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
4
+ /**
5
+ * Builds the value for the data-blok-c attribute (Storyblok-style JSON string).
6
+ * Use when you need to set data-blok-c yourself; otherwise BlocklifyEditable includes it.
7
+ */
8
+ export function getDataBlokC(block, options) {
9
+ const payload = {
10
+ name: block.type,
11
+ uid: block._uid ?? "",
12
+ ...(options?.spaceId != null && options.spaceId !== "" && { space: options.spaceId }),
13
+ ...((options?.blockId ?? block.id) != null &&
14
+ (options?.blockId ?? block.id) !== "" && {
15
+ id: String(options?.blockId ?? block.id),
16
+ }),
17
+ };
18
+ return JSON.stringify(payload);
19
+ }
20
+ /**
21
+ * Returns props to spread on the root element of a block so it becomes clickable in the preview iframe (like Storyblok’s storyblokEditable).
22
+ * Sets data-blok-c (Storyblok-style JSON: name, uid, space?, id?) and data-blok-uid for compatibility.
23
+ * Only returns non-empty when in iframe with onBlockClick; otherwise returns {} so components work unchanged outside preview.
24
+ * Use in the preview renderer; components just spread the BlocklifyEditable prop they receive.
25
+ * Clicks send the block's _uid so the editor can open the form for the correct block.
26
+ */
27
+ export function BlocklifyEditable(block, index, onBlockClick, options) {
28
+ if (!onBlockClick)
29
+ return {};
30
+ const uid = block._uid ?? "";
31
+ return {
32
+ "data-blocklify-iframe-editable": true,
33
+ "data-blok-c": getDataBlokC(block, options),
34
+ ...(uid && { "data-blok-uid": uid }),
35
+ role: "button",
36
+ tabIndex: 0,
37
+ title: "Click to focus in editor",
38
+ "aria-label": `Edit block ${index + 1}, ${block.type}`,
39
+ onClick: (e) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ onBlockClick(uid);
43
+ },
44
+ onKeyDown: (e) => {
45
+ if (e.key === "Enter" || e.key === " ") {
46
+ e.preventDefault();
47
+ onBlockClick(uid);
48
+ }
49
+ },
50
+ };
51
+ }
52
+ const BLOCKLIFY_DRAFT = "blocklify-draft";
53
+ const BLOCKLIFY_BLOCK_CLICK = "blocklify-block-click";
54
+ /** Message type the admin sends to the iframe with editable wrapper CSS (single source of truth in Blocklify). */
55
+ export const BLOCKLIFY_PREVIEW_STYLES = "blocklify-preview-styles";
56
+ /** Message type the admin sends when peer-focused blocks change, so the preview can render colored borders. */
57
+ export const BLOCKLIFY_PEER_FOCUS = "blocklify-peer-focus";
58
+ /** Message type the admin sends to scroll a block into view in the preview iframe (e.g. when focusing a field in the editor). */
59
+ export const BLOCKLIFY_SCROLL_TO_BLOCK = "blocklify-scroll-to-block";
60
+ const PREVIEW_FOCUS_BLINK_CLASS = "blocklify-preview-focus-blink";
61
+ const PREVIEW_FOCUS_BLINK_MS = 550;
62
+ function pulsePreviewBlock(el) {
63
+ el.classList.remove(PREVIEW_FOCUS_BLINK_CLASS);
64
+ void el.offsetWidth;
65
+ el.classList.add(PREVIEW_FOCUS_BLINK_CLASS);
66
+ window.setTimeout(() => {
67
+ el.classList.remove(PREVIEW_FOCUS_BLINK_CLASS);
68
+ }, PREVIEW_FOCUS_BLINK_MS);
69
+ }
70
+ function scrollPreviewBlockIntoView(uid) {
71
+ if (typeof document === "undefined" || !uid)
72
+ return;
73
+ const escaped = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(uid) : uid;
74
+ const el = document.querySelector(`[data-blok-uid="${escaped}"]`) ??
75
+ document.querySelector(`[data-blocklify-uid="${escaped}"]`);
76
+ if (!(el instanceof HTMLElement))
77
+ return;
78
+ el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
79
+ pulsePreviewBlock(el);
80
+ }
81
+ /** Single source of truth for editable block styling in the preview iframe. Sent by the admin via postMessage so the other app doesn’t need to duplicate it. */
82
+ export const PREVIEW_EDITABLE_CSS = `
83
+ :root {
84
+ --radius: 0.5rem;
85
+ }
86
+ [data-blocklify-iframe-editable],
87
+ [data-blok-uid] {
88
+ cursor: pointer !important;
89
+ border: 2px solid #2563eb !important;
90
+ border-radius: var(--radius) !important;
91
+ padding: 6px !important;
92
+ margin-bottom: 10px !important;
93
+ box-sizing: border-box !important;
94
+ transition: border-color 0.2s ease, background-color 0.2s ease !important;
95
+ position: relative !important;
96
+ min-height: 24px !important;
97
+ }
98
+ [data-blocklify-iframe-editable]:hover,
99
+ [data-blok-uid]:hover {
100
+ border-color: #1d4ed8 !important;
101
+ background-color: rgba(37, 99, 235, 0.06) !important;
102
+ }
103
+ @keyframes blocklify-preview-focus-blink {
104
+ 0% {
105
+ box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);
106
+ border-color: #2563eb !important;
107
+ }
108
+ 35% {
109
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.45);
110
+ border-color: #1d4ed8 !important;
111
+ }
112
+ 100% {
113
+ box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);
114
+ border-color: #2563eb !important;
115
+ }
116
+ }
117
+ .${PREVIEW_FOCUS_BLINK_CLASS} {
118
+ animation: blocklify-preview-focus-blink 0.55s ease-out 1 !important;
119
+ }
120
+ @media (prefers-reduced-motion: reduce) {
121
+ .${PREVIEW_FOCUS_BLINK_CLASS} {
122
+ animation: none !important;
123
+ }
124
+ }
125
+ `;
126
+ function subscribeIsInIframe() {
127
+ // iframe embedding does not change for the lifetime of the page.
128
+ return () => { };
129
+ }
130
+ function getIsInIframeSnapshot() {
131
+ return window.self !== window.top;
132
+ }
133
+ function getIsInIframeServerSnapshot() {
134
+ return false;
135
+ }
136
+ /**
137
+ * Hook that connects your preview page to the Blocklify admin iframe:
138
+ * - Listens for draft content via postMessage (type: "blocklify-draft").
139
+ * - Sends block clicks back to the editor (type: "blocklify-block-click").
140
+ * Use this when your preview page is loaded inside the Blocklify admin iframe.
141
+ */
142
+ export function useBlocklifyPreview(blocksFieldName = "body") {
143
+ const [draftContent, setDraftContent] = useState(null);
144
+ /** CSS for editable wrappers: from parent (postMessage) or fallback to PREVIEW_EDITABLE_CSS. */
145
+ const [receivedEditableCss, setReceivedEditableCss] = useState(null);
146
+ const [peerFocusedBlocks, setPeerFocusedBlocks] = useState({});
147
+ const isInIframe = useSyncExternalStore(subscribeIsInIframe, getIsInIframeSnapshot, getIsInIframeServerSnapshot);
148
+ const [spaceId, setSpaceId] = useState(null);
149
+ useEffect(() => {
150
+ if (!isInIframe)
151
+ return;
152
+ const onMessage = (event) => {
153
+ if (event.data?.type === BLOCKLIFY_DRAFT && event.data.content != null) {
154
+ setDraftContent(event.data.content);
155
+ setSpaceId(typeof event.data.spaceId === "string" && event.data.spaceId.trim() !== ""
156
+ ? event.data.spaceId
157
+ : null);
158
+ }
159
+ if (event.data?.type === BLOCKLIFY_PREVIEW_STYLES &&
160
+ typeof event.data.css === "string") {
161
+ setReceivedEditableCss(event.data.css);
162
+ }
163
+ if (event.data?.type === BLOCKLIFY_PEER_FOCUS && event.data.peers != null) {
164
+ setPeerFocusedBlocks(event.data.peers);
165
+ }
166
+ if (event.data?.type === BLOCKLIFY_SCROLL_TO_BLOCK &&
167
+ typeof event.data.uid === "string" &&
168
+ event.data.uid.trim() !== "") {
169
+ // Wait a frame so draft content / DOM from a concurrent update can settle.
170
+ requestAnimationFrame(() => {
171
+ scrollPreviewBlockIntoView(event.data.uid);
172
+ });
173
+ }
174
+ };
175
+ window.addEventListener("message", onMessage);
176
+ return () => window.removeEventListener("message", onMessage);
177
+ }, [isInIframe]);
178
+ const onBlockClick = useCallback((uid) => {
179
+ if (typeof window === "undefined" || window === window.parent)
180
+ return;
181
+ window.parent.postMessage({ type: BLOCKLIFY_BLOCK_CLICK, uid, blocksFieldName }, "*");
182
+ }, [blocksFieldName]);
183
+ const rawBlocks = draftContent?.[blocksFieldName];
184
+ const blocks = Array.isArray(rawBlocks)
185
+ ? rawBlocks.filter((b) => b != null &&
186
+ typeof b === "object" &&
187
+ typeof b.type === "string")
188
+ : [];
189
+ const showEmptyMessage = isInIframe && blocks.length === 0 && draftContent === null;
190
+ return {
191
+ blocks,
192
+ isInIframe,
193
+ onBlockClick,
194
+ showEmptyMessage,
195
+ /** Space id from the draft message (for data-blok-c when admin sends spaceId). */
196
+ spaceId,
197
+ /** Use this when rendering editable wrappers: parent-sent CSS or PREVIEW_EDITABLE_CSS. */
198
+ editableCss: receivedEditableCss ?? PREVIEW_EDITABLE_CSS,
199
+ /** Peer-focused blocks: uid → { name, color }. Inject as colored borders to show who is editing what. */
200
+ peerFocusedBlocks,
201
+ };
202
+ }
203
+ /** Fallback when a block type has no component in the blockMap – never pass undefined to React. */
204
+ function UnknownBlockFallback({ block, BlocklifyEditable = {}, }) {
205
+ return (_jsxs("section", { style: {
206
+ padding: "1rem 1.25rem",
207
+ opacity: 0.9,
208
+ border: "2px dashed #94a3b8",
209
+ borderRadius: 8,
210
+ backgroundColor: "rgba(148, 163, 184, 0.08)",
211
+ color: "#64748b",
212
+ fontSize: "0.875rem",
213
+ }, ...BlocklifyEditable, children: [_jsxs("strong", { style: { color: "#475569" }, children: ["Block type \"", block.type, "\" not found."] }), _jsx("p", { style: { margin: "0.5rem 0 0", lineHeight: 1.5 }, children: "Add a component for this block type to your project's blockMap (e.g. in your preview page) to render it here." })] }));
214
+ }
215
+ /**
216
+ * Renders blocks using your blockMap. Passes BlocklifyEditable (from BlocklifyEditable()) so components can spread it on their root for click-to-focus in the iframe.
217
+ * Styles are injected from editableCss (sent by admin via postMessage). No wrapper div – components apply {...BlocklifyEditable} themselves.
218
+ */
219
+ export function BlocklifyPreviewRenderer({ blocks, blockMap, onBlockClick, spaceId, editableCss = PREVIEW_EDITABLE_CSS, blockMapForNested, }) {
220
+ const safeBlockMap = blockMap ?? {};
221
+ const nestedMap = blockMapForNested ?? safeBlockMap;
222
+ const resolveComponent = (type) => safeBlockMap[type] ??
223
+ (type.endsWith("_block") ? safeBlockMap[type.slice(0, -6)] : undefined) ??
224
+ UnknownBlockFallback;
225
+ return (_jsxs(_Fragment, { children: [_jsx("style", { id: "blocklify-preview-editable-styles", dangerouslySetInnerHTML: { __html: editableCss } }), blocks.map((block, i) => {
226
+ const Component = resolveComponent(block.type);
227
+ const key = block._uid ?? i;
228
+ const blocklifyEditable = BlocklifyEditable(block, i, onBlockClick, {
229
+ spaceId: spaceId ?? undefined,
230
+ blockId: block.id,
231
+ });
232
+ return (_jsx(Component, { block: block, BlocklifyEditable: blocklifyEditable, ...(nestedMap && { blockMap: nestedMap }) }, key));
233
+ })] }));
234
+ }
235
+ /**
236
+ * Preview connector for Blocklify. Use this on your frontend’s preview page (e.g. /preview).
237
+ * You only need to define your block components and a blockMap; this component handles:
238
+ * - Receiving draft content from the Blocklify admin via postMessage
239
+ * - Sending block clicks back to the editor
240
+ * - Empty state message and editable borders when embedded in the admin iframe
241
+ */
242
+ export function BlocklifyPreview({ blockMap, blocksFieldName = "body", emptyMessage = "Waiting for content from editor…", className, }) {
243
+ const { blocks, isInIframe, onBlockClick, showEmptyMessage, spaceId, editableCss, peerFocusedBlocks } = useBlocklifyPreview(blocksFieldName);
244
+ const peerFocusCss = Object.entries(peerFocusedBlocks)
245
+ .map(([uid, { color }]) => `[data-blok-uid="${uid}"] { border-color: ${color} !important; box-shadow: 0 0 0 2px ${color} !important; }`)
246
+ .join("\n");
247
+ return (_jsxs("div", { className: className ?? "min-h-screen bg-surface text-text blocklify-preview-non-interactive", children: [_jsx("style", { children: `
248
+ .blocklify-preview-non-interactive a,
249
+ .blocklify-preview-non-interactive button {
250
+ pointer-events: none;
251
+ cursor: default;
252
+ }
253
+ ` }), peerFocusCss && (_jsx("style", { id: "blocklify-peer-focus-styles", children: peerFocusCss })), showEmptyMessage && (_jsx("div", { className: "flex min-h-screen items-center justify-center", children: _jsx("p", { className: "text-sm text-text-muted", children: emptyMessage }) })), _jsx(BlocklifyPreviewRenderer, { blocks: blocks, blockMap: blockMap, onBlockClick: isInIframe ? onBlockClick : undefined, spaceId: spaceId, editableCss: editableCss, blockMapForNested: blockMap })] }));
254
+ }
255
+ /** Default export so both "import BlocklifyPreview from '...'" and "import { BlocklifyPreview } from '...'" work. */
256
+ export default BlocklifyPreview;
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
- "name": "@blocklify/preview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@blocklify/preview",
3
+ "version": "0.1.0",
4
+ "description": "Blocklify frontend SDK: Content API client and live-preview React helpers for theme blocks.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./client": {
16
+ "types": "./dist/client.d.ts",
17
+ "import": "./dist/client.js"
18
+ },
19
+ "./preview": {
20
+ "types": "./dist/preview.d.ts",
21
+ "import": "./dist/preview.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/BodhiMooij/blocklify.git",
34
+ "directory": "packages/preview"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "prepublishOnly": "bun run build"
39
+ },
40
+ "peerDependencies": {
41
+ "react": ">=18",
42
+ "react-dom": ">=18"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "react": {
46
+ "optional": false
47
+ },
48
+ "react-dom": {
49
+ "optional": false
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "@types/react": "19.2.14",
54
+ "@types/react-dom": "19.2.3",
55
+ "typescript": "^5"
56
+ },
57
+ "keywords": [
58
+ "blocklify",
59
+ "cms",
60
+ "live-preview",
61
+ "headless"
62
+ ]
63
+ }