@blocklify/preview 0.0.0-stage → 0.2.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,74 @@
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://www.blocklify.app): Content API client, live preview, and starter blocks for a new website.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @blocklify/preview
9
+ # or: npm install @blocklify/preview
10
+ ```
11
+
12
+ Peer dependencies: `react` and `react-dom` (≥18).
13
+
14
+ ## New Next.js site
15
+
16
+ ```bash
17
+ bunx create-next-app@latest my-blocklify-site
18
+ cd my-blocklify-site
19
+ bun add @blocklify/preview
20
+ cp -R node_modules/@blocklify/preview/starter/nextjs/app ./app
21
+ cp node_modules/@blocklify/preview/starter/nextjs/.env.example ./.env.local
22
+ ```
23
+
24
+ Fill in `.env.local`:
25
+
26
+ ```bash
27
+ BLOCKLIFY_API_URL=https://localhost:3000
28
+ NEXT_PUBLIC_BLOCKLIFY_API_URL=https://localhost:3000
29
+ BLOCKLIFY_ACCESS_TOKEN=blocklify_…
30
+ ```
31
+
32
+ Run the site on port 3001 and register `http://localhost:3001/preview` under Space → Settings → Live preview URLs.
33
+
34
+ `app/page.tsx` renders the published `home` entry. `app/preview/page.tsx` is the editor iframe.
35
+
36
+ ## New React (Vite) site
37
+
38
+ ```bash
39
+ bunx create-vite@latest my-blocklify-site --template react-ts
40
+ cd my-blocklify-site
41
+ bun add @blocklify/preview react-router-dom
42
+ cp node_modules/@blocklify/preview/starter/react/src/App.tsx ./src/App.tsx
43
+ cp node_modules/@blocklify/preview/starter/react/src/main.tsx ./src/main.tsx
44
+ mkdir -p src/pages
45
+ cp node_modules/@blocklify/preview/starter/react/src/pages/PreviewPage.tsx ./src/pages/PreviewPage.tsx
46
+ cp node_modules/@blocklify/preview/starter/react/.env.example ./.env
47
+ ```
48
+
49
+ Register `http://localhost:5173/preview` as the live preview URL.
50
+
51
+ ## What the starter renders
52
+
53
+ Matches a Blocklify starter space:
54
+
55
+ | Block type | Fields |
56
+ | ---------- | ---------------------------------------------------------- |
57
+ | `hero` | `headline`, `subheadline`, `image`, `cta_text`, `cta_link` |
58
+ | `text` | `content` (HTML) |
59
+ | `image` | `image`, `caption`, `alt` |
60
+ | `cta` | `headline`, `subtext`, `button_text`, `button_link` |
61
+
62
+ `hero_block`, `text_block`, and `image_block` use the same components.
63
+
64
+ Asset fields can be an id, a URL, or `{ url }`. Pass the Blocklify origin so ids load from `/api/assets/{id}/file`:
65
+
66
+ ```ts
67
+ import { BlocklifyEntryView, BlocklifyPreview, createStarterBlockMap } from "@blocklify/preview";
68
+
69
+ const blockMap = createStarterBlockMap({
70
+ assetBaseUrl: process.env.BLOCKLIFY_API_URL,
71
+ });
72
+ ```
73
+
74
+ Use `blockMap` with `BlocklifyPreview` on `/preview`, and `BlocklifyEntryView` for the public page.
@@ -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,14 @@
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";
14
+ export { BlocklifyEntryView, blocklifyAssetUrl, createStarterBlockMap, starterBlockMap, type StarterBlockMapOptions, } from "./starter.js";
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
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";
14
+ export { BlocklifyEntryView, blocklifyAssetUrl, createStarterBlockMap, starterBlockMap, } from "./starter.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;
@@ -0,0 +1,22 @@
1
+ import type { BlocklifyBlock, BlockMap } from "./preview.js";
2
+ export type StarterBlockMapOptions = {
3
+ /**
4
+ * Blocklify origin, no trailing slash.
5
+ * Used when an asset field is still an id (`/api/assets/{id}/file` is public).
6
+ */
7
+ assetBaseUrl?: string;
8
+ };
9
+ /** URL for an asset field: expanded `{ url }`, an absolute/path string, or a bare asset id. */
10
+ export declare function blocklifyAssetUrl(value: unknown, assetBaseUrl?: string): string | null;
11
+ /**
12
+ * Theme components for a starter Blocklify space: hero, text, image, and cta.
13
+ * Also registers the `*_block` aliases.
14
+ */
15
+ export declare function createStarterBlockMap(options?: StarterBlockMapOptions): BlockMap;
16
+ /** Starter map without an asset origin. Expanded `{ url }` fields still render. */
17
+ export declare const starterBlockMap: BlockMap;
18
+ /** Renders an entry body with a block map. Use on the public site, outside the preview iframe. */
19
+ export declare function BlocklifyEntryView({ blocks, blockMap, }: {
20
+ blocks?: BlocklifyBlock[] | null;
21
+ blockMap?: BlockMap;
22
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,139 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ function textOf(block, key) {
3
+ const value = block[key];
4
+ return typeof value === "string" ? value : "";
5
+ }
6
+ function absolutize(url, assetBaseUrl) {
7
+ if (/^https?:\/\//i.test(url) || url.startsWith("data:"))
8
+ return url;
9
+ if (url.startsWith("/") && assetBaseUrl) {
10
+ return `${assetBaseUrl.replace(/\/+$/, "")}${url}`;
11
+ }
12
+ return url;
13
+ }
14
+ /** URL for an asset field: expanded `{ url }`, an absolute/path string, or a bare asset id. */
15
+ export function blocklifyAssetUrl(value, assetBaseUrl) {
16
+ if (value == null || value === "")
17
+ return null;
18
+ if (typeof value === "object") {
19
+ const record = value;
20
+ if (typeof record.url === "string" && record.url !== "") {
21
+ return absolutize(record.url, assetBaseUrl);
22
+ }
23
+ if (typeof record.id === "string" && record.id !== "") {
24
+ return blocklifyAssetUrl(record.id, assetBaseUrl);
25
+ }
26
+ return null;
27
+ }
28
+ if (typeof value !== "string")
29
+ return null;
30
+ if (/^https?:\/\//i.test(value) || value.startsWith("/") || value.startsWith("data:")) {
31
+ return absolutize(value, assetBaseUrl);
32
+ }
33
+ if (!assetBaseUrl)
34
+ return null;
35
+ return `${assetBaseUrl.replace(/\/+$/, "")}/api/assets/${encodeURIComponent(value)}/file`;
36
+ }
37
+ const sectionStyle = {
38
+ padding: "4rem 1.5rem",
39
+ maxWidth: "42rem",
40
+ margin: "0 auto",
41
+ };
42
+ function HeroBlock({ block, BlocklifyEditable = {}, assetBaseUrl, }) {
43
+ const headline = textOf(block, "headline");
44
+ const subheadline = textOf(block, "subheadline");
45
+ const ctaText = textOf(block, "cta_text");
46
+ const ctaLink = textOf(block, "cta_link") || "#";
47
+ const imageSrc = blocklifyAssetUrl(block.image, assetBaseUrl);
48
+ return (_jsxs("section", { style: { ...sectionStyle, textAlign: "center" }, ...BlocklifyEditable, children: [imageSrc ? (
49
+ // Themes install this package outside the Blocklify app, so they cannot use its Image component.
50
+ _jsx("img", { src: imageSrc, alt: textOf(block, "alt") || headline, style: { width: "100%", borderRadius: 12, marginBottom: "1.5rem" } })) : null, headline ? (_jsx("h1", { style: { fontSize: "2.5rem", lineHeight: 1.15, margin: 0 }, children: headline })) : null, subheadline ? (_jsx("p", { style: { marginTop: "1rem", fontSize: "1.125rem", opacity: 0.75 }, children: subheadline })) : null, ctaText ? (_jsx("a", { href: ctaLink, style: {
51
+ display: "inline-block",
52
+ marginTop: "1.5rem",
53
+ padding: "0.75rem 1.5rem",
54
+ borderRadius: 999,
55
+ background: "#111",
56
+ color: "#fff",
57
+ textDecoration: "none",
58
+ fontSize: "0.875rem",
59
+ }, children: ctaText })) : null] }));
60
+ }
61
+ function TextBlock({ block, BlocklifyEditable = {} }) {
62
+ const html = textOf(block, "content") || textOf(block, "text") || textOf(block, "html");
63
+ return (_jsx("section", { style: sectionStyle, ...BlocklifyEditable, children: html ? (_jsx("div", { dangerouslySetInnerHTML: { __html: html } })) : (_jsx("p", { style: { fontStyle: "italic", opacity: 0.6 }, children: "Empty text block" })) }));
64
+ }
65
+ function ImageBlock({ block, BlocklifyEditable = {}, assetBaseUrl, }) {
66
+ const src = blocklifyAssetUrl(block.image, assetBaseUrl);
67
+ const caption = textOf(block, "caption");
68
+ const alt = textOf(block, "alt") || caption;
69
+ return (_jsxs("figure", { style: { ...sectionStyle, paddingTop: "1.5rem", paddingBottom: "1.5rem" }, ...BlocklifyEditable, children: [src ? (_jsx("img", { src: src, alt: alt, style: { width: "100%", borderRadius: 12 } })) : (_jsx("div", { style: {
70
+ padding: "3rem 1rem",
71
+ textAlign: "center",
72
+ border: "1px dashed #cbd5e1",
73
+ borderRadius: 12,
74
+ opacity: 0.7,
75
+ }, children: "No image selected" })), caption ? (_jsx("figcaption", { style: {
76
+ marginTop: "0.5rem",
77
+ textAlign: "center",
78
+ fontSize: "0.875rem",
79
+ opacity: 0.7,
80
+ }, children: caption })) : null] }));
81
+ }
82
+ function CtaBlock({ block, BlocklifyEditable = {} }) {
83
+ const headline = textOf(block, "headline");
84
+ const subtext = textOf(block, "subtext");
85
+ const buttonText = textOf(block, "button_text");
86
+ const buttonLink = textOf(block, "button_link") || "#";
87
+ return (_jsxs("section", { style: { ...sectionStyle, textAlign: "center" }, ...BlocklifyEditable, children: [headline ? _jsx("h2", { style: { fontSize: "1.75rem", margin: 0 }, children: headline }) : null, subtext ? _jsx("p", { style: { marginTop: "0.75rem", opacity: 0.75 }, children: subtext }) : null, buttonText ? (_jsx("a", { href: buttonLink, style: {
88
+ display: "inline-block",
89
+ marginTop: "1.5rem",
90
+ padding: "0.75rem 1.5rem",
91
+ borderRadius: 999,
92
+ background: "#65a30d",
93
+ color: "#fff",
94
+ textDecoration: "none",
95
+ }, children: buttonText })) : null] }));
96
+ }
97
+ function withAssetBase(Component, assetBaseUrl) {
98
+ function StarterBlock(props) {
99
+ return _jsx(Component, { ...props, assetBaseUrl: assetBaseUrl });
100
+ }
101
+ StarterBlock.displayName = Component.displayName ?? Component.name;
102
+ return StarterBlock;
103
+ }
104
+ /**
105
+ * Theme components for a starter Blocklify space: hero, text, image, and cta.
106
+ * Also registers the `*_block` aliases.
107
+ */
108
+ export function createStarterBlockMap(options) {
109
+ const assetBaseUrl = options?.assetBaseUrl;
110
+ const hero = withAssetBase(HeroBlock, assetBaseUrl);
111
+ const text = TextBlock;
112
+ const image = withAssetBase(ImageBlock, assetBaseUrl);
113
+ const cta = CtaBlock;
114
+ return {
115
+ hero,
116
+ hero_block: hero,
117
+ text,
118
+ text_block: text,
119
+ image,
120
+ image_block: image,
121
+ cta,
122
+ };
123
+ }
124
+ /** Starter map without an asset origin. Expanded `{ url }` fields still render. */
125
+ export const starterBlockMap = createStarterBlockMap();
126
+ function resolveBlock(blockMap, type) {
127
+ return blockMap[type] ?? (type.endsWith("_block") ? blockMap[type.slice(0, -6)] : undefined);
128
+ }
129
+ /** Renders an entry body with a block map. Use on the public site, outside the preview iframe. */
130
+ export function BlocklifyEntryView({ blocks, blockMap = starterBlockMap, }) {
131
+ if (!blocks?.length)
132
+ return null;
133
+ return (_jsx(_Fragment, { children: blocks.map((block, index) => {
134
+ const Component = resolveBlock(blockMap, block.type);
135
+ if (!Component)
136
+ return null;
137
+ return (_jsx(Component, { block: block, blockMap: blockMap }, block._uid ?? `${block.type}-${index}`));
138
+ }) }));
139
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
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.2.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
+ "./starter": {
24
+ "types": "./dist/starter.d.ts",
25
+ "import": "./dist/starter.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "starter",
31
+ "README.md"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/BodhiMooij/blocklify.git",
39
+ "directory": "packages/preview"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "prepublishOnly": "bun run build"
44
+ },
45
+ "peerDependencies": {
46
+ "react": ">=18",
47
+ "react-dom": ">=18"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "react": {
51
+ "optional": false
52
+ },
53
+ "react-dom": {
54
+ "optional": false
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@types/react": "19.2.14",
59
+ "@types/react-dom": "19.2.3",
60
+ "typescript": "^5"
61
+ },
62
+ "keywords": [
63
+ "blocklify",
64
+ "cms",
65
+ "live-preview",
66
+ "headless"
67
+ ]
68
+ }
@@ -0,0 +1,7 @@
1
+ # Blocklify CMS origin (no trailing slash)
2
+ BLOCKLIFY_API_URL=https://localhost:3000
3
+ # Same origin, exposed to the preview page so asset ids can load in the browser
4
+ NEXT_PUBLIC_BLOCKLIFY_API_URL=https://localhost:3000
5
+
6
+ # Read token from Admin → Access tokens
7
+ BLOCKLIFY_ACCESS_TOKEN=blocklify_your_token_here
@@ -0,0 +1,12 @@
1
+ # Next.js starter files
2
+
3
+ Copy these into a new App Router app after `bun add @blocklify/preview`.
4
+
5
+ ```bash
6
+ cp -R node_modules/@blocklify/preview/starter/nextjs/app ./app
7
+ cp node_modules/@blocklify/preview/starter/nextjs/.env.example ./.env.local
8
+ ```
9
+
10
+ Then set `BLOCKLIFY_API_URL`, `NEXT_PUBLIC_BLOCKLIFY_API_URL`, and `BLOCKLIFY_ACCESS_TOKEN` in `.env.local`.
11
+
12
+ Register `http://localhost:3001/preview` under Space → Settings → Live preview URLs, and run this app on port 3001.
@@ -0,0 +1,25 @@
1
+ import {
2
+ BlocklifyEntryView,
3
+ createBlocklifyClient,
4
+ createStarterBlockMap,
5
+ } from "@blocklify/preview";
6
+
7
+ const blockMap = createStarterBlockMap({
8
+ assetBaseUrl: process.env.BLOCKLIFY_API_URL,
9
+ });
10
+
11
+ const client = createBlocklifyClient({
12
+ baseUrl: process.env.BLOCKLIFY_API_URL!,
13
+ accessToken: process.env.BLOCKLIFY_ACCESS_TOKEN!,
14
+ });
15
+
16
+ export default async function HomePage() {
17
+ const entry = await client.getEntry("home");
18
+ const body = entry?.content.body ?? [];
19
+
20
+ return (
21
+ <main>
22
+ <BlocklifyEntryView blocks={body} blockMap={blockMap} />
23
+ </main>
24
+ );
25
+ }
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ import { BlocklifyPreview, createStarterBlockMap } from "@blocklify/preview";
4
+
5
+ const blockMap = createStarterBlockMap({
6
+ assetBaseUrl: process.env.NEXT_PUBLIC_BLOCKLIFY_API_URL,
7
+ });
8
+
9
+ export default function PreviewPage() {
10
+ return (
11
+ <BlocklifyPreview
12
+ blockMap={blockMap}
13
+ blocksFieldName="body"
14
+ emptyMessage="Waiting for content from the Blocklify editor…"
15
+ />
16
+ );
17
+ }
@@ -0,0 +1,2 @@
1
+ VITE_BLOCKLIFY_API_URL=https://localhost:3000
2
+ VITE_BLOCKLIFY_ACCESS_TOKEN=blocklify_your_token_here
@@ -0,0 +1,13 @@
1
+ # React (Vite) starter files
2
+
3
+ Copy these into a Vite React app after `bun add @blocklify/preview react-router-dom`.
4
+
5
+ ```bash
6
+ cp node_modules/@blocklify/preview/starter/react/src/App.tsx ./src/App.tsx
7
+ cp node_modules/@blocklify/preview/starter/react/src/main.tsx ./src/main.tsx
8
+ mkdir -p src/pages
9
+ cp node_modules/@blocklify/preview/starter/react/src/pages/PreviewPage.tsx ./src/pages/PreviewPage.tsx
10
+ cp node_modules/@blocklify/preview/starter/react/.env.example ./.env
11
+ ```
12
+
13
+ Register `http://localhost:5173/preview` under Space → Settings → Live preview URLs.
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ BlocklifyEntryView,
4
+ createBlocklifyClient,
5
+ createStarterBlockMap,
6
+ } from "@blocklify/preview";
7
+ import type { BlocklifyBlock } from "@blocklify/preview";
8
+
9
+ const blockMap = createStarterBlockMap({
10
+ assetBaseUrl: import.meta.env.VITE_BLOCKLIFY_API_URL,
11
+ });
12
+
13
+ const client = createBlocklifyClient({
14
+ baseUrl: import.meta.env.VITE_BLOCKLIFY_API_URL,
15
+ accessToken: import.meta.env.VITE_BLOCKLIFY_ACCESS_TOKEN,
16
+ });
17
+
18
+ export default function App() {
19
+ const [blocks, setBlocks] = useState<BlocklifyBlock[]>([]);
20
+
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ client.getEntry("home").then((entry) => {
24
+ if (!cancelled) setBlocks(entry?.content.body ?? []);
25
+ });
26
+ return () => {
27
+ cancelled = true;
28
+ };
29
+ }, []);
30
+
31
+ return (
32
+ <main>
33
+ <BlocklifyEntryView blocks={blocks} blockMap={blockMap} />
34
+ </main>
35
+ );
36
+ }
@@ -0,0 +1,16 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { BrowserRouter, Route, Routes } from "react-router-dom";
4
+ import App from "./App";
5
+ import { PreviewPage } from "./pages/PreviewPage";
6
+
7
+ createRoot(document.getElementById("root")!).render(
8
+ <StrictMode>
9
+ <BrowserRouter>
10
+ <Routes>
11
+ <Route path="/" element={<App />} />
12
+ <Route path="/preview" element={<PreviewPage />} />
13
+ </Routes>
14
+ </BrowserRouter>
15
+ </StrictMode>
16
+ );
@@ -0,0 +1,15 @@
1
+ import { BlocklifyPreview, createStarterBlockMap } from "@blocklify/preview";
2
+
3
+ const blockMap = createStarterBlockMap({
4
+ assetBaseUrl: import.meta.env.VITE_BLOCKLIFY_API_URL,
5
+ });
6
+
7
+ export function PreviewPage() {
8
+ return (
9
+ <BlocklifyPreview
10
+ blockMap={blockMap}
11
+ blocksFieldName="body"
12
+ emptyMessage="Waiting for content from the Blocklify editor…"
13
+ />
14
+ );
15
+ }