Files
ONE-OS/axhub-make/vite-plugins/virtualHtml/previewHost.ts
王冕 a27e3b8e43 feat: sync full workspace including web modules, docs, and configurations to Gitea
Optimized the root .gitignore to exclude virtual environments, node modules,
and temp folders to ensure clean and lightweight version tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:12:25 +08:00

532 lines
14 KiB
TypeScript

import path from 'path';
import { encodeRoutePath } from './handlers/pathNormalizer';
import { buildDocApiPath } from '../utils/docUtils';
export const PREVIEW_HOST_MODULE_PREFIX = 'virtual:axhub-preview-host.js?';
export interface PreviewHostModuleOptions {
type: 'components' | 'prototypes' | 'themes';
name: string;
entryImportPath: string;
resourcePath: string;
resourceUrlPath: string;
editableHackCssHref: string | null;
initialHackCssEnabled: boolean;
versionId?: string;
}
export interface HackCssUpdatePayload {
resourcePath: string;
href: string;
removed: boolean;
timestamp: number;
}
export interface DocUpdatePayload {
docUrl: string;
filePath: string;
removed: boolean;
timestamp: number;
}
export function createPreviewHostModuleId(options: PreviewHostModuleOptions): string {
const params = new URLSearchParams({
resourceType: options.type,
name: options.name,
entry: options.entryImportPath,
resourcePath: options.resourcePath,
resourceUrlPath: options.resourceUrlPath,
initialHackCssEnabled: options.initialHackCssEnabled ? '1' : '0',
});
if (options.editableHackCssHref) {
params.set('editableHackCssHref', options.editableHackCssHref);
}
if (options.versionId) {
params.set('versionId', options.versionId);
}
return `${PREVIEW_HOST_MODULE_PREFIX}${params.toString()}`;
}
export function parsePreviewHostModuleId(id: string): PreviewHostModuleOptions | null {
if (!id.startsWith(PREVIEW_HOST_MODULE_PREFIX)) {
return null;
}
const params = new URLSearchParams(id.slice(PREVIEW_HOST_MODULE_PREFIX.length));
const type = params.get('resourceType');
const name = params.get('name');
const entryImportPath = params.get('entry');
const resourcePath = params.get('resourcePath');
const resourceUrlPath = params.get('resourceUrlPath');
if (
(type !== 'components' && type !== 'prototypes' && type !== 'themes')
|| !name
|| !entryImportPath
|| !resourcePath
|| !resourceUrlPath
) {
return null;
}
return {
type,
name,
entryImportPath,
resourcePath,
resourceUrlPath,
editableHackCssHref: params.get('editableHackCssHref'),
initialHackCssEnabled: params.get('initialHackCssEnabled') === '1',
versionId: params.get('versionId') || undefined,
};
}
export function replacePreviewLoaderScript(html: string, previewHostModuleCode: string): string {
const indentedCode = previewHostModuleCode
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
const loaderScript = ` <script type="module">\n${indentedCode}\n </script>`;
const legacyLoaderPattern = /<script type="module">\s*\/\/ 等待 bootstrap 加载完成[\s\S]*?<\/script>\s*<\/body>/;
if (legacyLoaderPattern.test(html)) {
return html.replace(legacyLoaderPattern, `${loaderScript}\n\n</body>`);
}
return html.replace('</body>', `${loaderScript}\n</body>`);
}
export function resolveEditableHackCssHref(
type: 'components' | 'prototypes' | 'themes',
name: string,
versionId?: string,
): string | null {
if (versionId) {
return null;
}
if (type !== 'components' && type !== 'prototypes') {
return null;
}
return encodeRoutePath(`/${type}/${name}/hack.css`);
}
export function createHackCssUpdatePayload(filePath: string, changeType: 'add' | 'change' | 'unlink'): HackCssUpdatePayload | null {
const normalizedPath = filePath.replace(/\\/g, '/');
const marker = '/src/';
const markerIndex = normalizedPath.lastIndexOf(marker);
if (markerIndex === -1) {
return null;
}
const relativePath = normalizedPath.slice(markerIndex + marker.length);
const pathParts = relativePath.split('/');
if (pathParts.length < 3) {
return null;
}
const [type, ...rest] = pathParts;
if ((type !== 'components' && type !== 'prototypes') || rest[rest.length - 1] !== 'hack.css') {
return null;
}
const name = rest.slice(0, -1).join('/');
if (!name) {
return null;
}
return {
resourcePath: `${type}/${name}`,
href: encodeRoutePath(`/${type}/${name}/hack.css`),
removed: changeType === 'unlink',
timestamp: Date.now(),
};
}
export function createDocUpdatePayload(filePath: string, changeType: 'add' | 'change' | 'unlink'): DocUpdatePayload | null {
const normalizedPath = filePath.replace(/\\/g, '/');
const marker = '/src/';
const markerIndex = normalizedPath.lastIndexOf(marker);
if (markerIndex === -1) {
return null;
}
const relativePath = normalizedPath.slice(markerIndex + marker.length);
const pathParts = relativePath.split('/');
if (pathParts.length < 2) {
return null;
}
if (pathParts[0] === 'docs' && relativePath.endsWith('.md')) {
const docName = relativePath.slice('docs/'.length);
if (!docName) {
return null;
}
return {
docUrl: buildDocApiPath(docName),
filePath: normalizedPath,
removed: changeType === 'unlink',
timestamp: Date.now(),
};
}
const [type, ...rest] = pathParts;
if ((type !== 'components' && type !== 'prototypes' && type !== 'themes') || rest.length < 2) {
return null;
}
const fileName = rest[rest.length - 1];
if (fileName !== 'spec.md' && fileName !== 'prd.md') {
return null;
}
const name = rest.slice(0, -1).join('/');
if (!name) {
return null;
}
return {
docUrl: encodeRoutePath(`/${type}/${name}/${fileName}`),
filePath: normalizedPath,
removed: changeType === 'unlink',
timestamp: Date.now(),
};
}
function toJsString(value: string | null): string {
return value === null ? 'null' : JSON.stringify(value);
}
export function createPreviewHostModuleCode(options: PreviewHostModuleOptions): string {
const editorModeBootstrapSnippet = `
function resolveInitialEditorMode() {
const params = new URLSearchParams(window.location.search);
const editor = params.get('editor');
if (editor === 'inspecta' || editor === 'textEdit' || editor === 'webEditorV2') {
return editor;
}
if (params.get('inspecta') === 'true') {
return 'inspecta';
}
return 'none';
}
function maybeEnableInitialEditorMode(hostState) {
if (hostState.editorModeHandled) {
return;
}
const bootstrap = window.DevTemplateBootstrap;
if (!bootstrap || !bootstrap.editors || typeof bootstrap.editors.enable !== 'function') {
window.setTimeout(() => maybeEnableInitialEditorMode(hostState), 30);
return;
}
hostState.editorModeHandled = true;
const initialMode = resolveInitialEditorMode();
if (initialMode === 'none') {
return;
}
Promise.resolve(bootstrap.editors.enable(initialMode)).catch((error) => {
hostState.editorModeHandled = false;
console.error('[Axhub Preview Host] Failed to enable initial editor mode:', error);
});
}
`;
return `import React from 'react';
import * as ReactDOMLegacy from 'react-dom';
import { createRoot, hydrateRoot } from 'react-dom/client';
import PreviewComponent from ${JSON.stringify(options.entryImportPath)};
const RESOURCE_PATH = ${JSON.stringify(options.resourcePath)};
const RESOURCE_URL_PATH = ${JSON.stringify(options.resourceUrlPath)};
const ENTRY_IMPORT_PATH = ${JSON.stringify(options.entryImportPath)};
const AXHUB_RUNTIME_KEY = '__AXHUB_PREVIEW_RUNTIME__';
const AXHUB_HOST_KEY = ${JSON.stringify(options.resourcePath)};
let CurrentComponent = PreviewComponent;
let currentHackCssHref = ${toJsString(options.editableHackCssHref)};
const LegacyReactDOM = {
...ReactDOMLegacy,
createRoot,
hydrateRoot,
};
function hashString(input) {
let hash = 2166136261;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash).toString(36);
}
function getStablePageId() {
if (typeof window.__PAGE_FULL_PATH__ !== 'undefined') {
const fullPath = window.__PAGE_FULL_PATH__;
const hash = hashString(fullPath);
const pathSegment = String(fullPath)
.split('/')
.slice(-2)
.join('-')
.replace(/\\.(tsx|jsx|ts|js)$/u, '')
.replace(/[^a-zA-Z0-9-]/g, '-')
.slice(0, 32);
return \`\${pathSegment}-\${hash}\`;
}
if (typeof window.__PAGE_ID__ !== 'undefined') {
return window.__PAGE_ID__;
}
const pathKey = \`\${window.location.pathname}\${window.location.search}\`;
const hash = hashString(pathKey);
const pathSegment = pathKey
.replace(/[^a-zA-Z0-9]/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
return \`\${pathSegment}-\${hash}\`;
}
function getRuntimeState() {
if (!window[AXHUB_RUNTIME_KEY]) {
window[AXHUB_RUNTIME_KEY] = { hosts: new Map() };
}
const runtime = window[AXHUB_RUNTIME_KEY];
let hostState = runtime.hosts.get(AXHUB_HOST_KEY);
if (!hostState) {
hostState = {
root: null,
rootElement: null,
editorModeHandled: false,
latestProps: null,
};
runtime.hosts.set(AXHUB_HOST_KEY, hostState);
}
return hostState;
}
function getRootElement() {
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('[Axhub Preview Host] Missing #root container');
}
return rootElement;
}
function ensureRoot(hostState) {
const rootElement = getRootElement();
if (!hostState.root || hostState.rootElement !== rootElement) {
hostState.root = createRoot(rootElement);
hostState.rootElement = rootElement;
}
return rootElement;
}
function getDefaultProps(container) {
return {
container,
config: {},
data: {},
events: {},
};
}
function getRenderProps(container, nextProps) {
if (nextProps && typeof nextProps === 'object') {
return {
...getDefaultProps(container),
...nextProps,
container: nextProps.container || container,
};
}
return getDefaultProps(container);
}
function findHackCssLink() {
return document.head.querySelector(\`link[data-axhub-hack-css="\${RESOURCE_PATH}"]\`);
}
function updateHackCssLink(payload) {
const hostState = getRuntimeState();
if (payload && typeof payload.href === 'string' && payload.href) {
currentHackCssHref = payload.href;
}
const nextHref = currentHackCssHref;
const existingLink = findHackCssLink();
if (payload && payload.removed) {
if (existingLink) {
existingLink.remove();
}
hostState.hackCssLink = null;
return;
}
if (!nextHref) {
return;
}
const link = existingLink || document.createElement('link');
link.rel = 'stylesheet';
link.setAttribute('data-axhub-hack-css', RESOURCE_PATH);
link.href = \`\${nextHref}?t=\${payload && payload.timestamp ? payload.timestamp : Date.now()}\`;
if (!link.parentNode) {
document.head.appendChild(link);
}
hostState.hackCssLink = link;
}
function applyStablePageId(container) {
const stablePageId = getStablePageId();
if (stablePageId) {
container.setAttribute('data-page-id', stablePageId);
}
}
function syncLegacyBootstrap(container) {
const hostState = getRuntimeState();
const bootstrap = window.DevTemplateBootstrap;
window.React = React;
window.ReactDOM = LegacyReactDOM;
window.AxhubDevComponent = CurrentComponent;
if (!bootstrap || typeof bootstrap !== 'object') {
return;
}
bootstrap.React = React;
bootstrap.ReactDOM = LegacyReactDOM;
bootstrap.renderComponent = (Component, props) => {
if (Component) {
CurrentComponent = Component;
}
hostState.latestProps = props && typeof props === 'object' ? props : null;
renderCurrentComponent(hostState.latestProps);
};
if (typeof bootstrap.inspectaMode === 'undefined') {
bootstrap.inspectaMode = false;
}
}
${editorModeBootstrapSnippet}
function afterRender(container) {
syncLegacyBootstrap(container);
applyStablePageId(container);
maybeEnableInitialEditorMode(getRuntimeState());
}
function renderCurrentComponent(nextProps) {
const hostState = getRuntimeState();
const rootElement = ensureRoot(hostState);
if (typeof nextProps !== 'undefined') {
hostState.latestProps = nextProps && typeof nextProps === 'object' ? nextProps : null;
}
const renderProps = getRenderProps(rootElement, hostState.latestProps);
hostState.root.render(React.createElement(CurrentComponent, renderProps));
afterRender(rootElement);
}
async function resolveUpdatedPreviewModule(nextModule) {
if (nextModule && nextModule.default) {
return nextModule;
}
try {
const refreshedModule = await import(/* @vite-ignore */ \`\${ENTRY_IMPORT_PATH}?t=\${Date.now()}\`);
if (refreshedModule && refreshedModule.default) {
return refreshedModule;
}
} catch (error) {
console.warn('[Axhub Preview Host] Fallback re-import failed:', error);
}
return null;
}
renderCurrentComponent();
if (${options.initialHackCssEnabled ? 'true' : 'false'} && currentHackCssHref) {
updateHackCssLink({ href: currentHackCssHref, removed: false, timestamp: Date.now() });
}
if (import.meta.hot) {
const hostState = getRuntimeState();
if (hostState.hackCssHandler && typeof import.meta.hot.off === 'function') {
import.meta.hot.off('axhub:hack-css-update', hostState.hackCssHandler);
}
hostState.hackCssHandler = (payload) => {
if (!payload || payload.resourcePath !== RESOURCE_PATH) {
return;
}
updateHackCssLink(payload);
};
import.meta.hot.on('axhub:hack-css-update', hostState.hackCssHandler);
import.meta.hot.accept(ENTRY_IMPORT_PATH, async (module) => {
const resolvedModule = await resolveUpdatedPreviewModule(module);
if (!resolvedModule || !resolvedModule.default) {
console.warn('[Axhub Preview Host] Skipped preview rerender because the updated module has no default export.');
return;
}
CurrentComponent = resolvedModule.default;
renderCurrentComponent();
});
import.meta.hot.accept();
import.meta.hot.dispose(() => {
if (hostState.hackCssHandler && typeof import.meta.hot.off === 'function') {
import.meta.hot.off('axhub:hack-css-update', hostState.hackCssHandler);
}
});
}
export function __axhubRenderPreview() {
renderCurrentComponent();
}
`;
}
export function createPreviewHostOptions(input: {
type: 'components' | 'prototypes' | 'themes';
name: string;
entryImportPath: string;
versionId?: string;
initialHackCssEnabled?: boolean;
}): PreviewHostModuleOptions {
const resourcePath = `${input.type}/${input.name}`;
return {
type: input.type,
name: input.name,
entryImportPath: input.entryImportPath,
resourcePath,
resourceUrlPath: encodeRoutePath(`/${resourcePath}`),
editableHackCssHref: resolveEditableHackCssHref(input.type, input.name, input.versionId),
initialHackCssEnabled: Boolean(input.initialHackCssEnabled),
versionId: input.versionId,
};
}
export function toPosixPath(input: string): string {
return input.split(path.sep).join('/');
}