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>
532 lines
14 KiB
TypeScript
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('/');
|
|
}
|