/** * MetonaEditor Plugins — plugin system v2 * @module plugins * @version 0.2.0 */ import { t } from './i18n'; export interface Plugin { name: string; version?: string; description?: string; depends?: string[]; priority?: number; install?: (editor: any) => void | Promise; destroy?: (editor: any) => void; [key: string]: any; } export interface PluginSchema { [key: string]: { type?: string; required?: boolean; default?: any; enum?: any[]; validator?: (val: any) => boolean | string; }; } // ============ Plugin Manager ============ export class PluginManager { plugins = new Map(); register(name: string, plugin: Plugin): this { if (this.plugins.has(name)) { console.warn(`MeEditor: plugin "${name}" already registered`); return this; } if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) { console.error(`MeEditor: invalid plugin "${name}"`); return this; } this.plugins.set(name, { version: plugin.version || '0.0.0', description: plugin.description || '', depends: plugin.depends || [], priority: plugin.priority || 0, ...plugin, name, enabled: true, }); return this; } unregister(name: string): this { this.plugins.delete(name); return this; } get(name: string): Plugin | null { return this.plugins.get(name) || null; } has(name: string): boolean { return this.plugins.has(name); } getAll(): Plugin[] { return Array.from(this.plugins.values()); } getNames(): string[] { return Array.from(this.plugins.keys()); } enable(name: string): this { const p = this.plugins.get(name); if (p) p.enabled = true; return this; } disable(name: string): this { const p = this.plugins.get(name); if (p) p.enabled = false; return this; } isEnabled(name: string): boolean { const p = this.plugins.get(name); return p ? p.enabled : false; } destroy(): void { this.plugins.clear(); } } const defaultPluginManager = new PluginManager(); // ============ Topological sort ============ export const topologicalSort = (plugins: Plugin[]): Plugin[] => { const map = new Map(); plugins.forEach((p) => map.set(p.name, p)); const inDegree = new Map(); const adj = new Map(); plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); }); plugins.forEach((p) => { (p.depends || []).forEach((dep) => { if (map.has(dep)) { adj.get(dep)!.push(p.name); inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1); } else { console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`); } }); }); const queue: string[] = []; inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); }); const sorted: Plugin[] = []; while (queue.length) { queue.sort((a, b) => ((map.get(b)!.priority || 0) - (map.get(a)!.priority || 0))); const name = queue.shift()!; sorted.push(map.get(name)!); (adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n)! - 1); if (inDegree.get(n) === 0) queue.push(n); }); } if (sorted.length !== plugins.length) { console.warn('MeEditor: circular dependency detected, falling back to original order'); return plugins; } return sorted; }; // ============ Config validation ============ export const validateConfig = (schema: PluginSchema = {}, config: Record = {}): { valid: boolean; errors: string[]; patched: Record } => { const errors: string[] = []; const patched = { ...config }; for (const [key, rule] of Object.entries(schema)) { const val = config[key]; if (rule.required && (val === undefined || val === null)) { errors.push(`"${key}" is required`); continue; } if (val === undefined && rule.default !== undefined) { patched[key] = rule.default; continue; } if (val !== undefined && rule.type) { const actual = Array.isArray(val) ? 'array' : typeof val; if (actual !== rule.type) errors.push(`"${key}" expected ${rule.type}, got ${actual}`); } if (val !== undefined && rule.enum && !rule.enum.includes(val)) errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`); if (val !== undefined && typeof rule.validator === 'function') { try { const r = rule.validator(val); if (r !== true) errors.push(`"${key}": ${r}`); } catch (e: any) { errors.push(`"${key}": ${e.message}`); } } } return { valid: errors.length === 0, errors, patched }; }; // ============ Preset plugins ============ const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); const autoSavePlugin: Plugin = { name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100, install(editor, options?: Record) { if (!editor || typeof editor.getValue !== 'function') return; const opts = options || (this as any); const key: string = opts.key || ('me-draft-' + (editor.id || '')); const delay: number = opts.delay || 1000; const state = { _timer: null as ReturnType | null }; const save = () => { if (state._timer) { clearTimeout(state._timer); state._timer = null; } try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); } catch (e) { console.warn('MeEditor autoSave:', e); } }; const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); }; const _onBlur = save; const _onSave = save; editor.on('change', _onInput); editor.on('blur', _onBlur); editor.on('save', _onSave); editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } }; editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; }; editor.getDraftKey = () => key; (editor as any).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save }; }, destroy(editor) { const cleanup = (editor as any).__autoSaveCleanup; if (cleanup) { if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; } if (editor && typeof editor.off === 'function') { if (cleanup._onInput) editor.off('change', cleanup._onInput); if (cleanup._onBlur) editor.off('blur', cleanup._onBlur); if (cleanup._onSave) editor.off('save', cleanup._onSave); } delete (editor as any).__autoSaveCleanup; } }, }; const exportToolPlugin: Plugin = { name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const download = (filename: string, content: string, mime: string) => { if (typeof document === 'undefined') return; const blob = new Blob([content], { type: mime + ';charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 0); }; const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; }; editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; }; editor.exportHTML = (filename?: string, opts: any = {}) => { const title = opts.title || 'Document'; const css = opts.css || ''; const body = typeof editor.getHTML === 'function' ? editor.getHTML() : ''; // Build minimal embedded CSS for the exported HTML const embedCSS = opts.embedCSS !== false ? `` : ''; download(filename || `metona-${stamp()}.html`, `\n\n\n\n\n${title}\n${css?``:''}${embedCSS}\n\n\n${body}\n\n`, 'text/html'); return editor; }; }, destroy() {}, }; const searchReplacePlugin: Plugin = { name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; // Inject style once globally if (!document.getElementById('me-search-style')) { const style = document.createElement('style'); style.id = 'me-search-style'; style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`; document.head.appendChild(style); } const state = { _panel: null as HTMLElement | null, _regexMode: false, _cleanup: null as (() => void) | null, }; const _updateReplaceVisible = () => { if (!state._panel) return; const show = state._panel.dataset.replace === '1'; const row = state._panel.querySelector('.me-search-replace-row') as HTMLElement; if (row) row.style.display = show ? 'flex' : 'none'; }; const _close = () => { if (state._cleanup) { state._cleanup(); state._cleanup = null; } state._panel = null; if (editor && editor.textarea) editor.textarea.focus(); }; const _open = (showReplace?: boolean) => { if (state._panel) { state._panel.dataset.replace = showReplace ? '1' : '0'; _updateReplaceVisible(); const inp = state._panel.querySelector('.me-search-find') as HTMLInputElement; if (inp) { inp.focus(); inp.select(); } return; } const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const panel = document.createElement('div'); panel.className = 'me-search'; panel.dataset.replace = showReplace ? '1' : '0'; panel.innerHTML = `
`; editor.el.appendChild(panel); state._panel = panel; _updateReplaceVisible(); state._regexMode = false; const fi = panel.querySelector('.me-search-find') as HTMLInputElement; const ri = panel.querySelector('.me-search-replace') as HTMLInputElement; const ce = panel.querySelector('.me-search-count') as HTMLElement; const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement; regexBtn.addEventListener('click', () => { state._regexMode = !state._regexMode; regexBtn.classList.toggle('me-active', state._regexMode); lastIdxs = findAll(); }); const findAll = () => { const q = fi.value; if (!q) { ce.textContent = ''; return []; } const idxs: number[] = []; let from = 0; if (state._regexMode) { try { const re = new RegExp(q, 'g'); let m: RegExpExecArray | null; while ((m = re.exec(editor.textarea.value)) !== null) { idxs.push(m.index); if (m[0].length === 0) re.lastIndex++; } } catch (_) { ce.textContent = 'err'; return []; } } else { while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; } } ce.textContent = idxs.length ? `${idxs.length}` : '0'; return idxs; }; let lastIdxs: number[] = []; const selectAt = (idx: number, len?: number) => { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length)); }; const findNext = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionEnd; const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; let next = lastIdxs.find((i: number) => i >= cur); if (next == null) next = lastIdxs[0]; selectAt(next, qlen); }; const findPrev = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionStart; const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; let prev = -1; for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } if (prev === -1) prev = lastIdxs[lastIdxs.length-1]; selectAt(prev, qlen); }; const replaceOne = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; const matchText = ta.value.substring(s, e); if (state._regexMode) { try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) {} } else if (matchText === q) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); findNext(); }; const replaceAll = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; if (state._regexMode) { try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; } } else { ta.value = ta.value.split(q).join(r); } ta.setSelectionRange(0,0); editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); findAll(); }; fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } }); ri.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } }); panel.querySelector('.me-search-next')!.addEventListener('click', findNext); panel.querySelector('.me-search-prev')!.addEventListener('click', findPrev); panel.querySelector('.me-search-close')!.addEventListener('click', () => _close()); panel.querySelector('.me-search-replace-one')!.addEventListener('click', replaceOne); panel.querySelector('.me-search-replace-all')!.addEventListener('click', replaceAll); fi.focus(); fi.select(); state._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); }; }; const _onKeydown = (e: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; if (!mod) return; const k = e.key.toLowerCase(); if (k === 'f') { e.preventDefault(); _open(false); } else if (k === 'h') { e.preventDefault(); _open(true); } else if (k === 'escape' && state._panel) { _close(); } }; editor.textarea.addEventListener('keydown', _onKeydown); // Expose for tests (editor as any).__srState = state; (editor as any).__srOpen = _open; (editor as any).__srClose = _close; (editor as any).__srKeydown = _onKeydown; }, destroy(editor: any) { const state = (editor as any).__srState; if (state) { if (state._cleanup) { state._cleanup(); state._cleanup = null; } state._panel = null; } const _onKeydown = (editor as any).__srKeydown; if (_onKeydown && editor && editor.textarea) { editor.textarea.removeEventListener('keydown', _onKeydown); } delete (editor as any).__srState; delete (editor as any).__srOpen; delete (editor as any).__srClose; delete (editor as any).__srKeydown; }, }; const imagePastePlugin: Plugin = { name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; const _onPaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type?.startsWith('image/')) { e.preventDefault(); const reader = new FileReader(); reader.onload = () => { editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); }; reader.readAsDataURL(item.getAsFile()!); break; } } }; editor.textarea.addEventListener('paste', _onPaste); (editor as any).__ipOnPaste = _onPaste; }, destroy(editor: any) { const _onPaste = (editor as any).__ipOnPaste; if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste); delete (editor as any).__ipOnPaste; }, }; const shortcutHelpPlugin: Plugin = { name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!document.getElementById('me-shortcut-style')) { const s = document.createElement('style'); s.id = 'me-shortcut-style'; s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`; document.head.appendChild(s); } let _panel: HTMLElement | null = null; const _close = () => { if (_panel) { _panel.remove(); _panel = null; } }; const _open = () => { if (_panel) { _close(); return; } // i18n-aware shortcut labels const builtin: [string, string][] = [ ['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['?', t('close') || 'Shortcuts'], ]; let rows = ''; builtin.forEach(([c, d]) => { rows += `${c}${d}`; }); const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; overlay.innerHTML = `

⌨️ ${t('close') || 'Shortcuts'}

${rows}
`; overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); }); document.body.appendChild(overlay); _panel = overlay; }; const _onKeydown = (e: KeyboardEvent) => { if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); } if (e.key === 'Escape' && _panel) _close(); }; editor.textarea.addEventListener('keydown', _onKeydown); (editor as any).__shState = { _panel, _open, _close, _onKeydown }; }, destroy(editor: any) { const state = (editor as any).__shState; if (state) { if (state._panel) { state._panel.remove(); } } const _onKeydown = (editor as any).__shState?._onKeydown; if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); delete (editor as any).__shState; }, }; const fileSystemPlugin: Plugin = { name: 'fileSystem', version: '0.2.0', description: 'File System Access API', priority: 90, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function'; let _fileHandle: any = null; editor.openFile = async (opts: any = {}) => { if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } try { const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts }); _fileHandle = handle; const file = await handle.getFile(); const content = await file.text(); editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle }; } catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; } }; editor.saveFile = async (opts: any = {}) => { let handle = _fileHandle; if (!handle || opts.saveAs) { if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; } catch (e: any) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; } } try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; } catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } }; editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name }); editor.getFileHandle = () => _fileHandle; (editor as any).__fsCleanup = { getHandle: () => _fileHandle }; }, destroy(editor: any) { if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } delete (editor as any).__fsCleanup; }, }; // ============ Preset plugins table ============ export const presetPlugins: Record = { autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin, imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin, }; // ============ Plugin utils ============ export const pluginUtils = { createManager: () => new PluginManager(), manager: defaultPluginManager, register: (name: string, plugin: Plugin) => defaultPluginManager.register(name, plugin), unregister: (name: string) => defaultPluginManager.unregister(name), get: (name: string) => defaultPluginManager.get(name), has: (name: string) => defaultPluginManager.has(name), getAll: () => defaultPluginManager.getAll(), getNames: () => defaultPluginManager.getNames(), enable: (name: string) => defaultPluginManager.enable(name), disable: (name: string) => defaultPluginManager.disable(name), isEnabled: (name: string) => defaultPluginManager.isEnabled(name), getPreset: (name: string): Plugin | null => presetPlugins[name] ? { ...presetPlugins[name] } : null, getAllPresets: (): Record => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {} as Record), createPlugin: (config: Partial = {}): Plugin => { const result: Plugin = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, install: () => {}, destroy: () => {}, ...config }; if (typeof result.install !== 'function') result.install = () => {}; if (typeof result.destroy !== 'function') result.destroy = () => {}; return result; }, validatePlugin: (plugin: any): { valid: boolean; errors: string[] } => { const errors: string[] = []; if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object'); if (plugin && !plugin.name) errors.push('plugin must have a name'); if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function'); return { valid: errors.length === 0, errors }; }, topologicalSort, validateConfig, }; export default presetPlugins;