Skip to content

Commit 44abe14

Browse files
committed
feat: improve embed rendering and playground coverage
1 parent fabe9e5 commit 44abe14

35 files changed

Lines changed: 2634 additions & 450 deletions

README.md

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77

88
Embeddable memo cards for Memos, delivered as a website and npm packages.
99

10+
## Features
11+
- Rich memo cards with themes and density presets
12+
- Core HTML renderer for SSR and static-site workflows
13+
- React component wrapper
14+
- Web Component wrapper
15+
- Iframe embed route for no-build integrations
16+
- Lightweight markdown support for headings, lists, task lists, quotes, links, and fenced code blocks
17+
- Attachment previews for images and grouped reaction badges
18+
- Optional auto-resizing iframe snippets via `postMessage`
19+
1020
## Workspace Layout
1121
- `apps/site`: TanStack Start website (docs, playground, iframe embeds)
1222
- `packages/memos-embed`: core API + SSR HTML helpers
@@ -19,6 +29,10 @@ pnpm install
1929
pnpm dev
2030
```
2131

32+
Open:
33+
- site: `http://localhost:3000`
34+
- playground: `http://localhost:3000/playground`
35+
2236
## Build
2337
```bash
2438
pnpm -r build
@@ -39,27 +53,57 @@ const memo = await fetchMemo({
3953
memoId: '1',
4054
})
4155

42-
const html = renderMemoHtmlSnippet(memo, { includeStyles: true })
56+
const html = renderMemoHtmlSnippet(memo, {
57+
includeStyles: true,
58+
theme: 'paper',
59+
density: 'comfortable',
60+
showAttachments: true,
61+
showReactions: true,
62+
linkTarget: '_blank',
63+
})
4364
```
4465

4566
### React
4667
```tsx
4768
import { MemoEmbed } from '@memos-embed/react'
4869

49-
<MemoEmbed baseUrl="https://demo.usememos.com/api/v1" memoId="1" />
70+
<MemoEmbed
71+
baseUrl="https://demo.usememos.com/api/v1"
72+
memoId="1"
73+
theme="glass"
74+
density="compact"
75+
showAttachments
76+
showReactions
77+
/>
5078
```
5179

5280
### Web Component
5381
```html
5482
<script type="module" src="https://unpkg.com/@memos-embed/wc@latest/dist/register.js"></script>
55-
<memos-embed base-url="https://demo.usememos.com/api/v1" memo-id="1"></memos-embed>
83+
<memos-embed
84+
base-url="https://demo.usememos.com/api/v1"
85+
memo-id="1"
86+
theme="midnight"
87+
show-tags="true"
88+
show-attachments="true"
89+
show-reactions="true"
90+
></memos-embed>
5691
```
5792

