diff --git a/README.md b/README.md
index 9aabb98..bcb9954 100644
--- a/README.md
+++ b/README.md
@@ -76,13 +76,48 @@ const html = generateSSGHTML({
The `./ssg` export uses Node.js `fs`/`path` and must not be bundled into browser code.
+### Iframe Embed (no React required)
+
+For non-React sites (Hugo, Jekyll, plain HTML, etc.), use the iframe embed API. The host page only loads a tiny JS file — React runs inside the iframe.
+
+```html
+
+
+```
+
+Options:
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `example` | `string` | **Required.** Example folder name |
+| `defaultFile` | `string` | Initial file to display (default: `'src/App.tsx'`) |
+| `defaultTab` | `'preview' \| 'web' \| 'qrcode'` | Default preview tab |
+| `exampleBasePath` | `string` | Base path or full URL for example data |
+| `img` | `string` | Static preview image URL |
+| `defaultEntryFile` | `string` | Default entry file for web preview |
+| `highlight` | `string` | Line highlight spec, e.g. `'{1,3-5}'` |
+| `entry` | `string \| string[]` | Filter entry files in tree |
+
## Development
```bash
pnpm dev
```
-This starts the standalone example app at `localhost:3000`.
+This starts the standalone example app at `localhost:5969`.
### Lynx examples
diff --git a/example-iframe-embed/index.html b/example-iframe-embed/index.html
new file mode 100644
index 0000000..63d801d
--- /dev/null
+++ b/example-iframe-embed/index.html
@@ -0,0 +1,255 @@
+
+
+
+
+
+ Go Web — Iframe Embed Test
+
+
+
+ Iframe Embed API Test
+
+ This page demonstrates the @lynx-js/go-web iframe embed API.
+ It loads the Go component inside an iframe via a pure JS mount() call —
+ no React dependency needed on the host page.
+
+
+
+ How to test:
+ 1. Start dev server: cd example && pnpm dev (serves on localhost:5969)
+ 2. Open this file in a browser (or npx serve example-iframe-embed)
+ Example data is proxied from go.lynxjs.org via /proxy-lynx-examples.
+
+
+ Usage
+ <div id="demo" style="height: 500px"></div>
+<script type="module">
+ import { mount } from 'https://go.lynxjs.org/embed.js';
+ mount('#demo', {
+ example: 'hello-world',
+ defaultFile: 'src/App.tsx',
+ exampleBasePath: 'https://go.lynxjs.org/lynx-examples',
+ });
+</script>
+
+ Live Demo
+
+
+
+
+
+
+
+
diff --git a/example/embed.html b/example/embed.html
new file mode 100644
index 0000000..d9a31c5
--- /dev/null
+++ b/example/embed.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Go Web Embed
+
+
+
+
+
+
+
diff --git a/example/rsbuild.config.ts b/example/rsbuild.config.ts
index 3522ec5..95831bc 100644
--- a/example/rsbuild.config.ts
+++ b/example/rsbuild.config.ts
@@ -44,13 +44,28 @@ for (const name of exampleNames) {
export default defineConfig({
plugins: [pluginReact(), pluginSass()],
+ server: {
+ port: 5969,
+ proxy: {
+ // Proxy requests to production examples when local examples are not available.
+ // This avoids CORS issues when testing the embed with go.lynxjs.org data.
+ '/proxy-lynx-examples': {
+ target: 'https://go.lynxjs.org',
+ pathRewrite: { '^/proxy-lynx-examples': '/lynx-examples' },
+ changeOrigin: true,
+ },
+ },
+ },
+
html: {
- template: './index.html',
+ template: ({ entryName }) =>
+ entryName === 'embed' ? './embed.html' : './index.html',
},
source: {
entry: {
index: './src/main.tsx',
+ embed: './src/embed-entry.tsx',
},
define: {
// Inject the example list as a build-time constant
diff --git a/example/src/embed-entry.tsx b/example/src/embed-entry.tsx
new file mode 100644
index 0000000..26bdcb8
--- /dev/null
+++ b/example/src/embed-entry.tsx
@@ -0,0 +1,184 @@
+/**
+ * Embed entry point — renders inside the iframe created by src/embed.ts.
+ *
+ * Listens for postMessage from the parent to receive example configuration,
+ * then renders a Go component with GoConfigProvider.
+ */
+import React, { useEffect, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import '@douyinfe/semi-ui/dist/css/semi.min.css';
+import { GoConfigProvider, Go } from '../../src/index';
+import type { GoConfig } from '../../src/config';
+import type { ShikiTransformer, BundledLanguage } from 'shiki';
+import './styles.css';
+
+// ---------------------------------------------------------------------------
+// Shiki CodeBlock (reused from main.tsx)
+// ---------------------------------------------------------------------------
+
+let _codeHighlighterP: ReturnType | null = null;
+function getCodeHighlighter() {
+ if (!_codeHighlighterP) {
+ _codeHighlighterP = import('shiki').then((mod) =>
+ mod.createHighlighter({
+ themes: ['github-light', 'github-dark'],
+ langs: [],
+ }),
+ );
+ }
+ return _codeHighlighterP;
+}
+
+const StandaloneCodeBlock = ({
+ lang,
+ code,
+ onRendered,
+ shikiOptions,
+}: {
+ lang: string;
+ code: string;
+ onRendered?: () => void;
+ shikiOptions?: { transformers?: ShikiTransformer[] };
+}) => {
+ const [html, setHtml] = useState('');
+
+ useEffect(() => {
+ if (!code) return;
+ let cancelled = false;
+ getCodeHighlighter().then(async (highlighter) => {
+ if (cancelled) return;
+ const loaded = highlighter.getLoadedLanguages();
+ if (!loaded.includes(lang as BundledLanguage)) {
+ try {
+ await highlighter.loadLanguage(lang as BundledLanguage);
+ } catch {
+ // fall back to plaintext
+ }
+ }
+ const effective = highlighter
+ .getLoadedLanguages()
+ .includes(lang as BundledLanguage)
+ ? lang
+ : 'text';
+ const result = highlighter.codeToHtml(code, {
+ lang: effective,
+ themes: { light: 'github-light', dark: 'github-dark' },
+ defaultColor: false,
+ transformers: shikiOptions?.transformers ?? [],
+ });
+ if (!cancelled) setHtml(result);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [code, lang, shikiOptions?.transformers]);
+
+ useEffect(() => {
+ if (html && onRendered) requestAnimationFrame(() => onRendered());
+ }, [html, onRendered]);
+
+ if (!html) {
+ return (
+
+ {code}
+
+ );
+ }
+ return (
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Embed options type (mirrors src/embed.ts)
+// ---------------------------------------------------------------------------
+
+type EmbedOptions = {
+ example: string;
+ defaultFile?: string;
+ defaultTab?: 'preview' | 'web' | 'qrcode';
+ img?: string;
+ defaultEntryFile?: string;
+ highlight?: string;
+ entry?: string | string[];
+ seamless?: boolean;
+ exampleBasePath?: string;
+};
+
+// ---------------------------------------------------------------------------
+// EmbedApp
+// ---------------------------------------------------------------------------
+
+function EmbedApp() {
+ const [options, setOptions] = useState(null);
+
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ const data = event.data as { type?: string; options?: EmbedOptions };
+
+ if (data?.type === 'go-embed:init' && data.options) {
+ setOptions(data.options);
+ }
+
+ if (data?.type === 'go-embed:update' && data.options) {
+ setOptions((prev) => (prev ? { ...prev, ...data.options } : null));
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ // Signal to parent that we're ready to receive options
+ if (window.parent !== window) {
+ window.parent.postMessage({ type: 'go-embed:ready' }, '*');
+ }
+
+ return () => window.removeEventListener('message', handleMessage);
+ }, []);
+
+ if (!options) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ const goConfig: GoConfig = {
+ exampleBasePath: options.exampleBasePath || '/lynx-examples',
+ CodeBlock: StandaloneCodeBlock,
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Mount
+// ---------------------------------------------------------------------------
+
+const container = document.getElementById('embed-root')!;
+const root = createRoot(container);
+root.render();
diff --git a/package.json b/package.json
index 7f13e4f..ad90749 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"exports": {
".": "./src/index.ts",
"./adapters/rspress": "./src/adapters/rspress.tsx",
- "./ssg": "./src/ssg.tsx"
+ "./ssg": "./src/ssg.tsx",
+ "./embed": "./src/embed.ts"
},
"files": [
"src"
diff --git a/src/embed.ts b/src/embed.ts
new file mode 100644
index 0000000..0408947
--- /dev/null
+++ b/src/embed.ts
@@ -0,0 +1,147 @@
+/**
+ * @lynx-js/go-web Iframe Embed API
+ *
+ * Pure JS entry point — zero React dependency.
+ * Creates an iframe that loads the Go component, communicating via postMessage.
+ *
+ * Usage:
+ * ```html
+ *
+ *
+ * ```
+ */
+
+export type EmbedOptions = {
+ /** Example name (folder name under exampleBasePath) */
+ example: string;
+ /** Initial file to display */
+ defaultFile?: string;
+ /** Default preview tab */
+ defaultTab?: 'preview' | 'web' | 'qrcode';
+ /** Static preview image URL */
+ img?: string;
+ /** Default entry file for web preview */
+ defaultEntryFile?: string;
+ /** Code highlight spec, e.g. '{1,3-5}' */
+ highlight?: string;
+ /** Filter entry files in tree */
+ entry?: string | string[];
+ /** Hide the header bar for minimal embeds */
+ seamless?: boolean;
+ /**
+ * Base path (or full URL) for example assets.
+ * Defaults to '/lynx-examples'. Use a full URL for cross-origin data,
+ * e.g. 'https://go.lynxjs.org/lynx-examples'.
+ */
+ exampleBasePath?: string;
+};
+
+export type EmbedControl = {
+ iframe: HTMLIFrameElement;
+ /** Update the displayed example */
+ update: (options: Partial) => void;
+ /** Remove the embed and clean up listeners */
+ destroy: () => void;
+};
+
+type EmbedReadyMessage = {
+ type: 'go-embed:ready';
+};
+
+type EmbedInitMessage = {
+ type: 'go-embed:init';
+ options: EmbedOptions;
+};
+
+type EmbedUpdateMessage = {
+ type: 'go-embed:update';
+ options: Partial;
+};
+
+function isEmbedReadyMessage(data: unknown): data is EmbedReadyMessage {
+ return (
+ typeof data === 'object' &&
+ data !== null &&
+ (data as { type?: string }).type === 'go-embed:ready'
+ );
+}
+
+/**
+ * Resolve the embed.html URL relative to this script's location.
+ * Works because embed.js and embed.html are co-located in the build output.
+ */
+function getEmbedUrl(): string {
+ return new URL('embed.html', import.meta.url).href;
+}
+
+/**
+ * Mount a Go interactive example embed into a container element.
+ *
+ * @param container - CSS selector or DOM element
+ * @param options - Example configuration
+ * @returns Control object to update or destroy the embed
+ */
+export function mount(
+ container: string | HTMLElement,
+ options: EmbedOptions,
+): EmbedControl {
+ const el =
+ typeof container === 'string'
+ ? document.querySelector(container)
+ : container;
+
+ if (!el) {
+ throw new Error(`@lynx-js/go-web embed: container not found: ${container}`);
+ }
+
+ const embedUrl = new URL(getEmbedUrl());
+ if (options.seamless) {
+ embedUrl.searchParams.set('seamless', '1');
+ }
+
+ const iframe = document.createElement('iframe');
+ iframe.src = embedUrl.href;
+ iframe.style.cssText =
+ 'width: 100%; height: 100%; border: none; border-radius: 8px;';
+
+ let currentOptions = { ...options };
+
+ const handleMessage = (event: MessageEvent): void => {
+ if (event.source !== iframe.contentWindow) return;
+
+ if (isEmbedReadyMessage(event.data)) {
+ const initMessage: EmbedInitMessage = {
+ type: 'go-embed:init',
+ options: currentOptions,
+ };
+ iframe.contentWindow?.postMessage(initMessage, '*');
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ el.innerHTML = '';
+ el.appendChild(iframe);
+
+ return {
+ iframe,
+ update(newOptions: Partial): void {
+ currentOptions = { ...currentOptions, ...newOptions };
+ const updateMessage: EmbedUpdateMessage = {
+ type: 'go-embed:update',
+ options: newOptions,
+ };
+ iframe.contentWindow?.postMessage(updateMessage, '*');
+ },
+ destroy(): void {
+ window.removeEventListener('message', handleMessage);
+ el.innerHTML = '';
+ },
+ };
+}