diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index 8e98574..be42075 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -14,9 +14,9 @@ const highlighterOptions = { }; const shellHighlighter: HighlighterGeneric = - import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions)); + import.meta.hot?.data?.shellHighlighter ?? (await createHighlighter(highlighterOptions)); -if (import.meta.hot) { +if (import.meta.hot && import.meta.hot.data) { import.meta.hot.data.shellHighlighter = shellHighlighter; } diff --git a/app/components/editor/Editor.tsx b/app/components/editor/Editor.tsx index 39ef2a2..827cb1e 100644 --- a/app/components/editor/Editor.tsx +++ b/app/components/editor/Editor.tsx @@ -28,7 +28,6 @@ interface Props { editable?: boolean; debounceChange?: number; debounceScroll?: number; - autoFocusOnDocumentChange?: boolean; onChange?: OnChangeCallback; onReset?: () => void; onSave?: OnSaveCallback; @@ -39,7 +38,7 @@ interface Props { } export const EditorStudio = memo( - ({ documents, currentPage, currentSection, autoFocusOnDocumentChange, onChange, onSave, onLoad, onReady }: Props) => { + ({ documents, currentPage, currentSection, onChange, onSave, onLoad, onReady }: Props) => { const editorRef = useRef(null); const pendingSectionRef = useRef
(null); @@ -134,7 +133,7 @@ export const EditorStudio = memo( // 保存最新的页面属性,确保在节流期间如果有新的更新进来,会使用最新的数据 pendingSectionRef.current = currentSection; setEditorDocument(editor, currentSection); - }, [currentSection, autoFocusOnDocumentChange]); + }, [currentSection]); // 确保在组件卸载前应用最后一次更新 useEffect(() => { diff --git a/app/components/webbuilder/EditorPanel.tsx b/app/components/webbuilder/EditorPanel.tsx index fcf2795..403f6b5 100644 --- a/app/components/webbuilder/EditorPanel.tsx +++ b/app/components/webbuilder/EditorPanel.tsx @@ -6,7 +6,6 @@ import type { PageMap } from '~/lib/stores/pages'; import type { PageHistory, Section } from '~/types/actions'; import type { DocumentProperties } from '~/types/editor'; import { logger, renderLogger } from '~/utils/logger'; -import { isMobile } from '~/utils/mobile'; import { EditorStudio, type OnChangeCallback, @@ -84,7 +83,6 @@ export const EditorPanel = memo( settings={editorSettings} currentPage={currentPage} currentSection={currentSection} - autoFocusOnDocumentChange={!isMobile()} onChange={onEditorChange} onSave={onPageSave} onReset={onPageReset} diff --git a/app/lib/bridge/index.ts b/app/lib/bridge/index.ts index af44dd7..f22a4f1 100644 --- a/app/lib/bridge/index.ts +++ b/app/lib/bridge/index.ts @@ -4,11 +4,11 @@ interface BridgeContext { loaded: boolean; } -export const bridgeContext: BridgeContext = import.meta.hot?.data.editorBridgeContext ?? { +export const bridgeContext: BridgeContext = import.meta.hot?.data?.editorBridgeContext ?? { loaded: false, }; -if (import.meta.hot) { +if (import.meta.hot && import.meta.hot.data) { import.meta.hot.data.editorBridgeContext = bridgeContext; } @@ -171,7 +171,7 @@ export let editorBridge: Promise = new Promise(() => { if (!import.meta.env.SSR) { editorBridge = - import.meta.hot?.data.editorBridge ?? + import.meta.hot?.data?.editorBridge ?? Promise.resolve() .then(() => { return new EditorBridge(); @@ -181,7 +181,7 @@ if (!import.meta.env.SSR) { return editorBridge; }); - if (import.meta.hot) { + if (import.meta.hot && import.meta.hot.data) { import.meta.hot.data.editorBridge = editorBridge; } } diff --git a/app/lib/stores/chat.ts b/app/lib/stores/chat.ts index 28efd84..6266074 100644 --- a/app/lib/stores/chat.ts +++ b/app/lib/stores/chat.ts @@ -27,14 +27,14 @@ export class ChatStore { // 当前消息 id currentMessageId: WritableAtom = - import.meta.hot?.data.currentMessageId ?? atom(undefined); + import.meta.hot?.data?.currentMessageId ?? atom(undefined); currentDescription: WritableAtom = - import.meta.hot?.data.currentDescription ?? atom(undefined); + import.meta.hot?.data?.currentDescription ?? atom(undefined); - artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); + artifacts: Artifacts = import.meta.hot?.data?.artifacts ?? map({}); artifactIdList: string[] = []; actionAlert: WritableAtom = - import.meta.hot?.data.actionAlert ?? atom(undefined); + import.meta.hot?.data?.actionAlert ?? atom(undefined); // 添加对webBuilderStore和pagesStore的引用 readonly webBuilderStore: WebBuilderStore; @@ -44,7 +44,7 @@ export class ChatStore { this.webBuilderStore = webBuilderStore; this.pagesStore = pagesStore; - if (import.meta.hot) { + if (import.meta.hot && import.meta.hot.data) { import.meta.hot.data.artifacts = this.artifacts; import.meta.hot.data.actionAlert = this.actionAlert; import.meta.hot.data.currentDescription = this.currentDescription; diff --git a/app/lib/stores/editor.ts b/app/lib/stores/editor.ts index 7abd1cf..792f133 100644 --- a/app/lib/stores/editor.ts +++ b/app/lib/stores/editor.ts @@ -31,11 +31,11 @@ export const editorCommands = atom(null); export class EditorStore { private readonly pagesStore: PagesStore; - editorInstance: WritableAtom = import.meta.hot?.data.editorInstance ?? atom(null); + editorInstance: WritableAtom = import.meta.hot?.data?.editorInstance ?? atom(null); // 编辑器中当前选中的文档。 - selectedDocument: SelectedDocument = import.meta.hot?.data.selectedPage ?? atom(); + selectedDocument: SelectedDocument = import.meta.hot?.data?.selectedPage ?? atom(); // 编辑器文档数据,始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。 - editorDocuments: MapStore = import.meta.hot?.data.documents ?? map({}); + editorDocuments: MapStore = import.meta.hot?.data?.documents ?? map({}); // 当前编辑器文档,基于 editorDocuments 和 selectedDocument 计算而来。始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。 currentDocument = computed([this.editorDocuments, this.selectedDocument], (documents, selectedDocument) => { if (!selectedDocument) { @@ -44,15 +44,15 @@ export class EditorStore { return documents[selectedDocument]; }); // 当前编辑器未保存的页面 - unsavedDocuments: WritableAtom> = import.meta.hot?.data.unsavedDocuments ?? atom(new Set()); + unsavedDocuments: WritableAtom> = import.meta.hot?.data?.unsavedDocuments ?? atom(new Set()); // 编辑器文档最后保存时间 documentLastSaved: WritableAtom> = - import.meta.hot?.data.documentLastSaved ?? atom>({}); + import.meta.hot?.data?.documentLastSaved ?? atom>({}); constructor(pagesStore: PagesStore) { this.pagesStore = pagesStore; - if (import.meta.hot) { + if (import.meta.hot && import.meta.hot.data) { import.meta.hot.data.unsavedDocuments = this.unsavedDocuments; import.meta.hot.data.selectedDocument = this.selectedDocument; import.meta.hot.data.editorDocuments = this.editorDocuments; diff --git a/app/lib/stores/pages.ts b/app/lib/stores/pages.ts index f3daf71..f15bcfe 100644 --- a/app/lib/stores/pages.ts +++ b/app/lib/stores/pages.ts @@ -33,27 +33,27 @@ export class PagesStore { * @note 跟踪所有自上次用户消息以来被修改的文件及其原始内容,以便模型感知这些更改。 * 当用户发送另一条消息且所有更改都需要提交时,需要重置。 */ - private modifiedPages: Map = import.meta.hot?.data.modifiedPages ?? new Map(); + private modifiedPages: Map = import.meta.hot?.data?.modifiedPages ?? new Map(); /** * 跟踪已删除的页面,防止它们在重新加载时重新出现 */ - private deletedPages: Set = import.meta.hot?.data.deletedPages ?? new Set(); + private deletedPages: Set = import.meta.hot?.data?.deletedPages ?? new Set(); /** * 页面映射,与 AI 做交互,基于 artifacts 数据解析而来。 * 因此,此数据表示与数据库通信的底层数据,未保存的数据将不会在此处体现。 * 如果在编辑器中确定保存了数据,则需要实时同步进 #modifiedPages 中。 */ - pages: MapStore = import.meta.hot?.data.pages ?? map({}); + pages: MapStore = import.meta.hot?.data?.pages ?? map({}); /** * 页面历史记录,用于 diff 视图。 * 每次页面保存时,会保存上一次的页面内容。 */ - pageHistory: MapStore> = import.meta.hot?.data.pageHistory ?? map({}); + pageHistory: MapStore> = import.meta.hot?.data?.pageHistory ?? map({}); - activePage: ActivePage = import.meta.hot?.data.activePage ?? atom(); + activePage: ActivePage = import.meta.hot?.data?.activePage ?? atom(); currentPage = computed([this.pages, this.activePage], (pages, activePage) => { if (!activePage) { return undefined; @@ -64,11 +64,11 @@ export class PagesStore { /** * 基于 action 的 section 映射,作为与 AI 交互的底层数据,基于 actions 数据解析而来。 */ - sections: MapStore = import.meta.hot?.data.sections ?? map({}); + sections: MapStore = import.meta.hot?.data?.sections ?? map({}); /** * 当前活跃的 section。 */ - activeSection: ActiveSection = import.meta.hot?.data.activeSection ?? atom(); + activeSection: ActiveSection = import.meta.hot?.data?.activeSection ?? atom(); currentSection = computed([this.sections, this.activeSection], (sections, activeSection) => { if (!activeSection) { @@ -100,7 +100,7 @@ export class PagesStore { logger.error('Failed to load deleted paths from localStorage', error); } - if (import.meta.hot) { + if (import.meta.hot && import.meta.hot.data) { // Persist our state across hot reloads import.meta.hot.data.pages = this.pages; import.meta.hot.data.modifiedPages = this.modifiedPages; diff --git a/app/lib/stores/web-builder.ts b/app/lib/stores/web-builder.ts index 61dc046..d827806 100644 --- a/app/lib/stores/web-builder.ts +++ b/app/lib/stores/web-builder.ts @@ -37,9 +37,9 @@ export class WebBuilderStore { readonly editorStore: EditorStore; // 是否显示 webBuilder - showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); + showWorkbench: WritableAtom = import.meta.hot?.data?.showWorkbench ?? atom(false); // 当前 webBuilder 所在的视图 - currentView: WritableAtom = import.meta.hot?.data.currentView ?? atom('code'); + currentView: WritableAtom = import.meta.hot?.data?.currentView ?? atom('code'); constructor() { this.previewsStore = new PreviewsStore(); @@ -47,7 +47,7 @@ export class WebBuilderStore { this.chatStore = new ChatStore(this, this.pagesStore); this.editorStore = new EditorStore(this.pagesStore); - if (import.meta.hot) { + if (import.meta.hot && import.meta.hot.data) { import.meta.hot.data.showWorkbench = this.showWorkbench; import.meta.hot.data.currentView = this.currentView; } diff --git a/app/utils/html-parse.spec.ts b/app/utils/html-parse.spec.ts new file mode 100644 index 0000000..80bcbe0 --- /dev/null +++ b/app/utils/html-parse.spec.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { isScriptContent, isValidContent } from './html-parse'; + +describe('html-parse', () => { + describe('isScriptContent', () => { + it('应该识别 script 标签内容', () => { + expect(isScriptContent('')).toBe(true); + expect(isScriptContent(' ')).toBe(true); + }); + + it('应该不识别非 script 标签内容', () => { + expect(isScriptContent('
hello
')).toBe(false); + expect(isScriptContent('')).toBe(false); + expect(isScriptContent('hello world')).toBe(false); + }); + }); + + describe('isValidContent - 有效的 HTML 内容', () => { + it('应该接受根节点完整且有 id 的 HTML(内部元素可不完整)', () => { + expect(isValidContent('
h')).toBe(true); + expect(isValidContent('
hello')).toBe(true); + expect(isValidContent('
hello world
')).toBe(true); + expect(isValidContent('
hello world')).toBe(true); + expect(isValidContent('
hello world
')).toBe(true); + }); + + it('应该接受完整的 HTML 元素', () => { + expect(isValidContent('
')).toBe(true); + expect(isValidContent('
content
')).toBe(true); + expect(isValidContent('

Title

')).toBe(true); + }); + + it('应该接受带有多个属性的根元素', () => { + expect(isValidContent('
content
')).toBe(true); + expect(isValidContent('
content
')).toBe(true); + }); + + it('应该接受使用单引号的 id 属性', () => { + expect(isValidContent("
content
")).toBe(true); + expect(isValidContent("
content
")).toBe(true); + }); + }); + + describe('isValidContent - 无效的 HTML 内容', () => { + it('应该拒绝根节点不完整的 HTML', () => { + expect(isValidContent('
hello world
hello world { + expect(isValidContent('
content
')).toBe(false); + expect(isValidContent('
content
')).toBe(false); + }); + + it('应该拒绝 id 属性不完整的根元素', () => { + expect(isValidContent('
{ + expect(isValidContent('
contentcontentcontent { + expect(isValidContent('
hello world<')).toBe(false); + expect(isValidContent('
content<')).toBe(false); + expect(isValidContent('
test<')).toBe(false); + }); + + it('应该接受 < 作为普通字符后跟完整闭合标签的内容', () => { + expect(isValidContent('
hello world<
')).toBe(true); + expect(isValidContent('
5 < 10
')).toBe(true); + expect(isValidContent('
a
')).toBe(true); + }); + }); + + describe('isValidContent - 有效的 script 内容', () => { + it('应该接受完整的 script 标签', () => { + expect(isValidContent('')).toBe(true); + expect(isValidContent('')).toBe(true); + expect(isValidContent('')).toBe(true); + }); + + it('应该接受使用单引号的 script 标签', () => { + expect(isValidContent("")).toBe(true); + }); + + it('应该接受带有多行代码的 script 标签', () => { + const content = ``; + expect(isValidContent(content)).toBe(true); + }); + }); + + describe('isValidContent - 无效的 script 内容', () => { + it('应该拒绝没有 id 的 script 标签', () => { + expect(isValidContent('')).toBe(false); + }); + + it('应该拒绝没有闭合标签的 script', () => { + expect(isValidContent('')).toBe(false); + expect(isValidContent('')).toBe(false); + }); + + it('应该拒绝末尾有孤立 < 字符的 script 内容', () => { + expect(isValidContent('')).toBe(true); + expect(isValidContent('')).toBe(true); + }); + }); + + describe('isValidContent - 有效的 style 内容', () => { + it('应该接受完整的 style 标签', () => { + expect(isValidContent('')).toBe(true); + expect(isValidContent('')).toBe(true); + expect(isValidContent('')).toBe(true); + }); + + it('应该接受使用单引号的 style 标签', () => { + expect(isValidContent("")).toBe(true); + }); + + it('应该接受带有多行样式的 style 标签', () => { + const content = ``; + expect(isValidContent(content)).toBe(true); + }); + }); + + describe('isValidContent - 无效的 style 内容', () => { + it('应该拒绝没有 id 的 style 标签', () => { + expect(isValidContent('')).toBe(false); + }); + + it('应该拒绝没有闭合标签的 style', () => { + expect(isValidContent('')).toBe(false); + expect(isValidContent('')).toBe(false); + }); + + it('应该拒绝末尾有孤立 < 字符的 style 内容', () => { + expect(isValidContent('')).toBe(true); + }); + }); + + describe('isValidContent - 边界情况', () => { + it('应该拒绝空字符串', () => { + expect(isValidContent('')).toBe(false); + }); + + it('应该拒绝只有空格的字符串', () => { + expect(isValidContent(' ')).toBe(false); + }); + + it('应该拒绝 null 和 undefined', () => { + expect(isValidContent(null as any)).toBe(false); + expect(isValidContent(undefined as any)).toBe(false); + }); + + it('应该拒绝非字符串类型', () => { + expect(isValidContent(123 as any)).toBe(false); + expect(isValidContent({} as any)).toBe(false); + expect(isValidContent([] as any)).toBe(false); + }); + + it('应该拒绝纯文本内容(不是标签)', () => { + expect(isValidContent('hello world')).toBe(false); + }); + + it('应该接受带有前导空格的有效内容', () => { + expect(isValidContent('
content
')).toBe(true); + expect(isValidContent(' ')).toBe(true); + expect(isValidContent(' ')).toBe(true); + }); + }); +}); diff --git a/app/utils/html-parse.ts b/app/utils/html-parse.ts index caf0dca..87c6a12 100644 --- a/app/utils/html-parse.ts +++ b/app/utils/html-parse.ts @@ -6,6 +6,102 @@ export function isScriptContent(content: string): boolean { return content.trim().startsWith(') + const firstTagEndIndex = trimmedContent.indexOf('>'); + + if (firstTagEndIndex === -1) { + logger.warn('根标签不完整:未找到闭合的 >'); + return { valid: false }; + } + + // 提取第一个完整的标签(包括 >) + const firstTag = trimmedContent.substring(0, firstTagEndIndex + 1); + + // 提取标签名称(支持 `; + return content.includes(closingTag); +} + +/** + * 检查内容中是否存在明显不完整的标签 + * @param content 内容字符串 + * @returns 是否存在不完整的标签 + */ +function hasIncompleteTag(content: string): boolean { + // 检查内容末尾是否有不完整的闭合标签 + // 匹配 的闭合标签) + const incompleteClosingTagPattern = /<\/([a-zA-Z][a-zA-Z0-9]*)?$/; + + if (incompleteClosingTagPattern.test(content)) { + logger.warn('检测到不完整的闭合标签', { contentEnd: content.slice(-20) }); + return true; + } + + // 检查内容末尾是否有不完整的开始标签 + // 匹配以 < 开头但没有对应 > 的情况 + const incompleteOpeningTagPattern = /<[a-zA-Z][^>]*$/; + + if (incompleteOpeningTagPattern.test(content)) { + logger.warn('检测到不完整的开始标签', { contentEnd: content.slice(-20) }); + return true; + } + + // 检查内容末尾是否有孤立的 < + // 匹配末尾单独的 < 字符(不属于任何标签) + const isolatedLessThanPattern = /<$/; + + if (isolatedLessThanPattern.test(content)) { + logger.warn('检测到末尾孤立的 < 字符', { contentEnd: content.slice(-20) }); + return true; + } + + return false; +} + /** * 验证内容是否有效 * - 检查是否包含完整的 id 属性 @@ -18,8 +114,20 @@ export function isValidContent(content: string): boolean { return false; } - // 处理可能存在的不完整标签 - content = sanitizeHtmlContent(content); + // 检查是否存在明显不完整的标签 + if (hasIncompleteTag(content)) { + logger.warn('内容包含不完整的标签'); + return false; + } + + // 验证根节点标签完整性 + const rootValidation = validateRootTagCompleteness(content); + if (!rootValidation.valid) { + logger.warn('根节点标签验证失败'); + return false; + } + + const { rootId } = rootValidation; try { // 创建一个临时的 DOM 解析器 @@ -28,6 +136,12 @@ export function isValidContent(content: string): boolean { // 检查内容类型 if (content.trim().startsWith(''); + return false; + } + // JavaScript 内容验证 const scriptElements = doc.getElementsByTagName('script'); @@ -47,15 +161,12 @@ export function isValidContent(content: string): boolean { return false; } - // 检查 id 是否完整,即属性在原始内容中是否以 id="..." 或 id='...' 的形式完整出现 - if (content.indexOf(`id="${scriptElement.id}"`) === -1 && content.indexOf(`id='${scriptElement.id}'`) === -1) { - logger.warn('JS content contains incomplete id attribute', { contentLength: content.length }); - return false; - } - - // 检查 script 标签是否有完整的闭合标签 - if (!content.includes('')) { - logger.warn('JS content must have closing tag', { contentLength: content.length }); + // 验证提取的 id 与 DOMParser 解析的 id 一致 + if (scriptElement.id !== rootId) { + logger.warn('script 标签 id 不一致', { + extractedId: rootId, + parsedId: scriptElement.id, + }); return false; } @@ -63,6 +174,12 @@ export function isValidContent(content: string): boolean { } if (content.trim().startsWith(''); + return false; + } + // CSS 内容验证 const styleElements = doc.getElementsByTagName('style'); @@ -82,14 +199,12 @@ export function isValidContent(content: string): boolean { return false; } - if (content.indexOf(`id="${styleElement.id}"`) === -1 && content.indexOf(`id='${styleElement.id}'`) === -1) { - logger.warn('CSS content contains incomplete id attribute', { contentLength: content.length }); - return false; - } - - // 检查 style 标签是否有完整的闭合标签 - if (!content.includes('')) { - logger.warn('style content must have closing tag'); + // 验证提取的 id 与 DOMParser 解析的 id 一致 + if (styleElement.id !== rootId) { + logger.warn('style 标签 id 不一致', { + extractedId: rootId, + parsedId: styleElement.id, + }); return false; } @@ -115,8 +230,12 @@ export function isValidContent(content: string): boolean { return false; } - if (content.indexOf(`id="${rootElement.id}"`) === -1 && content.indexOf(`id='${rootElement.id}'`) === -1) { - logger.warn('HTML content contains incomplete id attribute'); + // 验证提取的 id 与 DOMParser 解析的 id 一致 + if (rootElement.id !== rootId) { + logger.warn('HTML 根元素 id 不一致', { + extractedId: rootId, + parsedId: rootElement.id, + }); return false; } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..53e12d4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: 'jsdom', + globals: true, + }, +});