@avocadostudio-ai/preview-adapter 0.1.0 → 0.2.1

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.
@@ -7,6 +7,14 @@
7
7
  * specific transport (postMessage vs direct).
8
8
  */
9
9
  export declare function markdownToHtml(md: string): string;
10
+ /**
11
+ * Carry the editor-mode parameters from the current URL onto `href`.
12
+ *
13
+ * A parameter the link sets itself always wins — the link is the more specific
14
+ * intent. Everything else about `href` is preserved, so this is safe to apply
15
+ * to any same-origin destination.
16
+ */
17
+ export declare function withPreviewParams(href: string, currentSearch: string): string;
10
18
  export declare function findBlockNode(blockId: string): HTMLElement | null;
11
19
  export declare function findEditableNode(parent: HTMLElement, editablePath: string): HTMLElement | null;
12
20
  /**
@@ -6,58 +6,133 @@
6
6
  * communicate back to the editor through injectable callbacks, decoupled from any
7
7
  * specific transport (postMessage vs direct).
8
8
  */
9
- import { isImagePath } from "@avocadostudio-ai/shared";
9
+ import { isImagePath, parseInline, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel } from "@avocadostudio-ai/shared";
10
10
  // ---------------------------------------------------------------------------
11
- // Markdown → HTML helper (mirrors _shared.tsx renderRichTextContent)
11
+ // Markdown → HTML for the live-draft overlay
12
12
  // ---------------------------------------------------------------------------