5893
### Iframe
59-
```html
60-
<iframe
61-
src="https://your-site.com/embed/1?baseUrl=https%3A%2F%2Fdemo.usememos.com%2Fapi%2Fv1"
62-
style="width: 100%; height: 240px; border: none;"
63-
title="memos-embed"
64-
></iframe>
94+
```ts
95+
import { renderIframeHtml } from 'memos-embed'
96+
97+
const iframe = renderIframeHtml({
98+
embedBaseUrl: 'https://your-site.com',
99+
baseUrl: 'https://demo.usememos.com/api/v1',
100+
memoId: '1',
101+
height: 240,
102+
autoResize: true,
103+
})
65104
```
105+
106+
## Development Notes
107+
- The site uses source aliases so local changes in `packages/*` show up immediately in `apps/site`
108+
- The playground keeps its configuration in the URL for easy sharing
109+
- Package builds are powered by `tsup`; site builds use Vite + TanStack Start
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
bindEmbedAutoResize,
4+
createEmbedResizeMessage,
5+
isEmbedMeasureRequest,
6+
measureEmbedHeight,
7+
} from "@/lib/embed-resize";
8+
import { MEMOS_EMBED_MEASURE_MESSAGE_TYPE } from "memos-embed";
9+
10+
const createDomRect = (height: number): DOMRect =>
11+
({
12+
x: 0,
13+
y: 0,
14+
width: 0,
15+
height,
16+
top: 0,
17+
right: 0,
18+
bottom: height,
19+
left: 0,
20+
toJSON: () => ({}),
21+
} as DOMRect);
22+
23+
describe("embed resize helpers", () => {
24+
it("measures the tallest available embed height", () => {
25+
const container = document.createElement("div");
26+
vi.spyOn(container, "getBoundingClientRect").mockReturnValue(createDomRect(180));
27+
Object.defineProperty(document.documentElement, "scrollHeight", {
28+
value: 240,
29+
configurable: true,
30+
});
31+
Object.defineProperty(document.body, "scrollHeight", {
32+
value: 200,
33+
configurable: true,
34+
});
35+
36+
expect(measureEmbedHeight(container, document)).toBe(240);
37+
});
38+
39+
it("recognizes valid measure requests and creates resize payloads", () => {
40+
expect(
41+
isEmbedMeasureRequest(
42+
{ type: MEMOS_EMBED_MEASURE_MESSAGE_TYPE, frameId: "frame-1" },
43+
"frame-1",
44+
),
45+
).toBe(true);
46+
expect(
47+
isEmbedMeasureRequest(
48+
{ type: MEMOS_EMBED_MEASURE_MESSAGE_TYPE, frameId: "frame-2" },
49+
"frame-1",
50+
),
51+
).toBe(false);
52+
expect(createEmbedResizeMessage("frame-1", 320)).toEqual({
53+
type: "memos-embed:resize",
54+
frameId: "frame-1",
55+
height: 320,
56+
});
57+
});
58+
59+
it("binds resize observers and responds to measurement requests", () => {
60+
const parentWindow = { postMessage: vi.fn() };
61+
const container = document.createElement("div");
62+
vi.spyOn(container, "getBoundingClientRect").mockReturnValue(createDomRect(190));
63+
Object.defineProperty(document.documentElement, "scrollHeight", {
64+
value: 210,
65+
configurable: true,
66+
});
67+
Object.defineProperty(document.body, "scrollHeight", {
68+
value: 160,
69+
configurable: true,
70+
});
71+
72+
const observe = vi.fn();
73+
const disconnect = vi.fn();
74+
let observerCallback: (() => void) | undefined;
75+
class ResizeObserverMock {
76+
constructor(callback: () => void) {
77+
observerCallback = callback;
78+
}
79+
observe = observe;
80+
disconnect = disconnect;
81+
}
82+
83+
const rafSpy = vi
84+
.spyOn(window, "requestAnimationFrame")
85+
.mockImplementation((callback: FrameRequestCallback) => {
86+
callback(0);
87+
return 1;
88+
});
89+
const cancelSpy = vi
90+
.spyOn(window, "cancelAnimationFrame")
91+
.mockImplementation(() => {});
92+
93+
const cleanup = bindEmbedAutoResize({
94+
frameId: "frame-1",
95+
container,
96+
currentWindow: window,
97+
currentDocument: document,
98+
parentWindow: parentWindow as never,
99+
ResizeObserverCtor: ResizeObserverMock as never,
100+
});
101+
102+
expect(observe).toHaveBeenCalledWith(container);
103+
expect(parentWindow.postMessage).toHaveBeenCalledWith(
104+
{ type: "memos-embed:resize", frameId: "frame-1", height: 210 },
105+
"*",
106+
);
107+
108+
const callCount = parentWindow.postMessage.mock.calls.length;
109+
window.dispatchEvent(
110+
new MessageEvent("message", {
111+
data: { type: MEMOS_EMBED_MEASURE_MESSAGE_TYPE, frameId: "other" },
112+
}),
113+
);
114+
expect(parentWindow.postMessage).toHaveBeenCalledTimes(callCount);
115+
116+
window.dispatchEvent(
117+
new MessageEvent("message", {
118+
data: { type: MEMOS_EMBED_MEASURE_MESSAGE_TYPE, frameId: "frame-1" },
119+
}),
120+
);
121+
expect(parentWindow.postMessage).toHaveBeenCalledTimes(callCount + 1);
122+
123+
observerCallback?.();
124+
expect(parentWindow.postMessage).toHaveBeenCalledTimes(callCount + 2);
125+
126+
cleanup();
127+
expect(disconnect).toHaveBeenCalled();
128+
expect(cancelSpy).toHaveBeenCalledWith(1);
129+
130+
rafSpy.mockRestore();
131+
cancelSpy.mockRestore();
132+
});
133+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { EmbedPreview } from "@/routes/embed/$memoId";
4+
5+
const createDomRect = (height: number): DOMRect =>
6+
({
7+
x: 0,
8+
y: 0,
9+
width: 0,
10+
height,
11+
top: 0,
12+
right: 0,
13+
bottom: height,
14+
left: 0,
15+
toJSON: () => ({}),
16+
} as DOMRect);
17+
18+
let originalParentDescriptor = Object.getOwnPropertyDescriptor(window, "parent");
19+
let originalResizeObserverDescriptor = Object.getOwnPropertyDescriptor(
20+
globalThis,
21+
"ResizeObserver",
22+
);
23+
24+
afterEach(() => {
25+
vi.restoreAllMocks();
26+
if (originalParentDescriptor) {
27+
Object.defineProperty(window, "parent", originalParentDescriptor);
28+
}
29+
if (originalResizeObserverDescriptor) {
30+
Object.defineProperty(
31+
globalThis,
32+
"ResizeObserver",
33+
originalResizeObserverDescriptor,
34+
);
35+
}
36+
});
37+
38+
describe("EmbedPreview", () => {
39+
it("renders html and posts resize updates when embedded in an iframe", async () => {
40+
const parentWindow = { postMessage: vi.fn() };
41+
Object.defineProperty(window, "parent", {
42+
value: parentWindow,
43+
configurable: true,
44+
});
45+
46+
class ResizeObserverMock {
47+
observe = vi.fn();
48+
disconnect = vi.fn();
49+
constructor(_callback: ResizeObserverCallback) {}
50+
}
51+
Object.defineProperty(globalThis, "ResizeObserver", {
52+
value: ResizeObserverMock,
53+
configurable: true,
54+
});
55+
56+
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
57+
(callback: FrameRequestCallback) => {
58+
callback(0);
59+
return 1;
60+
},
61+
);
62+
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
63+
Object.defineProperty(document.documentElement, "scrollHeight", {
64+
value: 220,
65+
configurable: true,
66+
});
67+
Object.defineProperty(document.body, "scrollHeight", {
68+
value: 200,
69+
configurable: true,
70+
});
71+
72+
const { container, unmount } = render(
73+
<EmbedPreview html="<article>Rendered memo</article>" frameId="frame-1" />,
74+
);
75+
const root = container.firstElementChild as HTMLDivElement;
76+
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(createDomRect(180));
77+
78+
await waitFor(() => {
79+
expect(container.textContent).toContain("Rendered memo");
80+
expect(parentWindow.postMessage).toHaveBeenCalledWith(
81+
{ type: "memos-embed:resize", frameId: "frame-1", height: 220 },
82+
"*",
83+
);
84+
});
85+
86+
unmount();
87+
});
88+
});

0 commit comments

Comments
 (0)