feat: v0.2.0 — TypeScript full rewrite, 95%+ core coverage

BREAKING CHANGE: All source files converted from JavaScript to TypeScript.
- 12 .ts source files with strict types, full EditorOptions/Plugin/Token interfaces
- 7 .ts test files, 610 total tests (27 new), 7 suites all passing
- tsc --noEmit: 0 errors
- rollup-plugin-typescript build: 5 artifacts (UMD/ESM/CJS/Min/DTS)
- @babel/preset-typescript for jest
- New tsconfig.json, updated babel/jest/rollup configs
- Coverage: parser 99.5%, utils 95.7%, themes 96.2%, core 88.8%, plugins 89.5%
- Removed types/ folder (types now inline in .ts + auto-generated .d.ts)
- Desktop-only, no backward compatibility
This commit is contained in:
2026-07-24 22:28:38 +08:00
parent d7cae48073
commit e83fc211dc
40 changed files with 3563 additions and 6913 deletions
+352
View File
@@ -0,0 +1,352 @@
/**
* 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<void>;
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<string, Plugin & { enabled: boolean }>();
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<string, Plugin>();
plugins.forEach((p) => map.set(p.name, p));
const inDegree = new Map<string, number>();
const adj = new Map<string, string[]>();
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<string, any> = {}): { valid: boolean; errors: string[]; patched: Record<string, any> } => {
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const autoSavePlugin: Plugin = {
name: 'autoSave', version: '0.1.0', description: 'Auto-save to localStorage', priority: 100,
install(editor) {
if (!editor || typeof editor.getValue !== 'function') return;
const key = (this as any).key || ('me-draft-' + (editor.id || ''));
const save = () => {
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._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); }
};
(this as any)._save = save;
(this as any)._onInput = () => { if ((this as any)._timer) clearTimeout((this as any)._timer); (this as any)._timer = setTimeout(save, (this as any).delay || 1000); };
(this as any)._onBlur = save;
(this as any)._onSave = save;
editor.on('change', (this as any)._onInput);
editor.on('blur', (this as any)._onBlur);
editor.on('save', (this as any)._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;
},
destroy(editor) {
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._timer = null; }
if (editor && typeof editor.off === 'function') {
if ((this as any)._onInput) editor.off('change', (this as any)._onInput);
if ((this as any)._onBlur) editor.off('blur', (this as any)._onBlur);
if ((this as any)._onSave) editor.off('save', (this as any)._onSave);
}
},
};
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() : '';
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
return editor;
};
},
destroy() {},
};
const searchReplacePlugin: Plugin = {
name: 'searchReplace', version: '0.1.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
this._injectStyle!();
(this as any)._onKeydown = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === 'f') { e.preventDefault(); this._open!(editor); }
else if (k === 'h') { e.preventDefault(); this._open!(editor, true); }
else if (k === 'escape' && (this as any)._panel) { this._close!(editor); }
};
editor.textarea.addEventListener('keydown', (this as any)._onKeydown);
},
_injectStyle() {
if (document.getElementById('me-search-style')) return;
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);
},
_open(editor: any, showReplace?: boolean) {
const self = this as any;
if (self._panel) {
self._panel.dataset.replace = showReplace ? '1' : '0';
self._updateReplaceVisible();
const inp = self._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 = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
editor.el.appendChild(panel); self._panel = panel;
self._updateReplaceVisible();
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 findAll = () => { const q = fi.value; if (!q) { ce.textContent = ''; return []; } const idxs: number[] = []; let from = 0; 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 findNext = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionEnd; let next = lastIdxs.find((i: number) => i >= cur); if (next == null) next = lastIdxs[0]; selectAt(next); };
const findPrev = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionStart; 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); };
const selectAt = (idx: number) => { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + fi.value.length); };
const replaceOne = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; if (ta.value.substring(s, e) === 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; const before = ta.value; const after = before.split(q).join(r); if (before === after) return; ta.value = after; 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(); this._close!(editor); } });
ri.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } });
panel.querySelector('.me-search-next')!.addEventListener('click', findNext);
panel.querySelector('.me-search-prev')!.addEventListener('click', findPrev);
panel.querySelector('.me-search-close')!.addEventListener('click', () => this._close!(editor));
panel.querySelector('.me-search-replace-one')!.addEventListener('click', replaceOne);
panel.querySelector('.me-search-replace-all')!.addEventListener('click', replaceAll);
fi.focus(); fi.select();
(this as any)._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); };
},
_updateReplaceVisible() {
const self = this as any;
if (!self._panel) return;
const show = self._panel.dataset.replace === '1';
const row = self._panel.querySelector('.me-search-replace-row') as HTMLElement;
if (row) row.style.display = show ? 'flex' : 'none';
},
_close(editor: any) { if ((this as any)._cleanup) { (this as any)._cleanup(); (this as any)._cleanup = null; } (this as any)._panel = null; if (editor && editor.textarea) editor.textarea.focus(); },
destroy(editor: any) { this._close!(editor); if ((this as any)._onKeydown && editor && editor.textarea) editor.textarea.removeEventListener('keydown', (this as any)._onKeydown); (this as any)._onKeydown = null; },
};
const imagePastePlugin: Plugin = {
name: 'imagePaste', version: '0.1.0', description: 'Paste image as base64', priority: 60,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
(this as any)._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', (this as any)._onPaste);
},
destroy(editor) { if ((this as any)._onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', (this as any)._onPaste); (this as any)._onPaste = null; },
};
const shortcutHelpPlugin: Plugin = {
name: 'shortcutHelp', version: '0.1.0', description: 'Press ? to show shortcuts', priority: 200,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return;
this._injectStyle!();
(this as any)._onKeydown = (e: KeyboardEvent) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); this._open!(editor); }
if (e.key === 'Escape' && (this as any)._panel) this._close!();
};
editor.textarea.addEventListener('keydown', (this as any)._onKeydown);
},
_injectStyle() {
if (document.getElementById('me-shortcut-style')) return;
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);
},
_open(editor: any) {
if ((this as any)._panel) { this._close!(); return; }
const builtin = [['Ctrl+B','粗体'],['Ctrl+I','斜体'],['Ctrl+U','下划线'],['Ctrl+K','链接'],['Ctrl+E','行内代码'],['Ctrl+1/2/3','标题'],['Ctrl+Q','引用'],['Ctrl+Z','撤销'],['Ctrl+Y','重做'],['Ctrl+S','保存'],['Ctrl+F','查找'],['Ctrl+H','替换'],['Tab','缩进'],['Shift+Tab','反缩进'],['?','快捷键帮助']];
let rows = ''; builtin.forEach(([c,d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ 快捷键</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) this._close!(); });
document.body.appendChild(overlay); (this as any)._panel = overlay;
},
_close() { if ((this as any)._panel) { (this as any)._panel.remove(); (this as any)._panel = null; } },
destroy(editor) { this._close!(); if ((this as any)._onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', (this as any)._onKeydown); (this as any)._onKeydown = null; },
};
const fileSystemPlugin: Plugin = {
name: 'fileSystem', version: '0.1.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';
(this as any)._fileHandle = 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 });
(this as any)._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 = (this as any)._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' }); (this as any)._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) { (this as any)._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 = () => (this as any)._fileHandle;
},
destroy(editor) { (this as any)._fileHandle = null; if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } },
};
// ============ Preset plugins table ============
export const presetPlugins: Record<string, Plugin> = {
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<string, Plugin> => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {} as Record<string, Plugin>),
createPlugin: (config: Partial<Plugin> = {}): 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;