diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index 0f4877ea6..af8742643 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -31,7 +31,10 @@ import FolderComponent from './FolderComponent'; import { proxyFetchGet } from '@/api/http'; import { MarkDown } from '@/components/ChatBox/MessageItem/MarkDown'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; -import { injectFontStyles } from '@/lib/htmlFontStyles'; +import { + deferInlineScriptsUntilLoad, + injectFontStyles, +} from '@/lib/htmlFontStyles'; import { containsDangerousContent } from '@/lib/htmlSanitization'; import { useAuthStore } from '@/store/authStore'; import { useTranslation } from 'react-i18next'; @@ -992,8 +995,12 @@ function HtmlRenderer({ return; } + // Defer inline scripts until load when document has external scripts (e.g. Chart.js), + const htmlWithDeferredScripts = + deferInlineScriptsUntilLoad(processedHtmlContent); + // Set the processed HTML with font styles - iframe sandbox provides security - setProcessedHtml(injectFontStyles(processedHtmlContent)); + setProcessedHtml(injectFontStyles(htmlWithDeferredScripts)); }; processHtml(); diff --git a/src/lib/htmlFontStyles.ts b/src/lib/htmlFontStyles.ts index f84f12278..42ede91ed 100644 --- a/src/lib/htmlFontStyles.ts +++ b/src/lib/htmlFontStyles.ts @@ -53,3 +53,131 @@ export function isHtmlDocument(text: string): boolean { const trimmed = text.trim(); return /^]+))/i + ); + if (!typeMatch) return true; + + const rawType = (typeMatch[1] ?? typeMatch[2] ?? typeMatch[3] ?? '').trim(); + if (!rawType) return true; + + const normalizedType = rawType.split(';', 1)[0].trim(); + if (!normalizedType) return true; + + if (normalizedType === 'module') return false; + if (normalizedType === 'application/ld+json') return false; + + const jsMimeTypes = new Set([ + 'text/javascript', + 'application/javascript', + 'text/ecmascript', + 'application/ecmascript', + 'application/x-javascript', + 'text/x-javascript', + 'application/x-ecmascript', + 'text/x-ecmascript', + 'text/jscript', + 'text/livescript', + ]); + + return jsMimeTypes.has(normalizedType); +} + +/** + * Returns true when a script tag has a real src attribute. + * This intentionally excludes attributes like data-src. + */ +function hasScriptSrc(attrs: string): boolean { + return /(?:^|\s)src\s*=/.test(attrs.toLowerCase()); +} + +/** + * Defers inline classic-JS that appears after external scripts until window load. + * This keeps pre-library config scripts in place and preserves global scope by + * executing deferred code through dynamically-inserted script elements. + */ +export function deferInlineScriptsUntilLoad(html: string): string { + const lower = html.toLowerCase(); + let idx = lower.indexOf('', idx); + if (end !== -1) { + const attrs = html.slice(idx + '', afterOpen); + if (attrEnd === -1) { + result += html.slice(scriptStart); + break; + } + const attrs = html.slice(afterOpen, attrEnd); + const hasSrc = hasScriptSrc(attrs); + const contentStart = attrEnd + 1; + const endTag = ''; + const contentEnd = lower.indexOf(endTag, contentStart); + if (contentEnd === -1) { + result += html.slice(scriptStart); + break; + } + const fullTag = html.slice(scriptStart, contentEnd + endTag.length); + const content = html.slice(contentStart, contentEnd); + const openTag = html.slice(scriptStart, attrEnd + 1); + + if (hasSrc) { + seenExternalScript = true; + result += fullTag; + } else if ( + seenExternalScript && + content.trim().length > 0 && + isClassicInlineJs(attrs) + ) { + const serializedContent = JSON.stringify(content).replace( + /<\/script>/gi, + '<\\/script>' + ); + const deferredRunner = [ + '(function(){', + 'var __eigentRun=function(){', + "var __eigentScript=document.createElement('script');", + 'var __eigentCurrentScript=document.currentScript;', + 'if(__eigentCurrentScript&&__eigentCurrentScript.nonce){__eigentScript.nonce=__eigentCurrentScript.nonce;}', + `__eigentScript.text=${serializedContent};`, + '(document.head||document.body||document.documentElement).appendChild(__eigentScript);', + '__eigentScript.remove();', + '};', + "if(document.readyState==='complete'){__eigentRun();}else{window.addEventListener('load',__eigentRun,{once:true});}", + '})();', + ].join(''); + result += `${openTag}${deferredRunner}`; + } else { + result += fullTag; + } + i = contentEnd + endTag.length; + } + return result; +} diff --git a/test/unit/lib/htmlFontStyles.test.ts b/test/unit/lib/htmlFontStyles.test.ts new file mode 100644 index 000000000..7cb632dc2 --- /dev/null +++ b/test/unit/lib/htmlFontStyles.test.ts @@ -0,0 +1,84 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { deferInlineScriptsUntilLoad } from '@/lib/htmlFontStyles'; +import { describe, expect, it } from 'vitest'; + +describe('deferInlineScriptsUntilLoad', () => { + it('only defers inline scripts that appear after an external script', () => { + const input = ` + + + +`; + + const output = deferInlineScriptsUntilLoad(input); + + expect(output).toContain(''); + expect(output).toContain( + '' + ); + expect(output).not.toContain(''); + expect(output).toContain("window.addEventListener('load'"); + }); + + it('treats uppercase SRC as external and defers following inline scripts', () => { + const input = ``; + + const output = deferInlineScriptsUntilLoad(input); + + expect(output).not.toContain(''); + expect(output).toContain("window.addEventListener('load'"); + }); + + it('does not mistake data-src as an external script source', () => { + const input = + ''; + + const output = deferInlineScriptsUntilLoad(input); + + expect(output).toBe(input); + }); + + it('preserves inline script global execution via dynamic script injection', () => { + const input = ``; + + const output = deferInlineScriptsUntilLoad(input); + + expect(output).toContain("document.createElement('script')"); + expect(output).toContain('window.shared = 1;'); + }); + + it('does not rewrite non-javascript script types', () => { + const input = ``; + + const output = deferInlineScriptsUntilLoad(input); + + expect(output).toContain( + '' + ); + expect(output).toContain( + '' + ); + }); + + it('supports javascript mime types with parameters', () => { + const input = ``; + + const output = deferInlineScriptsUntilLoad(input); + + expect(output).not.toContain('window.paramType = 1;'); + expect(output).toContain("window.addEventListener('load'"); + }); +});