13
- function inlineToHtml(text) {
14
- return text
15
- .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
16
- .replace(/\*(.+?)\*/g, "<em>$1</em>")
17
- .replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
13
+ //
14
+ // The grammar comes from @avocadostudio-ai/shared, the same parser the block
15
+ // renderers use. Two things are specific to this surface and stay here:
16
+ //
17
+ // 1. we escape `& < >` ourselves — React does that for the renderers, but
18
+ // this output is written straight into `innerHTML`. The escaping happens
19
+ // per token, on the way out. Doing it up front on the whole string (as
20
+ // this did originally) turns `>` into `&gt;` before the parser sees it,
21
+ // so a blockquote can never match;
22
+ // 2. a single-line value returns bare inline HTML with no `<p>` wrapper,
23
+ // because it is written into an element that already exists.
24
+ //
25
+ // Note this only ever runs on *plain-text* fields: `applyLiveDraftFields`
26
+ // hands anything structurally rich back to React (see `isRichEditableNode`).
27
+ function escapeHtml(text) {
28
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29
+ }
30
+ /** Attribute values additionally need the quote that delimits them. */
31
+ function escapeAttribute(value) {
32
+ return escapeHtml(value).replace(/"/g, "&quot;");
33
+ }
34
+ function inlineToHtml(tokens) {
35
+ return tokens
36
+ .map((token) => {
37
+ switch (token.type) {
38
+ case "strong":
39
+ return `<strong>${escapeHtml(token.text)}</strong>`;
40
+ case "em":
41
+ return `<em>${escapeHtml(token.text)}</em>`;
42
+ case "strike":
43
+ return `<s>${escapeHtml(token.text)}</s>`;
44
+ case "code":
45
+ return `<code>${escapeHtml(token.text)}</code>`;
46
+ case "link":
47
+ return `<a href="${escapeAttribute(token.href)}">${escapeHtml(token.text)}</a>`;
48
+ case "break":
49
+ return "<br>";
50
+ default:
51
+ return escapeHtml(token.text);
52
+ }
53
+ })
54
+ .join("");
18
55
  }
19
56
  export function markdownToHtml(md) {
20
- const escaped = md
21
- .replace(/&/g, "&amp;")
22
- .replace(/</g, "&lt;")
23
- .replace(/>/g, "&gt;");
24
- const normalized = escaped
25
- .replace(/\r\n?/g, "\n")
26
- .replace(/([.!?])([A-Z])/g, "$1 $2")
27
- .replace(/\n{3,}/g, "\n\n")
28
- .trim();
57
+ const normalized = normalizeRichTextBody(md);
29
58
  if (!normalized.includes("\n")) {
30
- return inlineToHtml(normalized);
59
+ return inlineToHtml(parseInline(normalized));
31
60
  }
32
- const blocks = normalized.split(/\n\s*\n+/).filter(Boolean);
33
- return blocks
34
- .map((block) => {
35
- const lines = block.split(/\n+/).map((l) => l.trim()).filter(Boolean);
36
- if (lines.length === 0)
37
- return "";
38
- const hMatch = /^(#{1,6})\s+(.+)$/.exec(lines[0]);
39
- if (hMatch) {
40
- let html = `<h3>${inlineToHtml(hMatch[2].trim())}</h3>`;
41
- const rest = lines.slice(1).join(" ").trim();
42
- if (rest)
43
- html += `<p>${inlineToHtml(rest)}</p>`;
44
- return html;
45
- }
46
- const ulItems = lines.map((l) => /^\s*[-*+•]\s+(.+)$/.exec(l)?.[1]?.trim() ?? null);
47
- if (ulItems.every((i) => i !== null)) {
48
- return `<ul>${ulItems.map((i) => `<li>${inlineToHtml(i)}</li>`).join("")}</ul>`;
49
- }
50
- const olItems = lines.map((l) => /^\s*\d+[.)]\s+(.+)$/.exec(l)?.[1]?.trim() ?? null);
51
- if (olItems.every((i) => i !== null)) {
52
- return `<ol>${olItems.map((i) => `<li>${inlineToHtml(i)}</li>`).join("")}</ol>`;
53
- }
54
- return `<p>${inlineToHtml(block)}</p>`;
61
+ return parseRichTextBlocks(normalized).map(blockToHtml).join("");
62
+ }
63
+ function itemsToHtml(items) {
64
+ return items
65
+ .map((item) => {
66
+ const nested = item.children
67
+ ? item.children.type === "ordered-list"
68
+ ? `<ol>${itemsToHtml(item.children.items)}</ol>`
69
+ : `<ul>${itemsToHtml(item.children.items)}</ul>`
70
+ : "";
71
+ return `<li>${inlineToHtml(item.inline)}${nested}</li>`;
55
72
  })
56
73
  .join("");
57
74
  }
75
+ function blockToHtml(block) {
76
+ switch (block.type) {
77
+ case "heading": {
78
+ const level = resolveRichTextHeadingLevel(block.level);
79
+ const heading = `<h${level}>${inlineToHtml(block.inline)}</h${level}>`;
80
+ return block.trailing ? `${heading}<p>${inlineToHtml(block.trailing)}</p>` : heading;
81
+ }
82
+ case "unordered-list":
83
+ return `<ul>${itemsToHtml(block.items)}</ul>`;
84
+ case "ordered-list":
85
+ return `<ol>${itemsToHtml(block.items)}</ol>`;
86
+ case "blockquote":
87
+ return `<blockquote>${block.children.map(blockToHtml).join("")}</blockquote>`;
88
+ case "code": {
89
+ const cls = block.language ? ` class="language-${escapeAttribute(block.language)}"` : "";
90
+ return `<pre><code${cls}>${escapeHtml(block.code)}</code></pre>`;
91
+ }
92
+ case "rule":
93
+ return "<hr>";
94
+ default:
95
+ return `<p>${inlineToHtml(block.inline)}</p>`;
96
+ }
97
+ }
58
98
  // ---------------------------------------------------------------------------
59
99
  // Pure DOM queries
60
100
  // ---------------------------------------------------------------------------
101
+ /**
102
+ * The query parameters that put the site into editor mode.
103
+ *
104
+ * `__editor=1` is the one the site's middleware rewrites onto its preview
105
+ * route; the rest identify the session and authorise reading unpublished
106
+ * content. They ride on the URL the editor frames, and a navigation *inside*
107
+ * the preview has to carry them forward or the very next document is the
108
+ * ordinary public page — published content, no overlay, and a hard 404 for any
109
+ * page that has never been published.
110
+ */
111
+ const PREVIEW_PARAM_KEYS = ["__editor", "session", "siteId", "editorOrigin", "secret"];
112
+ /**
113
+ * Carry the editor-mode parameters from the current URL onto `href`.
114
+ *
115
+ * A parameter the link sets itself always wins — the link is the more specific
116
+ * intent. Everything else about `href` is preserved, so this is safe to apply
117
+ * to any same-origin destination.
118
+ */
119
+ export function withPreviewParams(href, currentSearch) {
120
+ try {
121
+ const base = typeof window !== "undefined" ? window.location.href : "http://localhost/";
122
+ const url = new URL(href, base);
123
+ const current = new URLSearchParams(currentSearch);
124
+ for (const key of PREVIEW_PARAM_KEYS) {
125
+ const value = current.get(key);
126
+ if (value !== null && !url.searchParams.has(key))
127
+ url.searchParams.set(key, value);
128
+ }
129
+ return url.pathname + url.search + url.hash;
130
+ }
131
+ catch {
132
+ // Malformed href — better to navigate somewhere than to throw mid-click.
133
+ return href;
134
+ }
135
+ }
61
136
  export function findBlockNode(blockId) {
62
137
  if (!blockId)
63
138
  return null;
@@ -1271,7 +1346,9 @@ export function createBridgeFunctions(state, callbacks, config) {
1271
1346
  if (url.origin === window.location.origin) {
1272
1347
  event.preventDefault();
1273
1348
  event.stopPropagation();
1274
- navigate(url.pathname + url.search + url.hash);
1349
+ // Without this the click leaves editor mode: the site's own links
1350
+ // carry no `__editor=1`, so the next document is the public page.
1351
+ navigate(withPreviewParams(url.pathname + url.search + url.hash, window.location.search));
1275
1352
  return;
1276
1353
  }
1277
1354
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { PreviewBridge } from "./preview-bridge.tsx";
2
2
  export { PreviewBridgeCore } from "./preview-bridge-core.tsx";
3
3
  export type { PreviewBridgeConfig, PreviewBridgeCoreProps } from "./preview-bridge-core.tsx";
4
4
  export { getPreviewWrapperProps } from "./selectable.ts";
5
- export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.ts";
5
+ export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, withPreviewParams, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.ts";
6
6
  export type { BridgeCallbacks, BridgeState, BridgeFunctions } from "./bridge-functions.ts";
7
7
  export { LivePreviewProvider, useLivePreviewBlocks, useLivePreviewBridgeApi, } from "./live-preview-store.tsx";
8
8
  export type { LivePreviewPage, LivePreviewBridgeApi } from "./live-preview-store.tsx";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { PreviewBridge } from "./preview-bridge.js";
2
2
  export { PreviewBridgeCore } from "./preview-bridge-core.js";
3
3
  export { getPreviewWrapperProps } from "./selectable.js";
4
- export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.js";
4
+ export { createBridgeFunctions, createBridgeState, findBlockNode, findEditableNode, parseListItemPath, supportsInlineEditablePath, readNodeText, placeCaretAtEnd, orderedBlockNodes, blockOrderIndex, computeMoveAfter, computeInsertBefore, groupListItemNodes, commonItemRoot, markdownToHtml, withPreviewParams, setNestedLabelsVisibility, clearChildFocus, clearListItemSelection, removeOverlayControls, clearAllHighlights, showSkeleton, removeSkeletons, ensureBlockBadges, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.js";
5
5
  export { LivePreviewProvider, useLivePreviewBlocks, useLivePreviewBridgeApi, } from "./live-preview-store.js";
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useEffect, useRef } from "react";
4
- import { createBridgeFunctions, createBridgeState, findBlockNode, parseListItemPath, ensureBlockBadges, setNestedLabelsVisibility, showSkeleton, removeSkeletons, clearChildFocus, clearAllHighlights, clearListItemSelection, applyAiFieldLoading, cleanupOverlayElements, } from "./bridge-functions.js";
4
+ import { createBridgeFunctions, createBridgeState, findBlockNode, parseListItemPath, ensureBlockBadges, setNestedLabelsVisibility, showSkeleton, removeSkeletons, clearChildFocus, clearAllHighlights, clearListItemSelection, applyAiFieldLoading, cleanupOverlayElements, withPreviewParams, } from "./bridge-functions.js";
5
5
  export function PreviewBridgeCore(props) {
6
6
  // When running standalone (no editor origin) or not embedded in an iframe, render nothing.
7
7
  if (!props.editorOrigin || typeof window !== "undefined" && window.parent === window)
@@ -154,7 +154,7 @@ function PreviewBridgeCoreInner({ slug, editorOrigin, navigate, refresh, pathnam
154
154
  const navigateTo = typeof msg.payload.navigateTo === "string" ? msg.payload.navigateTo.trim() : "";
155
155
  if (navigateTo) {
156
156
  const href = navigateTo.startsWith("/") ? navigateTo : `/${navigateTo}`;
157
- navigate(`${href}${window.location.search}`);
157
+ navigate(withPreviewParams(href, window.location.search));
158
158
  }
159
159
  else {
160
160
  bridge.smoothRefresh();
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/preview-adapter",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
8
8
  "./package.json": "./package.json",
9
9
  ".": {
10
+ "types": "./dist/index.d.ts",
10
11
  "import": "./dist/index.js",
11
- "types": "./dist/index.d.ts"
12
+ "default": "./dist/index.js"
12
13
  },
13
14
  "./core": {
15
+ "types": "./dist/preview-bridge-core.d.ts",
14
16
  "import": "./dist/preview-bridge-core.js",
15
- "types": "./dist/preview-bridge-core.d.ts"
17
+ "default": "./dist/preview-bridge-core.js"
16
18
  },
17
19
  "./styles.css": "./src/styles.css"
18
20
  },
@@ -30,7 +32,7 @@
30
32
  "src/styles.css"
31
33
  ],
32
34
  "dependencies": {
33
- "@avocadostudio-ai/shared": "0.1.0"
35
+ "@avocadostudio-ai/shared": "0.2.1"
34
36
  },
35
37
  "peerDependencies": {
36
38
  "next": ">=15.0.0",
@@ -43,6 +45,7 @@
43
45
  },
44
46
  "devDependencies": {
45
47
  "@types/react": "^19.0.10",
48
+ "tsx": "^4.21.0",
46
49
  "typescript": "^5.7.3"
47
50
  },
48
51
  "description": "Preview bridge and editor overlay for Avocado Studio live preview",
@@ -54,6 +57,6 @@
54
57
  "scripts": {
55
58
  "build": "tsc -p tsconfig.build.json",
56
59
  "typecheck": "tsc --noEmit",
57
- "test": "NODE_ENV=test tsx --test src/**/*.test.ts"
60
+ "test": "NODE_ENV=test node ../../scripts/run-tests.mjs"
58
61
  }
59
62
  }