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:
@@ -0,0 +1,7 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
@@ -2,6 +2,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.zcode/
|
||||||
|
|
||||||
# 本地 npm 发布凭据(含 _auth,勿提交)
|
# 本地 npm 发布凭据(含 _auth,勿提交)
|
||||||
.npmrc
|
.npmrc
|
||||||
|
|||||||
Vendored
-4536
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-677
@@ -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 };
|
|
||||||
Vendored
-4491
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-4542
File diff suppressed because it is too large
Load Diff
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+11
-8
@@ -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
@@ -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
@@ -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, '"').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('<http')) {
|
if (match.startsWith('<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
@@ -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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
};
|
||||||
|
|
||||||
/** 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) {
|
||||||
|
|||||||
@@ -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 "hi""');
|
||||||
|
expect(html).not.toContain('title="say "hi""');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('链接 title 中的尖括号被转义', () => {
|
||||||
|
const html = parseMarkdown('[text](https://example.com "a<b>c")');
|
||||||
|
expect(html).toContain('title="a<b>c"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图片 title 被转义', () => {
|
||||||
|
const html = parseMarkdown('');
|
||||||
|
expect(html).toContain('title="x"y"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用链接 title 被转义', () => {
|
||||||
|
const html = parseMarkdown('[ref]: https://example.com "a\\"b"\n\n[text][ref]');
|
||||||
|
expect(html).toContain('title="a"b"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('引用图片 title 被转义', () => {
|
||||||
|
const html = parseMarkdown('[img]: https://example.com/i.png "c<d>"\n\n![alt][img]');
|
||||||
|
expect(html).toContain('title="c<d>"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('链接 URL 中的引号不能注入属性', () => {
|
||||||
|
const html = parseMarkdown('[x](https://example.com/"onclick="alert(1))');
|
||||||
|
expect(html).not.toContain(' onclick=');
|
||||||
|
expect(html).toContain('"onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('图片 alt 中的引号不能注入属性', () => {
|
||||||
|
const html = parseMarkdown('');
|
||||||
|
expect(html).not.toContain(' onerror="');
|
||||||
|
expect(html).toContain('"');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 "hi""');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user