fix: Resolve the issue of possible abnormal text generated during page creation.

This commit is contained in:
LIlGG
2025-10-09 17:48:18 +08:00
parent a93a679c71
commit c5d47c680c
11 changed files with 404 additions and 54 deletions

View File

@@ -14,9 +14,9 @@ const highlighterOptions = {
}; };
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> = const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
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; import.meta.hot.data.shellHighlighter = shellHighlighter;
} }

View File

@@ -28,7 +28,6 @@ interface Props {
editable?: boolean; editable?: boolean;
debounceChange?: number; debounceChange?: number;
debounceScroll?: number; debounceScroll?: number;
autoFocusOnDocumentChange?: boolean;
onChange?: OnChangeCallback; onChange?: OnChangeCallback;
onReset?: () => void; onReset?: () => void;
onSave?: OnSaveCallback; onSave?: OnSaveCallback;
@@ -39,7 +38,7 @@ interface Props {
} }
export const EditorStudio = memo( export const EditorStudio = memo(
({ documents, currentPage, currentSection, autoFocusOnDocumentChange, onChange, onSave, onLoad, onReady }: Props) => { ({ documents, currentPage, currentSection, onChange, onSave, onLoad, onReady }: Props) => {
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
const pendingSectionRef = useRef<Section | null>(null); const pendingSectionRef = useRef<Section | null>(null);
@@ -134,7 +133,7 @@ export const EditorStudio = memo(
// 保存最新的页面属性,确保在节流期间如果有新的更新进来,会使用最新的数据 // 保存最新的页面属性,确保在节流期间如果有新的更新进来,会使用最新的数据
pendingSectionRef.current = currentSection; pendingSectionRef.current = currentSection;
setEditorDocument(editor, currentSection); setEditorDocument(editor, currentSection);
}, [currentSection, autoFocusOnDocumentChange]); }, [currentSection]);
// 确保在组件卸载前应用最后一次更新 // 确保在组件卸载前应用最后一次更新
useEffect(() => { useEffect(() => {

View File

@@ -6,7 +6,6 @@ import type { PageMap } from '~/lib/stores/pages';
import type { PageHistory, Section } from '~/types/actions'; import type { PageHistory, Section } from '~/types/actions';
import type { DocumentProperties } from '~/types/editor'; import type { DocumentProperties } from '~/types/editor';
import { logger, renderLogger } from '~/utils/logger'; import { logger, renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile';
import { import {
EditorStudio, EditorStudio,
type OnChangeCallback, type OnChangeCallback,
@@ -84,7 +83,6 @@ export const EditorPanel = memo(
settings={editorSettings} settings={editorSettings}
currentPage={currentPage} currentPage={currentPage}
currentSection={currentSection} currentSection={currentSection}
autoFocusOnDocumentChange={!isMobile()}
onChange={onEditorChange} onChange={onEditorChange}
onSave={onPageSave} onSave={onPageSave}
onReset={onPageReset} onReset={onPageReset}

View File

@@ -4,11 +4,11 @@ interface BridgeContext {
loaded: boolean; loaded: boolean;
} }
export const bridgeContext: BridgeContext = import.meta.hot?.data.editorBridgeContext ?? { export const bridgeContext: BridgeContext = import.meta.hot?.data?.editorBridgeContext ?? {
loaded: false, loaded: false,
}; };
if (import.meta.hot) { if (import.meta.hot && import.meta.hot.data) {
import.meta.hot.data.editorBridgeContext = bridgeContext; import.meta.hot.data.editorBridgeContext = bridgeContext;
} }
@@ -171,7 +171,7 @@ export let editorBridge: Promise<EditorBridge> = new Promise(() => {
if (!import.meta.env.SSR) { if (!import.meta.env.SSR) {
editorBridge = editorBridge =
import.meta.hot?.data.editorBridge ?? import.meta.hot?.data?.editorBridge ??
Promise.resolve() Promise.resolve()
.then(() => { .then(() => {
return new EditorBridge(); return new EditorBridge();
@@ -181,7 +181,7 @@ if (!import.meta.env.SSR) {
return editorBridge; return editorBridge;
}); });
if (import.meta.hot) { if (import.meta.hot && import.meta.hot.data) {
import.meta.hot.data.editorBridge = editorBridge; import.meta.hot.data.editorBridge = editorBridge;
} }
} }

View File

@@ -27,14 +27,14 @@ export class ChatStore {
// 当前消息 id // 当前消息 id
currentMessageId: WritableAtom<string | undefined> = currentMessageId: WritableAtom<string | undefined> =
import.meta.hot?.data.currentMessageId ?? atom<string | undefined>(undefined); import.meta.hot?.data?.currentMessageId ?? atom<string | undefined>(undefined);
currentDescription: WritableAtom<string | undefined> = currentDescription: WritableAtom<string | undefined> =
import.meta.hot?.data.currentDescription ?? atom<string | undefined>(undefined); import.meta.hot?.data?.currentDescription ?? atom<string | undefined>(undefined);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); artifacts: Artifacts = import.meta.hot?.data?.artifacts ?? map({});
artifactIdList: string[] = []; artifactIdList: string[] = [];
actionAlert: WritableAtom<ActionAlert | undefined> = actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data.actionAlert ?? atom<ActionAlert | undefined>(undefined); import.meta.hot?.data?.actionAlert ?? atom<ActionAlert | undefined>(undefined);
// 添加对webBuilderStore和pagesStore的引用 // 添加对webBuilderStore和pagesStore的引用
readonly webBuilderStore: WebBuilderStore; readonly webBuilderStore: WebBuilderStore;
@@ -44,7 +44,7 @@ export class ChatStore {
this.webBuilderStore = webBuilderStore; this.webBuilderStore = webBuilderStore;
this.pagesStore = pagesStore; 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.artifacts = this.artifacts;
import.meta.hot.data.actionAlert = this.actionAlert; import.meta.hot.data.actionAlert = this.actionAlert;
import.meta.hot.data.currentDescription = this.currentDescription; import.meta.hot.data.currentDescription = this.currentDescription;

View File

@@ -31,11 +31,11 @@ export const editorCommands = atom<EditorCommand | null>(null);
export class EditorStore { export class EditorStore {
private readonly pagesStore: PagesStore; private readonly pagesStore: PagesStore;
editorInstance: WritableAtom<Editor | null> = import.meta.hot?.data.editorInstance ?? atom<Editor | null>(null); editorInstance: WritableAtom<Editor | null> = import.meta.hot?.data?.editorInstance ?? atom<Editor | null>(null);
// 编辑器中当前选中的文档。 // 编辑器中当前选中的文档。
selectedDocument: SelectedDocument = import.meta.hot?.data.selectedPage ?? atom<string | undefined>(); selectedDocument: SelectedDocument = import.meta.hot?.data?.selectedPage ?? atom<string | undefined>();
// 编辑器文档数据,始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。 // 编辑器文档数据,始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。
editorDocuments: MapStore<EditorDocuments> = import.meta.hot?.data.documents ?? map({}); editorDocuments: MapStore<EditorDocuments> = import.meta.hot?.data?.documents ?? map({});
// 当前编辑器文档,基于 editorDocuments 和 selectedDocument 计算而来。始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。 // 当前编辑器文档,基于 editorDocuments 和 selectedDocument 计算而来。始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。
currentDocument = computed([this.editorDocuments, this.selectedDocument], (documents, selectedDocument) => { currentDocument = computed([this.editorDocuments, this.selectedDocument], (documents, selectedDocument) => {
if (!selectedDocument) { if (!selectedDocument) {
@@ -44,15 +44,15 @@ export class EditorStore {
return documents[selectedDocument]; return documents[selectedDocument];
}); });
// 当前编辑器未保存的页面 // 当前编辑器未保存的页面
unsavedDocuments: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedDocuments ?? atom(new Set<string>()); unsavedDocuments: WritableAtom<Set<string>> = import.meta.hot?.data?.unsavedDocuments ?? atom(new Set<string>());
// 编辑器文档最后保存时间 // 编辑器文档最后保存时间
documentLastSaved: WritableAtom<Record<string, number>> = documentLastSaved: WritableAtom<Record<string, number>> =
import.meta.hot?.data.documentLastSaved ?? atom<Record<string, number>>({}); import.meta.hot?.data?.documentLastSaved ?? atom<Record<string, number>>({});
constructor(pagesStore: PagesStore) { constructor(pagesStore: PagesStore) {
this.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.unsavedDocuments = this.unsavedDocuments;
import.meta.hot.data.selectedDocument = this.selectedDocument; import.meta.hot.data.selectedDocument = this.selectedDocument;
import.meta.hot.data.editorDocuments = this.editorDocuments; import.meta.hot.data.editorDocuments = this.editorDocuments;

View File

@@ -33,27 +33,27 @@ export class PagesStore {
* @note 跟踪所有自上次用户消息以来被修改的文件及其原始内容,以便模型感知这些更改。 * @note 跟踪所有自上次用户消息以来被修改的文件及其原始内容,以便模型感知这些更改。
* 当用户发送另一条消息且所有更改都需要提交时,需要重置。 * 当用户发送另一条消息且所有更改都需要提交时,需要重置。
*/ */
private modifiedPages: Map<string, string> = import.meta.hot?.data.modifiedPages ?? new Map(); private modifiedPages: Map<string, string> = import.meta.hot?.data?.modifiedPages ?? new Map();
/** /**
* 跟踪已删除的页面,防止它们在重新加载时重新出现 * 跟踪已删除的页面,防止它们在重新加载时重新出现
*/ */
private deletedPages: Set<string> = import.meta.hot?.data.deletedPages ?? new Set(); private deletedPages: Set<string> = import.meta.hot?.data?.deletedPages ?? new Set();
/** /**
* 页面映射,与 AI 做交互,基于 artifacts 数据解析而来。 * 页面映射,与 AI 做交互,基于 artifacts 数据解析而来。
* 因此,此数据表示与数据库通信的底层数据,未保存的数据将不会在此处体现。 * 因此,此数据表示与数据库通信的底层数据,未保存的数据将不会在此处体现。
* 如果在编辑器中确定保存了数据,则需要实时同步进 #modifiedPages 中。 * 如果在编辑器中确定保存了数据,则需要实时同步进 #modifiedPages 中。
*/ */
pages: MapStore<PageMap> = import.meta.hot?.data.pages ?? map({}); pages: MapStore<PageMap> = import.meta.hot?.data?.pages ?? map({});
/** /**
* 页面历史记录,用于 diff 视图。 * 页面历史记录,用于 diff 视图。
* 每次页面保存时,会保存上一次的页面内容。 * 每次页面保存时,会保存上一次的页面内容。
*/ */
pageHistory: MapStore<Record<string, PageHistory>> = import.meta.hot?.data.pageHistory ?? map({}); pageHistory: MapStore<Record<string, PageHistory>> = import.meta.hot?.data?.pageHistory ?? map({});
activePage: ActivePage = import.meta.hot?.data.activePage ?? atom<string | undefined>(); activePage: ActivePage = import.meta.hot?.data?.activePage ?? atom<string | undefined>();
currentPage = computed([this.pages, this.activePage], (pages, activePage) => { currentPage = computed([this.pages, this.activePage], (pages, activePage) => {
if (!activePage) { if (!activePage) {
return undefined; return undefined;
@@ -64,11 +64,11 @@ export class PagesStore {
/** /**
* 基于 action 的 section 映射,作为与 AI 交互的底层数据,基于 actions 数据解析而来。 * 基于 action 的 section 映射,作为与 AI 交互的底层数据,基于 actions 数据解析而来。
*/ */
sections: MapStore<SectionMap> = import.meta.hot?.data.sections ?? map({}); sections: MapStore<SectionMap> = import.meta.hot?.data?.sections ?? map({});
/** /**
* 当前活跃的 section。 * 当前活跃的 section。
*/ */
activeSection: ActiveSection = import.meta.hot?.data.activeSection ?? atom<string | undefined>(); activeSection: ActiveSection = import.meta.hot?.data?.activeSection ?? atom<string | undefined>();
currentSection = computed([this.sections, this.activeSection], (sections, activeSection) => { currentSection = computed([this.sections, this.activeSection], (sections, activeSection) => {
if (!activeSection) { if (!activeSection) {
@@ -100,7 +100,7 @@ export class PagesStore {
logger.error('Failed to load deleted paths from localStorage', error); 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 // Persist our state across hot reloads
import.meta.hot.data.pages = this.pages; import.meta.hot.data.pages = this.pages;
import.meta.hot.data.modifiedPages = this.modifiedPages; import.meta.hot.data.modifiedPages = this.modifiedPages;

View File

@@ -37,9 +37,9 @@ export class WebBuilderStore {
readonly editorStore: EditorStore; readonly editorStore: EditorStore;
// 是否显示 webBuilder // 是否显示 webBuilder
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false); showWorkbench: WritableAtom<boolean> = import.meta.hot?.data?.showWorkbench ?? atom(false);
// 当前 webBuilder 所在的视图 // 当前 webBuilder 所在的视图
currentView: WritableAtom<WebBuilderViewType> = import.meta.hot?.data.currentView ?? atom('code'); currentView: WritableAtom<WebBuilderViewType> = import.meta.hot?.data?.currentView ?? atom('code');
constructor() { constructor() {
this.previewsStore = new PreviewsStore(); this.previewsStore = new PreviewsStore();
@@ -47,7 +47,7 @@ export class WebBuilderStore {
this.chatStore = new ChatStore(this, this.pagesStore); this.chatStore = new ChatStore(this, this.pagesStore);
this.editorStore = new EditorStore(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.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView; import.meta.hot.data.currentView = this.currentView;
} }

View File

@@ -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('<script id="test">console.log("hello")</script>')).toBe(true);
expect(isScriptContent(' <script id="test">console.log("hello")</script>')).toBe(true);
});
it('应该不识别非 script 标签内容', () => {
expect(isScriptContent('<div id="test">hello</div>')).toBe(false);
expect(isScriptContent('<style id="test">body { color: red; }</style>')).toBe(false);
expect(isScriptContent('hello world')).toBe(false);
});
});
describe('isValidContent - 有效的 HTML 内容', () => {
it('应该接受根节点完整且有 id 的 HTML内部元素可不完整', () => {
expect(isValidContent('<section id="xxxx"><div>h')).toBe(true);
expect(isValidContent('<section id="xxxx"><div>hello')).toBe(true);
expect(isValidContent('<section id="xxxx"><div>hello world</div>')).toBe(true);
expect(isValidContent('<section id="xxxx">hello world')).toBe(true);
expect(isValidContent('<section id="xxxx">hello world</br>')).toBe(true);
});
it('应该接受完整的 HTML 元素', () => {
expect(isValidContent('<div id="root"></div>')).toBe(true);
expect(isValidContent('<div id="root">content</div>')).toBe(true);
expect(isValidContent('<section id="main"><h1>Title</h1></section>')).toBe(true);
});
it('应该接受带有多个属性的根元素', () => {
expect(isValidContent('<div id="root" class="container">content</div>')).toBe(true);
expect(isValidContent('<div class="container" id="root" data-test="value">content</div>')).toBe(true);
});
it('应该接受使用单引号的 id 属性', () => {
expect(isValidContent("<div id='root'>content</div>")).toBe(true);
expect(isValidContent("<div id='root' class='container'>content</div>")).toBe(true);
});
});
describe('isValidContent - 无效的 HTML 内容', () => {
it('应该拒绝根节点不完整的 HTML', () => {
expect(isValidContent('<div')).toBe(false);
expect(isValidContent('<div id="xx')).toBe(false);
expect(isValidContent('<div id="xxxx"><div>hello world</d')).toBe(false);
expect(isValidContent('<div id="xxxx"><div>hello world</div')).toBe(false);
});
it('应该拒绝缺少 id 属性的根元素', () => {
expect(isValidContent('<div>content</div>')).toBe(false);
expect(isValidContent('<div class="container">content</div>')).toBe(false);
});
it('应该拒绝 id 属性不完整的根元素', () => {
expect(isValidContent('<div id=')).toBe(false);
expect(isValidContent('<div id="')).toBe(false);
expect(isValidContent('<div id="root')).toBe(false);
});
it('应该拒绝末尾有不完整标签的内容', () => {
expect(isValidContent('<div id="root">content</')).toBe(false);
expect(isValidContent('<div id="root">content</d')).toBe(false);
expect(isValidContent('<div id="root">content</di')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的内容', () => {
expect(isValidContent('<div id="xxxx"><div>hello world<')).toBe(false);
expect(isValidContent('<div id="root">content<')).toBe(false);
expect(isValidContent('<div id="test"><span>test<')).toBe(false);
});
it('应该接受 < 作为普通字符后跟完整闭合标签的内容', () => {
expect(isValidContent('<div id="xxxx"><div>hello world<</div>')).toBe(true);
expect(isValidContent('<div id="root">5 < 10</div>')).toBe(true);
expect(isValidContent('<div id="test"><span>a<b</span></div>')).toBe(true);
});
});
describe('isValidContent - 有效的 script 内容', () => {
it('应该接受完整的 script 标签', () => {
expect(isValidContent('<script id="test">console.log("hello");</script>')).toBe(true);
expect(isValidContent('<script id="test"></script>')).toBe(true);
expect(isValidContent('<script id="main" type="text/javascript">alert("test");</script>')).toBe(true);
});
it('应该接受使用单引号的 script 标签', () => {
expect(isValidContent("<script id='test'>console.log('hello');</script>")).toBe(true);
});
it('应该接受带有多行代码的 script 标签', () => {
const content = `<script id="test">
function hello() {
console.log("world");
}
hello();
</script>`;
expect(isValidContent(content)).toBe(true);
});
});
describe('isValidContent - 无效的 script 内容', () => {
it('应该拒绝没有 id 的 script 标签', () => {
expect(isValidContent('<script>console.log("hello");</script>')).toBe(false);
});
it('应该拒绝没有闭合标签的 script', () => {
expect(isValidContent('<script id="test">console.log("hello");')).toBe(false);
expect(isValidContent('<script id="test">console.log("hello");</scrip')).toBe(false);
expect(isValidContent('<script id="test">console.log("hello");</script')).toBe(false);
});
it('应该拒绝 script 开始标签不完整', () => {
expect(isValidContent('<script id="test"')).toBe(false);
expect(isValidContent('<script id="test')).toBe(false);
expect(isValidContent('<script id=')).toBe(false);
});
it('应该拒绝 id 属性不完整的 script 标签', () => {
expect(isValidContent('<script id="test>console.log("hello");</script>')).toBe(false);
expect(isValidContent('<script id=test>console.log("hello");</script>')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的 script 内容', () => {
expect(isValidContent('<script id="test">console.log("hello")<')).toBe(false);
expect(isValidContent('<script id="test">var a = 5<')).toBe(false);
});
it('应该接受 script 中 < 作为普通字符后跟完整闭合标签', () => {
expect(isValidContent('<script id="test">if (5 < 10) { console.log("yes"); }</script>')).toBe(true);
expect(isValidContent('<script id="test">var a = b < c;</script>')).toBe(true);
});
});
describe('isValidContent - 有效的 style 内容', () => {
it('应该接受完整的 style 标签', () => {
expect(isValidContent('<style id="test">body { color: red; }</style>')).toBe(true);
expect(isValidContent('<style id="test"></style>')).toBe(true);
expect(isValidContent('<style id="main" type="text/css">.class { margin: 0; }</style>')).toBe(true);
});
it('应该接受使用单引号的 style 标签', () => {
expect(isValidContent("<style id='test'>body { color: red; }</style>")).toBe(true);
});
it('应该接受带有多行样式的 style 标签', () => {
const content = `<style id="test">
body {
margin: 0;
padding: 0;
}
.container {
width: 100%;
}
</style>`;
expect(isValidContent(content)).toBe(true);
});
});
describe('isValidContent - 无效的 style 内容', () => {
it('应该拒绝没有 id 的 style 标签', () => {
expect(isValidContent('<style>body { color: red; }</style>')).toBe(false);
});
it('应该拒绝没有闭合标签的 style', () => {
expect(isValidContent('<style id="test">body { color: red; }')).toBe(false);
expect(isValidContent('<style id="test">body { color: red; }</styl')).toBe(false);
expect(isValidContent('<style id="test">body { color: red; }</style')).toBe(false);
});
it('应该拒绝 style 开始标签不完整', () => {
expect(isValidContent('<style id="test"')).toBe(false);
expect(isValidContent('<style id="test')).toBe(false);
expect(isValidContent('<style id=')).toBe(false);
});
it('应该拒绝 id 属性不完整的 style 标签', () => {
expect(isValidContent('<style id="test>body { color: red; }</style>')).toBe(false);
expect(isValidContent('<style id=test>body { color: red; }</style>')).toBe(false);
});
it('应该拒绝末尾有孤立 < 字符的 style 内容', () => {
expect(isValidContent('<style id="test">body { color: red; }<')).toBe(false);
expect(isValidContent('<style id="test">.class { margin: 0; }<')).toBe(false);
});
it('应该接受 style 中 < 作为普通字符后跟完整闭合标签', () => {
expect(isValidContent('<style id="test">/* comment < test */ body { color: red; }</style>')).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(' <div id="root">content</div>')).toBe(true);
expect(isValidContent(' <script id="test">console.log("hello");</script>')).toBe(true);
expect(isValidContent(' <style id="test">body { color: red; }</style>')).toBe(true);
});
});
});

View File

@@ -6,6 +6,102 @@ export function isScriptContent(content: string): boolean {
return content.trim().startsWith('<script'); return content.trim().startsWith('<script');
} }
/**
* 验证第一个标签(根节点)的完整性
* @param content 内容字符串
* @returns 验证结果,包括是否有效、根元素 id 和标签名
*/
function validateRootTagCompleteness(content: string): { valid: boolean; rootId?: string; tagName?: string } {
const trimmedContent = content.trim();
if (!trimmedContent.startsWith('<')) {
logger.warn('内容不以标签开始');
return { valid: false };
}
// 查找第一个完整的开始标签(找到第一个 >
const firstTagEndIndex = trimmedContent.indexOf('>');
if (firstTagEndIndex === -1) {
logger.warn('根标签不完整:未找到闭合的 >');
return { valid: false };
}
// 提取第一个完整的标签(包括 >
const firstTag = trimmedContent.substring(0, firstTagEndIndex + 1);
// 提取标签名称(支持 <tagName 或 <tagName空格 的形式)
const tagNameMatch = firstTag.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
if (!tagNameMatch) {
logger.warn('无法提取标签名称');
return { valid: false };
}
const tagName = tagNameMatch[1];
// 验证 id 属性是否存在且完整
// 支持 id="..." 或 id='...' 两种形式
const idPattern = /id=["']([^"']+)["']/;
const idMatch = firstTag.match(idPattern);
if (!idMatch) {
logger.warn('根标签缺少完整的 id 属性', { tagName });
return { valid: false };
}
const rootId = idMatch[1];
return { valid: true, rootId, tagName };
}
/**
* 验证指定标签的闭合标签是否完整
* @param content 内容字符串
* @param tagName 标签名称
* @returns 是否存在完整的闭合标签
*/
function validateClosingTag(content: string, tagName: string): boolean {
const closingTag = `</${tagName}>`;
return content.includes(closingTag);
}
/**
* 检查内容中是否存在明显不完整的标签
* @param content 内容字符串
* @returns 是否存在不完整的标签
*/
function hasIncompleteTag(content: string): boolean {
// 检查内容末尾是否有不完整的闭合标签
// 匹配 </、</d、</div 等(没有 > 的闭合标签)
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 属性 * - 检查是否包含完整的 id 属性
@@ -18,8 +114,20 @@ export function isValidContent(content: string): boolean {
return false; 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 { try {
// 创建一个临时的 DOM 解析器 // 创建一个临时的 DOM 解析器
@@ -28,6 +136,12 @@ export function isValidContent(content: string): boolean {
// 检查内容类型 // 检查内容类型
if (content.trim().startsWith('<script')) { if (content.trim().startsWith('<script')) {
// 对于 script验证闭合标签完整性
if (!validateClosingTag(content, 'script')) {
logger.warn('script 标签缺少完整的闭合标签 </script>');
return false;
}
// JavaScript 内容验证 // JavaScript 内容验证
const scriptElements = doc.getElementsByTagName('script'); const scriptElements = doc.getElementsByTagName('script');
@@ -47,15 +161,12 @@ export function isValidContent(content: string): boolean {
return false; return false;
} }
// 检查 id 是否完整,即属性在原始内容中是否以 id="..." 或 id='...' 的形式完整出现 // 验证提取的 id 与 DOMParser 解析的 id 一致
if (content.indexOf(`id="${scriptElement.id}"`) === -1 && content.indexOf(`id='${scriptElement.id}'`) === -1) { if (scriptElement.id !== rootId) {
logger.warn('JS content contains incomplete id attribute', { contentLength: content.length }); logger.warn('script 标签 id 不一致', {
return false; extractedId: rootId,
} parsedId: scriptElement.id,
});
// 检查 script 标签是否有完整的闭合标签
if (!content.includes('</script>')) {
logger.warn('JS content must have closing </script> tag', { contentLength: content.length });
return false; return false;
} }
@@ -63,6 +174,12 @@ export function isValidContent(content: string): boolean {
} }
if (content.trim().startsWith('<style')) { if (content.trim().startsWith('<style')) {
// 对于 style验证闭合标签完整性
if (!validateClosingTag(content, 'style')) {
logger.warn('style 标签缺少完整的闭合标签 </style>');
return false;
}
// CSS 内容验证 // CSS 内容验证
const styleElements = doc.getElementsByTagName('style'); const styleElements = doc.getElementsByTagName('style');
@@ -82,14 +199,12 @@ export function isValidContent(content: string): boolean {
return false; return false;
} }
if (content.indexOf(`id="${styleElement.id}"`) === -1 && content.indexOf(`id='${styleElement.id}'`) === -1) { // 验证提取的 id 与 DOMParser 解析的 id 一致
logger.warn('CSS content contains incomplete id attribute', { contentLength: content.length }); if (styleElement.id !== rootId) {
return false; logger.warn('style 标签 id 不一致', {
} extractedId: rootId,
parsedId: styleElement.id,
// 检查 style 标签是否有完整的闭合标签 });
if (!content.includes('</style>')) {
logger.warn('style content must have closing </style> tag');
return false; return false;
} }
@@ -115,8 +230,12 @@ export function isValidContent(content: string): boolean {
return false; return false;
} }
if (content.indexOf(`id="${rootElement.id}"`) === -1 && content.indexOf(`id='${rootElement.id}'`) === -1) { // 验证提取的 id 与 DOMParser 解析的 id 一致
logger.warn('HTML content contains incomplete id attribute'); if (rootElement.id !== rootId) {
logger.warn('HTML 根元素 id 不一致', {
extractedId: rootId,
parsedId: rootElement.id,
});
return false; return false;
} }

10
vitest.config.ts Normal file
View File

@@ -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,
},
});