fix(security): 属性级转义防注入 + 发布产物扩展名修复

- 链接/图片 href/src/alt/title 与代码块 language 属性改用属性级转义
  (浏览器环境 escapeHTML 不转义双引号,存在属性注入面)
- 修复 title 中反斜杠转义还原,引用链接 title 输出与行内一致
- 产物改用 .mjs/.cjs 扩展名(type:module 下 .js 被 Node 按 ESM 解析,
  require() 拿不到导出);exports 指向 dist 而非 src TS 源码
- 新增 .gitattributes 强制 LF,避免 Windows CRLF 污染 diff
- 配套测试:title/URL/alt/language 注入防护
This commit is contained in:
2026-08-09 08:59:19 +08:00
parent 9fe209ba88
commit ddc650feeb
15 changed files with 115 additions and 14278 deletions
+7
View File
@@ -0,0 +1,7 @@
* text=auto eol=lf
*.sh text eol=lf
*.png binary
*.jpg binary
*.gif binary
*.ico binary
+1
View File
@@ -2,6 +2,7 @@ node_modules/
dist/ dist/
coverage/ coverage/
.DS_Store .DS_Store
.zcode/
# 本地 npm 发布凭据(含 _auth,勿提交) # 本地 npm 发布凭据(含 _auth,勿提交)
.npmrc .npmrc
-4536
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
-677
View File
@@ -1,677 +0,0 @@
/**
* MetonaEditor Plugins — plugin system v2
* @module plugins
* @version 0.2.0
*/
interface Plugin {
name: string;
version?: string;
description?: string;
depends?: string[];
priority?: number;
install?: (editor: any) => void | Promise<void>;
destroy?: (editor: any) => void;
[key: string]: any;
}
interface PluginSchema {
[key: string]: {
type?: string;
required?: boolean;
default?: any;
enum?: any[];
validator?: (val: any) => boolean | string;
};
}
declare class PluginManager {
plugins: Map<string, Plugin & {
enabled: boolean;
}>;
register(name: string, plugin: Plugin): this;
unregister(name: string): this;
get(name: string): Plugin | null;
has(name: string): boolean;
getAll(): Plugin[];
getNames(): string[];
enable(name: string): this;
disable(name: string): this;
isEnabled(name: string): boolean;
destroy(): void;
}
declare const topologicalSort: (plugins: Plugin[]) => Plugin[];
declare const validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
declare const presetPlugins: Record<string, Plugin>;
declare const pluginUtils: {
createManager: () => PluginManager;
manager: PluginManager;
register: (name: string, plugin: Plugin) => PluginManager;
unregister: (name: string) => PluginManager;
get: (name: string) => Plugin | null;
has: (name: string) => boolean;
getAll: () => Plugin[];
getNames: () => string[];
enable: (name: string) => PluginManager;
disable: (name: string) => PluginManager;
isEnabled: (name: string) => boolean;
getPreset: (name: string) => Plugin | null;
getAllPresets: () => Record<string, Plugin>;
createPlugin: (config?: Partial<Plugin>) => Plugin;
validatePlugin: (plugin: any) => {
valid: boolean;
errors: string[];
};
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
};
declare const loadRemote: (url: string, locale: string) => Promise<boolean>;
interface InstanceI18n {
set: (locale: string) => string;
get: () => string;
getDirection: () => 'ltr' | 'rtl';
t: (key: string, params?: Record<string, any>) => string;
formatNumber: (n: number, opts?: Intl.NumberFormatOptions) => string;
formatDate: (d: Date | string, opts?: Intl.DateTimeFormatOptions) => string;
}
declare const createInstanceI18n: (editor: any) => InstanceI18n;
declare const i18nUtils: {
t: (key: string, params?: Record<string, any>, locale?: string) => string;
getCurrentLocale: () => string;
setCurrentLocale: (locale: string) => void;
switchLocale: (locale: string) => void;
getFallbackLocale: () => string;
setFallbackLocale: (locale: string) => void;
hasTranslation: (key: string) => boolean;
getTranslations: (locale: string) => Record<string, any>;
addTranslations: (locale: string, translations: Record<string, any>) => void;
loadRemote: (url: string, locale: string) => Promise<boolean>;
getSupportedLocales: () => string[];
isLocaleSupported: (locale: string) => boolean;
getLocaleName: (locale: string) => string;
getLocaleDirection: (locale: string) => "ltr" | "rtl";
formatNumber: (number: number, options?: Intl.NumberFormatOptions) => string;
formatCurrency: (amount: number, currency?: string, options?: Intl.NumberFormatOptions) => string;
formatDate: (date: Date | string, options?: Intl.DateTimeFormatOptions) => string;
addLocaleListener: (fn: (locale: string) => void) => () => void;
removeLocaleListener: (fn: (locale: string) => void) => void;
clearLocaleListeners: () => void;
initI18n: () => void;
saveLocale: (locale: string) => void;
loadLocale: () => string;
getDefaultLocale: () => string;
createInstanceI18n: (editor: any) => InstanceI18n;
};
/**
* MetonaEditor Icons — SVG toolbar icons
* @module icons
* @version 0.2.0
*/
declare const ICONS: Record<string, string>;
/**
* MetonaEditor Constants — default configs, themes, animations
* @module constants
* @version 0.2.0
*/
type EditMode = 'edit' | 'split' | 'preview';
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string;
type ToolbarItem = string | '|';
interface RenderEnv {
highlight?: (code: string, lang: string) => string;
locale?: string;
refs?: Record<string, string>;
}
interface EditorStyle {
color?: string;
backgroundColor?: string;
[key: string]: string | undefined;
}
interface EditorOptions {
value?: string;
placeholder?: string;
mode?: EditMode;
height?: number | string;
toolbar?: ToolbarItem[] | false;
wordCount?: boolean;
autofocus?: boolean;
spellcheck?: boolean;
historyLimit?: number;
historyDebounce?: number;
syncScroll?: boolean;
tabSize?: number;
readOnly?: boolean;
lineNumbers?: boolean;
outline?: boolean;
autoBrackets?: boolean;
zenMode?: boolean;
wordWrap?: boolean;
maxLength?: number;
theme?: ThemeName;
locale?: string;
render?: ((markdown: string, env: RenderEnv) => string) | null;
highlight?: ((code: string, lang: string) => string) | null;
sanitize?: ((html: string) => string) | null;
className?: string;
style?: EditorStyle;
plugins?: any[];
id?: string;
onChange?: ((value: string, editor: any) => void) | null;
onInput?: ((value: string, editor: any) => void) | null;
onFocus?: ((editor: any) => void) | null;
onBlur?: ((editor: any) => void) | null;
onSave?: ((value: string, editor: any) => void) | null;
onModeChange?: ((mode: string, editor: any) => void) | null;
onFullscreen?: ((fullscreen: boolean, editor: any) => void) | null;
onCreate?: ((editor: any) => void) | null;
onDestroy?: ((editor: any) => void) | null;
onLinkClick?: ((href: string, text: string, editor: any) => void) | null;
floatingToolbar?: boolean;
}
interface ThemeConfig {
bg: string;
text: string;
border: string;
shadow: string;
hoverShadow?: string;
toolbarBg?: string;
textareaBg?: string;
previewBg?: string;
codeBg?: string;
codeText?: string;
accent?: string;
muted?: string;
progressBg?: string;
closeHoverBg?: string;
}
interface AnimationConfig {
name?: string;
enter: Record<string, string>;
leave: Record<string, string>;
duration: number;
easing: string;
}
/** Default toolbar button sequence */
declare const DEFAULT_TOOLBAR: ToolbarItem[];
/** Default options */
declare const DEFAULTS: Readonly<EditorOptions>;
declare const THEMES: Record<string, ThemeConfig | string>;
declare const EDIT_MODES: EditMode[];
declare const TOOLBAR_ACTIONS: string[];
/**
* MetonaEditor Parser — lightweight Markdown parser (TypeScript)
* @module parser
* @version 0.2.0
*/
interface Token {
type: string;
[key: string]: any;
}
interface ParseResult {
tokens: Token[];
footnotes: Record<string, string>;
refs: Record<string, string>;
}
interface BlockHandler {
name: string;
priority: number;
test: (line: string, lines: string[], i: number) => any;
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>, refs?: Record<string, string>) => {
token: Token | null;
newIndex: number;
};
}
declare const clearRenderCache: () => void;
declare const safeUrl: (url: string | null | undefined) => string;
declare const slugify: (text: string) => string;
declare const registerBlockHandler: (handler: BlockHandler) => void;
declare const parseTokens: (md: string | null | undefined) => ParseResult;
declare const renderTokens: (tokens: Token[], env?: RenderEnv, footnotes?: Record<string, string>) => string;
declare const parseMarkdown: (md: string | null | undefined, env?: RenderEnv) => string;
/**
* MetonaEditor Core — MarkdownEditor class (TypeScript)
* @module core
* @version 0.2.0
*/
declare class MarkdownEditor {
static _hooks: Map<string, ((editor: MarkdownEditor) => void)[]>;
static on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
static off(name: string, fn: (editor: MarkdownEditor) => void): void;
static trigger(name: string, instance: MarkdownEditor): void;
id: string;
container: HTMLElement;
config: EditorOptions;
el: HTMLElement;
toolbarEl: HTMLElement;
bodyEl: HTMLElement;
editorPane: HTMLElement;
editorInner: HTMLElement;
previewPane: HTMLElement;
dividerEl: HTMLElement;
textarea: HTMLTextAreaElement;
previewEl: HTMLElement;
gutter: HTMLElement;
statusEl: HTMLElement | null;
_value: string;
_mode: EditMode;
_history: string[];
_historyIndex: number;
_cleanups: Array<() => void>;
_plugins: any[];
_listeners: Record<string, Array<(...args: any[]) => void>>;
_renderRaf: number | null;
_historyTimer: ReturnType<typeof setTimeout> | null;
_fullscreen: boolean;
_destroyed: boolean;
_lastRenderedValue: string | null;
_shortcuts: any[];
_contextMenuItems: any[];
_customActions: Record<string, (...args: any[]) => void>;
_outlineTimer: ReturnType<typeof setTimeout> | null;
_zenMode: boolean;
_wordWrap: boolean;
_syncing: boolean;
_zenMouseHandler: ((e: MouseEvent) => void) | null;
_ariaLive: HTMLElement;
_renderFn: (md: string, env: RenderEnv) => string;
_highlightFn: ((code: string, lang: string) => string) | null;
_themeCtx: any;
_i18nCtx: InstanceI18n;
_floatingToolbar: HTMLElement | null;
_floatingEnabled: boolean;
constructor(container: string | HTMLElement, options?: EditorOptions);
_buildDOM(): void;
_buildToolbar(): void;
_createBtn(item: string): HTMLButtonElement;
_bindEvents(): void;
_handleKeydown(e: KeyboardEvent): void;
_handleTab(shift: boolean): void;
_bindDividerDrag(e: PointerEvent): void;
_saveDividerPosition(): void;
_restoreDividerPosition(): void;
_scheduleRender(): void;
_render(): void;
_renderGutter(): void;
_updateCurrentLine(): void;
_handleSmartEnter(e: KeyboardEvent): void;
_handleBracketAutoClose(e: KeyboardEvent): void;
_bindDragDrop(): void;
_buildOutline(): void;
_updateOutline(): void;
_trackOutlineScroll(): void;
_scheduleHistory(): void;
_pushHistory(): void;
undo(): this;
redo(): this;
canUndo(): boolean;
canRedo(): boolean;
_applyHistory(): void;
exec(action: string, ...args: any[]): this;
_wrapSelection(before: string, after: string): void;
_toggleLinePrefix(prefix: string): void;
_insertBlock(text: string): void;
_insertLink(): void;
_insertImage(): void;
_insertTable(rows?: number, cols?: number): void;
_formatTable(): void;
setMode(mode: EditMode): this;
getMode(): EditMode;
_updateModeButtons(): void;
toggleFullscreen(): this;
isFullscreen(): boolean;
exitFullscreen(): this;
toggleZen(): this;
isZen(): boolean;
toggleWordWrap(): this;
setWordWrap(on: boolean): this;
isWordWrap(): boolean;
_initFloatingToolbar(): void;
toggleFloatingToolbar(): this;
isFloatingToolbar(): boolean;
_buildFloatingToolbar(): void;
_hideFloatingToolbar(): void;
_bindToolbarKeyboard(): void;
_initAriaLive(): void;
_announce(msg: string): void;
_syncScroll(): void;
_updateWordCount(): void;
getStats(): {
characters: number;
words: number;
chineseChars: number;
englishWords: number;
lines: number;
readingTime: number;
};
getSelectedText(): string;
getCursorPosition(): {
line: number;
column: number;
};
setCursorPosition(line: number, column?: number): this;
scrollToLine(line: number): this;
selectLine(line: number): this;
selectAll(): this;
replaceAll(search: string, replace: string, caseSensitive?: boolean): number;
replaceAllRegex(pattern: RegExp, replace: string): number;
lineCount(): number;
getLine(line: number): string;
getValue(): string;
setValue(md: string, opts?: {
silent?: boolean;
}): this;
getHTML(): string;
refresh(): this;
copyAsMarkdown(): this;
copyAsHTML(): this;
insert(text: string, opts?: {
replace?: boolean;
}): this;
wrap(before: string, after: string): this;
focus(): this;
blur(): this;
enable(): this;
disable(): this;
isDisabled(): boolean;
setReadOnly(readOnly: boolean): this;
isReadOnly(): boolean;
on(name: string, fn: (...args: any[]) => void): () => void;
off(name: string, fn: (...args: any[]) => void): this;
_emit(name: string, ...args: any[]): void;
use(plugin: string | any, options?: any): this;
unuse(name: string): this;
getPlugins(): any[];
addToolbarButton(config: any): this;
registerShortcut(combo: string, handler: Function | string, description?: string): this;
unregisterShortcut(combo: string): this;
getShortcuts(): any[];
configureToolbar(tools: ToolbarItem[]): this;
removeToolbarButton(action: string): this;
registerContextMenu(items?: any[]): this;
_bindContextMenu(): void;
_showContextMenu(e: MouseEvent): void;
_hideContextMenu(): void;
_execContextAction(action: string): void;
toast(message: string, opts?: {
type?: string;
duration?: number;
animation?: string;
}): this;
setLocale(locale: string): this;
getLocale(): string;
t(key: string, params?: Record<string, any>): string;
setTheme(theme: ThemeName): this;
getTheme(): string;
getThemeContext(): any;
getStatus(): {
id: string;
mode: EditMode;
theme: string;
locale: string;
fullscreen: boolean;
readOnly: boolean;
disabled: boolean;
destroyed: boolean;
plugins: any[];
};
destroy(): void;
isDestroyed(): boolean;
}
/**
* MetonaEditor Themes — theme system
* @module themes
* @version 0.2.0
*/
declare const exportCSSVars: (el?: HTMLElement) => Record<string, string>;
declare const getCSSVariable: (name: string, el?: HTMLElement) => string;
declare const followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
declare const adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
declare const createInstanceTheme: (editor: any) => any;
declare const themeUtils: {
getSystemTheme: () => "light" | "dark";
resolveTheme: (theme: string) => string;
getThemeConfig: (theme: string) => ThemeConfig;
watchSystemTheme: () => void;
unwatchSystemTheme: () => void;
applyTheme: (theme: string) => void;
getCurrentTheme: () => string;
getResolvedTheme: () => string;
switchTheme: (theme: string) => void;
toggleTheme: () => void;
resetToAuto: () => void;
initTheme: () => void;
saveTheme: (theme: string) => void;
loadTheme: () => string;
addThemeListener: (fn: (theme: string, resolved: string) => void) => () => void;
removeThemeListener: (fn: (theme: string, resolved: string) => void) => void;
clearThemeListeners: () => void;
registerTheme: (name: string, config?: Partial<ThemeConfig> & {
extends?: string;
}) => void;
unregisterTheme: (name: string) => void;
getAllThemes: () => Record<string, ThemeConfig | string>;
getThemeNames: () => string[];
hasTheme: (name: string) => boolean;
setThemeVariables: (config: ThemeConfig, target?: HTMLElement) => void;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
applyThemeToElement: (theme: string, target: HTMLElement) => void;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
watch: (source: Function | string, onChange: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
};
/**
* MetonaEditor Animations — animation metadata
* @module animations
* @version 0.2.0
*/
declare const animationUtils: {
register(name: string, config: Partial<AnimationConfig> & {
enter?: Record<string, string>;
leave?: Record<string, string>;
}): void;
unregister(name: string): void;
get(name: string): AnimationConfig | null;
getAnimationNames(): string[];
getActiveCount(): number;
cancelAll(): void;
reset(): void;
destroy(): void;
};
declare const VERSION = "0.2.4";
declare function create(container: string | HTMLElement, options?: EditorOptions): MarkdownEditor;
declare function use(plugin: string | any, options?: any): typeof api;
declare function on(name: string, fn: (editor: MarkdownEditor) => void): () => void;
declare function off(name: string, fn: (editor: MarkdownEditor) => void): typeof api;
declare function setTheme(theme: ThemeName, _options?: any): typeof api;
declare function setLocale(locale: string): typeof api;
declare function destroy(): void;
declare function getStatus(): {
version: string;
theme: string;
locale: string;
globalPlugins: any[];
presetPlugins: string[];
};
declare const api: {
VERSION: string;
version: string;
MarkdownEditor: typeof MarkdownEditor;
Editor: typeof MarkdownEditor;
create: typeof create;
use: typeof use;
on: typeof on;
off: typeof off;
setTheme: typeof setTheme;
setLocale: typeof setLocale;
destroy: typeof destroy;
getStatus: typeof getStatus;
parseMarkdown: (md: string | null | undefined, env?: RenderEnv) => string;
parseTokens: (md: string | null | undefined) => ParseResult;
renderTokens: (tokens: Token[], env?: RenderEnv, footnotes?: Record<string, string>) => string;
safeUrl: (url: string | null | undefined) => string;
slugify: (text: string) => string;
clearRenderCache: () => void;
registerBlockHandler: (handler: BlockHandler) => void;
themes: {
getSystemTheme: () => "light" | "dark";
resolveTheme: (theme: string) => string;
getThemeConfig: (theme: string) => ThemeConfig;
watchSystemTheme: () => void;
unwatchSystemTheme: () => void;
applyTheme: (theme: string) => void;
getCurrentTheme: () => string;
getResolvedTheme: () => string;
switchTheme: (theme: string) => void;
toggleTheme: () => void;
resetToAuto: () => void;
initTheme: () => void;
saveTheme: (theme: string) => void;
loadTheme: () => string;
addThemeListener: (fn: (theme: string, resolved: string) => void) => () => void;
removeThemeListener: (fn: (theme: string, resolved: string) => void) => void;
clearThemeListeners: () => void;
registerTheme: (name: string, config?: Partial<ThemeConfig> & {
extends?: string;
}) => void;
unregisterTheme: (name: string) => void;
getAllThemes: () => Record<string, ThemeConfig | string>;
getThemeNames: () => string[];
hasTheme: (name: string) => boolean;
setThemeVariables: (config: ThemeConfig, target?: HTMLElement) => void;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
applyThemeToElement: (theme: string, target: HTMLElement) => void;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
watch: (source: Function | string, onChange: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
};
i18n: {
t: (key: string, params?: Record<string, any>, locale?: string) => string;
getCurrentLocale: () => string;
setCurrentLocale: (locale: string) => void;
switchLocale: (locale: string) => void;
getFallbackLocale: () => string;
setFallbackLocale: (locale: string) => void;
hasTranslation: (key: string) => boolean;
getTranslations: (locale: string) => Record<string, any>;
addTranslations: (locale: string, translations: Record<string, any>) => void;
loadRemote: (url: string, locale: string) => Promise<boolean>;
getSupportedLocales: () => string[];
isLocaleSupported: (locale: string) => boolean;
getLocaleName: (locale: string) => string;
getLocaleDirection: (locale: string) => "ltr" | "rtl";
formatNumber: (number: number, options?: Intl.NumberFormatOptions) => string;
formatCurrency: (amount: number, currency?: string, options?: Intl.NumberFormatOptions) => string;
formatDate: (date: Date | string, options?: Intl.DateTimeFormatOptions) => string;
addLocaleListener: (fn: (locale: string) => void) => () => void;
removeLocaleListener: (fn: (locale: string) => void) => void;
clearLocaleListeners: () => void;
initI18n: () => void;
saveLocale: (locale: string) => void;
loadLocale: () => string;
getDefaultLocale: () => string;
createInstanceI18n: (editor: any) => InstanceI18n;
};
animations: {
register(name: string, config: Partial<AnimationConfig> & {
enter?: Record<string, string>;
leave?: Record<string, string>;
}): void;
unregister(name: string): void;
get(name: string): AnimationConfig | null;
getAnimationNames(): string[];
getActiveCount(): number;
cancelAll(): void;
reset(): void;
destroy(): void;
};
plugins: {
createManager: () => PluginManager;
manager: PluginManager;
register: (name: string, plugin: Plugin) => PluginManager;
unregister: (name: string) => PluginManager;
get: (name: string) => Plugin | null;
has: (name: string) => boolean;
getAll: () => Plugin[];
getNames: () => string[];
enable: (name: string) => PluginManager;
disable: (name: string) => PluginManager;
isEnabled: (name: string) => boolean;
getPreset: (name: string) => Plugin | null;
getAllPresets: () => Record<string, Plugin>;
createPlugin: (config?: Partial<Plugin>) => Plugin;
validatePlugin: (plugin: any) => {
valid: boolean;
errors: string[];
};
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
};
presetPlugins: Record<string, Plugin>;
topologicalSort: (plugins: Plugin[]) => Plugin[];
validateConfig: (schema?: PluginSchema, config?: Record<string, any>) => {
valid: boolean;
errors: string[];
patched: Record<string, any>;
};
createInstanceI18n: (editor: any) => InstanceI18n;
loadRemote: (url: string, locale: string) => Promise<boolean>;
exportCSSVars: (el?: HTMLElement) => Record<string, string>;
getCSSVariable: (name: string, el?: HTMLElement) => string;
followExternalTheme: (options: {
element?: HTMLElement;
attr?: string;
classMap?: Record<string, string>;
callback?: (el: HTMLElement) => string | null;
}, onThemeChange: (theme: string) => void) => () => void;
adoptFromParent: (container: HTMLElement, onThemeDetected: (theme: string) => void) => () => void;
createInstanceTheme: (editor: any) => any;
DEFAULTS: Readonly<EditorOptions>;
ICONS: Record<string, string>;
THEMES: Record<string, string | ThemeConfig>;
EDIT_MODES: EditMode[];
DEFAULT_TOOLBAR: string[];
TOOLBAR_ACTIONS: string[];
};
export { DEFAULTS, DEFAULT_TOOLBAR, EDIT_MODES, MarkdownEditor as Editor, ICONS, MarkdownEditor, api as MeEditor, THEMES, TOOLBAR_ACTIONS, VERSION, adoptFromParent, animationUtils, api, clearRenderCache, create, createInstanceI18n, createInstanceTheme, api as default, destroy, exportCSSVars, followExternalTheme, getCSSVariable, getStatus, i18nUtils, loadRemote, api as meEditor, off, on, parseMarkdown, parseTokens, pluginUtils, presetPlugins, registerBlockHandler, renderTokens, safeUrl, setLocale, setTheme, slugify, themeUtils, topologicalSort, use, validateConfig };
-4491
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
-4542
View File
File diff suppressed because it is too large Load Diff
-1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
+11 -8
View File
@@ -1,19 +1,21 @@
{ {
"name": "@metona-team/metona-editor", "name": "@metona-team/metona-editor",
"version": "0.2.4", "version": "0.2.5",
"description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.", "description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
"type": "module", "type": "module",
"main": "dist/metona-editor.js", "main": "dist/metona-editor.cjs",
"module": "src/index.ts", "module": "dist/metona-editor.mjs",
"unpkg": "dist/metona-editor.min.js", "unpkg": "dist/metona-editor.min.js",
"jsdelivr": "dist/metona-editor.min.js", "jsdelivr": "dist/metona-editor.min.js",
"types": "dist/metona-editor.d.ts", "types": "dist/metona-editor.d.ts",
"exports": { "exports": {
".": { ".": {
"import": "./src/index.ts", "types": "./dist/metona-editor.d.ts",
"require": "./dist/metona-editor.js", "import": "./dist/metona-editor.mjs",
"types": "./dist/metona-editor.d.ts" "require": "./dist/metona-editor.cjs",
} "default": "./dist/metona-editor.js"
},
"./package.json": "./package.json"
}, },
"files": [ "files": [
"dist/", "dist/",
@@ -27,9 +29,10 @@
"test": "jest --coverage", "test": "jest --coverage",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"lint": "eslint \"src/**/*.ts\"", "lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint src/ --fix", "lint:fix": "eslint \"src/**/*.ts\" --fix",
"format": "prettier --write src/", "format": "prettier --write src/",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"bench": "node bench/benchmark.cjs",
"prepublishOnly": "npm run typecheck && npm test && npm run build" "prepublishOnly": "npm run typecheck && npm test && npm run build"
}, },
"repository": { "repository": {
+2 -2
View File
@@ -61,7 +61,7 @@ export default [
{ {
input: 'src/index.ts', input: 'src/index.ts',
output: { output: {
file: 'dist/metona-editor.esm.js', file: 'dist/metona-editor.mjs',
format: 'es', format: 'es',
exports: 'named', exports: 'named',
sourcemap: true, sourcemap: true,
@@ -72,7 +72,7 @@ export default [
{ {
input: 'src/index.ts', input: 'src/index.ts',
output: { output: {
file: 'dist/metona-editor.cjs.js', file: 'dist/metona-editor.cjs',
format: 'cjs', format: 'cjs',
exports: 'named', exports: 'named',
sourcemap: true, sourcemap: true,
+26 -17
View File
@@ -4,7 +4,7 @@
* @version 0.2.0 * @version 0.2.0
*/ */
import { escapeHTML } from './utils'; import { escapeHTML, escapeAttr } from './utils';
import type { RenderEnv } from './constants'; import type { RenderEnv } from './constants';
// ============ Types ============ // ============ Types ============
@@ -137,6 +137,19 @@ const slugify = (text: string): string => {
return slug || 'heading'; return slug || 'heading';
}; };
/** Restore backslash-escaped punctuation in title text: \" -> " */
const unescapePunct = (text: string): string => text.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])/g, '$1');
/**
* Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped
* (entities are safe inside quoted attributes); ref-sourced text is raw but only
* `"` terminates a double-quoted attribute, so escaping quotes (and newlines) suffices.
*/
const titleAttr = (title: string | undefined): string => {
if (!title) return '';
return ` title="${unescapePunct(title).replace(/"/g, '&quot;').replace(/\r?\n/g, ' ')}"`;
};
// ============ Block handler registry ============ // ============ Block handler registry ============
const blockHandlers: BlockHandler[] = []; const blockHandlers: BlockHandler[] = [];
@@ -449,9 +462,9 @@ const parseList = (
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`)); const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
if (nUl || nOl) { if (nUl || nOl) {
const isNOl = !!nOl; const isNOl = !!nOl;
const nM = isNOl ? nOl![2] : nUl![2]; const nM = isNOl ? nOl[2] : nUl![2];
const nI = isNOl ? nOl![1].length : nUl![1].length; const nI = isNOl ? nOl[1].length : nUl![1].length;
const nS = isNOl ? parseInt(nOl![2], 10) : undefined; const nS = isNOl ? parseInt(nOl[2], 10) : undefined;
const nested = parseList(lines, i, nM, nI, isNOl, nS); const nested = parseList(lines, i, nM, nI, isNOl, nS);
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS }); subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
i = nested.endIdx; i = nested.endIdx;
@@ -538,8 +551,8 @@ const renderListItem = (it: ListItem, env: RenderEnv, idx?: number): string => {
const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record<string, string>): string => { const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record<string, string>): string => {
if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`; if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : ''; const langClass = lang ? ` class="language-${escapeAttr(lang)}"` : '';
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeHTML(attrs.title)}</div>` : ''; const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeAttr(attrs.title)}</div>` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) { if (env.highlight && typeof env.highlight === 'function' && lang) {
try { try {
const highlighted = env.highlight(code, lang); const highlighted = env.highlight(code, lang);
@@ -682,8 +695,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match; if (!m) return match;
const u = safeUrl(m[2]); if (!u) return escapeHTML(match); const u = safeUrl(m[2]); if (!u) return escapeHTML(match);
const t = m[3] ? ` title="${m[3]}"` : ''; return `<img src="${escapeAttr(u)}" alt="${escapeAttr(m[1])}"${titleAttr(m[3])} loading="lazy"/>`;
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
} }
if (match.startsWith('![') && match.includes('][')) { if (match.startsWith('![') && match.includes('][')) {
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/); const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
@@ -693,19 +705,17 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
try { try {
const ref = JSON.parse(env.refs[refKey]); const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : ''; return `<img src="${escapeAttr(u)}" alt="${escapeAttr(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`;
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`; } catch (_) { return `<img src="" alt="${escapeAttr(m[1])}" class="me-img-ref"/>`; }
} catch (_) { return `<img src="" alt="${m[1]}" class="me-img-ref"/>`; }
} }
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`; return `<img src="" alt="${escapeAttr(m[1])}" class="me-img-ref"/>`;
} }
if (match.startsWith('[') && match.includes('](')) { if (match.startsWith('[') && match.includes('](')) {
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m) return match; if (!m) return match;
const u = safeUrl(m[2]); if (!u) return match; const u = safeUrl(m[2]); if (!u) return match;
const t = m[3] ? ` title="${m[3]}"` : '';
const linkText = renderInline(m[1], env); const linkText = renderInline(m[1], env);
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`; return `<a href="${escapeAttr(u)}"${titleAttr(m[3])} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
} }
if (match.startsWith('[') && match.includes('][')) { if (match.startsWith('[') && match.includes('][')) {
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/); const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
@@ -715,8 +725,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
try { try {
const ref = JSON.parse(env.refs[refKey]); const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : ''; return `<a href="${escapeAttr(u)}"${titleAttr(escapeHTML(ref.title))} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
} catch (_) { return escapeHTML(match); } } catch (_) { return escapeHTML(match); }
} }
return escapeHTML(match); return escapeHTML(match);
@@ -724,7 +733,7 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
if (match.startsWith('&lt;http')) { if (match.startsWith('&lt;http')) {
const url = match.slice(4, -4); const url = match.slice(4, -4);
const u = safeUrl(url); const u = safeUrl(url);
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`; return `<a href="${escapeAttr(u)}" target="_blank" rel="noopener noreferrer">${url}</a>`;
} }
if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`; if (match.startsWith('**')) return `<strong>${match.slice(2, -2)}</strong>`;
if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`; if (match.startsWith('__')) return `<strong>${match.slice(2, -2)}</strong>`;
+10 -1
View File
@@ -25,6 +25,15 @@ export const escapeHTML = (s: unknown): string => {
return div.innerHTML; return div.innerHTML;
}; };
/** HTML-escape a string for use inside a double-quoted attribute value */
export const escapeAttr = (s: unknown): string => {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
/** Detect dark mode preference */ /** Detect dark mode preference */
export const prefersDark = (): boolean => { export const prefersDark = (): boolean => {
if (typeof window === 'undefined' || !window.matchMedia) return false; if (typeof window === 'undefined' || !window.matchMedia) return false;
@@ -69,7 +78,7 @@ export function throttle<T extends (...args: any[]) => void>(
/** Deep-merge two objects */ /** Deep-merge two objects */
export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => { export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => {
const output: Record<string, unknown> = { ...target }; const output: Record<string, unknown> = { ...target };
for (const key of Object.keys(source as Record<string, unknown>)) { for (const key of Object.keys(source)) {
const sv = (source as Record<string, unknown>)[key]; const sv = (source as Record<string, unknown>)[key];
const tv = (target as Record<string, unknown>)[key]; const tv = (target as Record<string, unknown>)[key];
if (sv instanceof Object && key in target && tv instanceof Object) { if (sv instanceof Object && key in target && tv instanceof Object) {
+58
View File
@@ -1270,3 +1270,61 @@ describe('parseMarkdown - v0.2.2 自定义 token 渲染', () => {
expect(html).toContain('<strong>Important!</strong>'); expect(html).toContain('<strong>Important!</strong>');
}); });
}); });
// ============ v0.2.5 链接 title 属性转义 ============
describe('parseMarkdown - v0.2.5 title 转义', () => {
test('链接 title 中的引号被转义', () => {
const html = parseMarkdown('[text](https://example.com "say \\"hi\\"")');
expect(html).toContain('title="say &quot;hi&quot;"');
expect(html).not.toContain('title="say "hi""');
});
test('链接 title 中的尖括号被转义', () => {
const html = parseMarkdown('[text](https://example.com "a<b>c")');
expect(html).toContain('title="a&lt;b&gt;c"');
});
test('图片 title 被转义', () => {
const html = parseMarkdown('![alt](https://example.com/i.png "x\\"y")');
expect(html).toContain('title="x&quot;y"');
});
test('引用链接 title 被转义', () => {
const html = parseMarkdown('[ref]: https://example.com "a\\"b"\n\n[text][ref]');
expect(html).toContain('title="a&quot;b"');
});
test('引用图片 title 被转义', () => {
const html = parseMarkdown('[img]: https://example.com/i.png "c<d>"\n\n![alt][img]');
expect(html).toContain('title="c&lt;d&gt;"');
});
test('链接 URL 中的引号不能注入属性', () => {
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
expect(html).not.toContain(' onclick=');
expect(html).toContain('&quot;onclick');
});
test('图片 alt 中的引号不能注入属性', () => {
const html = parseMarkdown('![a" onerror="alert(1)](https://example.com/i.png)');
expect(html).not.toContain(' onerror="');
expect(html).toContain('&quot;');
});
test('代码块语言属性中的引号不能注入', () => {
const html = parseMarkdown('```js" onclick="x\ncode\n```');
expect(html).not.toContain('language-js');
expect(html).not.toContain('onclick="x"');
});
test('代码块 title 属性中的引号不能注入', () => {
const html = parseMarkdown('```js title="a" onload="x"\ncode\n```');
expect(html).not.toContain('onload');
});
test('链接 title 中的反斜杠转义被还原', () => {
const html = parseMarkdown('[text](https://example.com "say \\"hi\\"")');
expect(html).toContain('title="say &quot;hi&quot;"');
});
});