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 = ''; + }, + }; +}