18 KiB
18 KiB
hyprcrd plugin system — mindmap
A complete architectural reference. Don't lose state on this — every detail here was learned the hard way during the canvas/tests/email plugin builds.
hyprcrd plugin system
│
├── Discovery (PHP)
│ ├── src/PluginManager.php (singleton)
│ │ ├── scan(dir) → reads each subdir's plugin.json
│ │ ├── loadDisabled(stateFile) → reads data/plugins.json {disabled: [...]}
│ │ ├── loadBackends() → require_once enabled backend.php files
│ │ └── isEnabled(name) → checked by cssLinks/htmlFragments/scriptTags
│ └── src/bootstrap.php (call order matters!)
│ 1. config.php
│ 2. Database.php
│ 3. PluginManager.php
│ 4. PluginManager::scan(public/plugins)
│ 5. PluginManager::loadDisabled(dirname(DB_PATH).'/plugins.json')
│ 6. PluginManager::loadBackends() ← AFTER loadDisabled, so disabled plugins skip backend.php too
│
├── plugin.json manifest format
│ ├── name (string, unique)
│ ├── version (string)
│ ├── description (string)
│ ├── frontend
│ │ ├── js: [...] (loaded as <script type="module">)
│ │ ├── css: [...] (loaded as <link>)
│ │ └── html: [...] (file contents inlined into body via htmlFragments())
│ └── backend (string, optional path to backend.php)
│
├── Core injection points (PHP, in index.php)
│ ├── In <head> after style.css: <?= PluginManager::instance()->cssLinks() ?>
│ ├── In #md-toolbar after extract: <?= PluginManager::instance()->slot('md-toolbar') ?>
│ ├── Before </body>: <?= PluginManager::instance()->htmlFragments() ?>
│ ├── Then synchronously: <script src="js/hyprcrd.js"></script>
│ ├── Then plugin entries: <?= PluginManager::instance()->scriptTags() ?>
│ ├── Then core ES module: <script type="module" src="js/app.js">
│ └── Inside auth-protected API block, BEFORE first /api/cards check:
│ PluginManager::instance()->dispatchRoute($route, $method);
│
├── Frontend API: window.hyprcrd (defined in public/js/hyprcrd.js, sync-loaded)
│ │
│ ├── hooks (generic event bus)
│ │ ├── on(name, fn) register listener
│ │ ├── off(name, fn) remove
│ │ └── emit(name, data) → data listeners can mutate via return value
│ │
│ ├── editor (toolbar command registry)
│ │ ├── registerCommand(name, fn) buttons with data-md="<name>" dispatch here
│ │ ├── unregisterCommand(name)
│ │ ├── getCommand(name)
│ │ └── api ← set by editor.js AFTER its init
│ │ ├── body the textarea element
│ │ ├── insertMd(b, a, ph) inserts markdown around selection
│ │ └── updatePreview() re-renders the preview pane
│ │
│ ├── markdown (registry, applied in order)
│ │ ├── registerFencedBlock(lang, fn) ```<lang> blocks → handler returns HTML
│ │ ├── registerPreProcessor(fn) runs on source text before marked.parse()
│ │ ├── registerPostProcessor(fn) runs on HTML after marked.parse(), before DOMPurify
│ │ ├── getFencedBlocks()
│ │ ├── getPreProcessors()
│ │ └── getPostProcessors()
│ │ └── handler signature: fn(content, { esc, normalizeListBlock }) → htmlString
│ │
│ ├── settings (settings overlay tab registry)
│ │ ├── registerTab({id, label, icon, onShow})
│ │ │ ├── injects button into #settings-nav
│ │ │ ├── creates empty <div id="settings-panel-{id}"> in panels container
│ │ │ ├── attaches click handler (active state + emit hook + onShow)
│ │ │ └── icon = phosphor class like 'ph-envelope', defaults to ph-puzzle-piece
│ │ ├── open() clicks #settings-btn
│ │ └── openTab(id) opens settings + switches to that tab
│ │
│ ├── util
│ │ ├── $(idOrSel) querySelector or getElementById
│ │ ├── $$(sel) querySelectorAll → Array
│ │ ├── esc(s) HTML escape
│ │ ├── setCsrf(token) called by app.js after auth
│ │ └── csrf() read by plugins for API calls
│ │
│ ├── api(path, opts) fetch wrapper, auto X-CSRF-Token header
│ │
│ ├── dialog (themed confirm/prompt — NEVER use native confirm())
│ │ ├── show(opts) → core dialog.js fills this in after load
│ │ │ opts: {title, message, input, danger, okLabel, cancelLabel}
│ │ │ returns Promise<value|null> (null = cancelled)
│ │ └── confirm(msg, opts) convenience: returns Promise<true|null>
│ │ opts: {title, okLabel, cancelLabel, danger}
│ │
│ └── registerPlugin({name, version, description})
│ └── pushes to hyprcrd.plugins[] for the settings → plugins UI
│
├── Backend API extension (PHP, from plugin's backend.php)
│ └── PluginManager::instance()->onApiRoute(function($route, $method) {
│ if ($route === '/api/foo' && $method === 'GET') {
│ // ... must call exit() on match
│ echo json_encode([...]);
│ exit;
│ }
│ });
│ │
│ ├── handlers run in registration order, before core route handling
│ ├── pass-through if none exit → core routes get a chance
│ ├── CSRF middleware in core runs BEFORE dispatchRoute, so plugin
│ │ routes are automatically CSRF-protected for mutations
│ └── auth check also runs BEFORE → all plugin routes require login
│
├── Lifecycle / load order (CRITICAL for timing bugs)
│ 1. Browser parses HTML
│ 2. <link rel="stylesheet"> tags loaded (core + plugin CSS)
│ 3. <?= htmlFragments() ?> → plugin overlay HTML now in DOM
│ 4. <script src="js/hyprcrd.js"> → SYNCHRONOUS, window.hyprcrd defined
│ 5. <script type="module" src="plugins/.../*.js"> → DEFERRED, run after parse
│ ├── Plugin modules execute in document order
│ ├── At this point: DOM ready, window.hyprcrd ready, core editor.js NOT loaded yet
│ ├── So hyprcrd.editor.api is null until app.js runs
│ └── Plugin commands that USE editor.api should defer (only fire on user click)
│ 6. <script type="module" src="js/app.js"> → orchestrator
│ ├── imports core modules (state, theme, editor, markdown, ...)
│ ├── markdown.js top-level: registers gallery/tree/bullets via the SAME registry
│ ├── editor.js top-level: registers heading/bold/italic/etc + sets editor.api
│ └── checkAuth() → setCsrf() → window.hyprcrd.util.setCsrf() (mirrored)
│
├── Settings tab race (avoided by registerTab design)
│ ├── theme.js attaches click handlers to all .settings-tab at module load
│ ├── If plugin's tab is added BEFORE theme.js runs (default), theme.js handler ALSO attaches
│ ├── Result: both core handler AND plugin click handler fire on click
│ ├── Both manage active states identically — harmless duplicate work
│ └── Plugin's onShow is called by the plugin handler, NOT by core
│
├── Disable mechanism
│ ├── State file: data/plugins.json → {"disabled": ["canvas", ...]}
│ ├── Settings UI: settings → plugins → toggle switches
│ ├── API: GET /api/plugins, POST /api/plugins/toggle {name, enabled}
│ ├── Disabled plugins are skipped in cssLinks, htmlFragments, scriptTags, AND loadBackends
│ ├── Toggling requires page reload (already-loaded code can't unload)
│ └── Reload notice in plugins panel; dirtyAfterToggle flag persists until reload
│
├── Conventions
│ ├── Plugins NEVER import from core modules. Use window.hyprcrd globals only.
│ ├── Plugin JS is ES module (loaded as type="module") so import/export works WITHIN the plugin
│ ├── Plugin self-registration happens at module top level (no init function needed)
│ ├── Plugin DOM injection should idempotently check if element already exists
│ │ (canvas: btn.querySelector('[data-md="asciiart"]') guard)
│ ├── If a plugin needs DOM that may not exist yet (e.g. injectToolbarButton),
│ │ wrap in DOMContentLoaded check OR run immediately if document.readyState !== 'loading'
│ ├── Plugin schema migrations live in backend.php as IIFE that runs once per request
│ │ (cheap CREATE TABLE IF NOT EXISTS)
│ └── Plugin credentials → reuse vault key encryption (sha256(vault_key) substr 0..32 + AES-256-CBC)
│
├── Existing plugins
│ ├── canvas/ (~500 lines)
│ │ ├── No backend
│ │ ├── Registers ascii fenced block + asciiart editor command + toolbar btn (JS injected)
│ │ ├── Has its own overlay (canvas.html), tools, palette
│ │ └── Edit-mode: detects cursor inside ```ascii block, loads existing content
│ ├── tests/ (~40 test files + runner.js + loader.js)
│ │ ├── No backend
│ │ ├── loader.js conditionally imports tests.js on ?test or Ctrl+Shift+T
│ │ ├── Test files import from '../../js/modules/...' (NOT '../modules/...')
│ │ └── Both static AND dynamic imports use that path — sed both when moving files
│ ├── email/ (~1300 lines, biggest)
│ │ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes)
│ │ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key
│ │ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}}
│ │ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'})
│ │ │ └── onShow rebuilds form on first show, refreshes accounts list every show
│ │ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
│ │ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
│ │ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
│ ├── logseq-import/ (~200 lines)
│ │ ├── Backend: reads ZIP, copies assets → uploads/, imports journals + pages
│ │ ├── Route: POST /api/import-logseq (multipart form, file field)
│ │ ├── Creates "import" bucket if missing; journals → blue, pages → violet
│ │ ├── Strips Logseq bullet prefixes and `key:: value` property lines
│ │ ├── Rewrites asset refs via filename → random-hex map
│ │ └── Frontend injects button + file input into footer before #settings-btn,
│ │ posts to /api/import-logseq, calls window._reloadAll() on success
│ └── slideshow/ (~500 lines)
│ ├── No backend — data stored as ```slide-image fenced block in card body
│ ├── Slideshow = parent card (start slide) + children (in position order)
│ ├── Text slides: normal markdown rendered fullscreen via hyprcrd.markdown.render
│ ├── Image slides: fenced block with `src:` + `x,y,w,h -> target` lines (coords in %)
│ │ └── Targets: card:<id>, slide:next, slide:prev, slide:first, slide:<index>
│ ├── Viewer: fullscreen overlay, ArrowLeft/Right + Esc, HUD with counter
│ │ ├── Image slides use object-fit:contain; hotspots positioned relative to img box
│ │ ├── Arrow keys always work as fallback, even when hotspots are present
│ │ └── 'h' key toggles debug-hotspots class to visualize rects during presentation
│ ├── Hotspot editor: drag on image to create rect, picker assigns target, dbl-click rect to re-pick
│ ├── Group-bar button: mutation-observed injection; reads parent id from #group-bar[data-group-id]
│ │ └── Requires board.js renderGroupBar to write bar.dataset.groupId — small core patch
│ └── Toolbar button "slideHotspots" injected into #md-toolbar, detects existing block at cursor
│
├── Gotchas / lessons learned
│ ├── statusHeight vs scrollHeight — textarea.scrollHeight includes padding, mind it
│ ├── inline-block vs block — pre with display:block stretches to container width,
│ │ making mouse-cell math wrong. Use width:max-content.
│ ├── auto-fit canvas — measure ONE char (set textContent='M'), then floor(container/charW)
│ ├── DOMPurify strips HTML comments — list:tree/bullets uses comments as markers, then
│ │ converts to classes BEFORE sanitize
│ ├── ALLOWED_TAGS in markdown.js — must include any tag plugins emit (pre, span, div ok)
│ ├── ALLOWED_ATTR — class, style, data-* are allowed; check before adding novel attrs
│ ├── theme.js handles tab switching for ALL .settings-tab, including dynamically added ones,
│ │ because forEach runs at module load AFTER plugins have injected their tabs
│ ├── CSRF token sync — app.js stores it via setCsrf() in util.js (module-private),
│ │ also calls window.hyprcrd.util.setCsrf() so plugins can read it
│ ├── Plugin tests can break core tab counts — don't hardcode toBe(N), use toContain checks
│ └── Header button injection — querySelector('#main-header .flex.items-center.gap-3:last-of-type')
│ to grab the right-side button group (calendar, gallery, etc.)
│
└── Adding a new plugin (checklist)
1. mkdir public/plugins/<name>
2. Write plugin.json (name, version, description, frontend.{js,css,html}, backend?)
3. If JS: import nothing from core. Use window.hyprcrd.* exclusively.
4. If HTML: provide overlay/modal markup. Use IDs prefixed with plugin name to avoid clashes.
5. If CSS: scope selectors to plugin-specific classes/IDs.
6. If backend: require_once your classes, define helpers in named functions (no closures
at top level — they pollute the global scope), register routes via onApiRoute().
7. If schema: CREATE TABLE IF NOT EXISTS in an IIFE at top of backend.php.
8. If credentials: reuse the vault key encryption pattern (see email/backend.php).
9. If settings: hyprcrd.settings.registerTab({...}) at the end of init().
10. Test by reloading; check window.hyprcrd.plugins includes your meta entry.
11. Verify it can be disabled via settings → plugins (assets must vanish from page source).
12. If adding a new core hook is unavoidable, document it HERE before merging.
API quick reference (copy-paste safe)
Register a markdown fenced block
window.hyprcrd.markdown.registerFencedBlock('mylang', (content, { esc }) => {
return `<div class="my-block">${esc(content)}</div>`;
});
Register an editor toolbar command
window.hyprcrd.editor.registerCommand('mycmd', () => {
const ed = window.hyprcrd.editor.api;
if (!ed) return; // editor not initialized yet (shouldn't happen on user click)
ed.insertMd('<<', '>>', 'placeholder');
});
// Add a button with data-md="mycmd" to #md-toolbar via JS:
const btn = document.createElement('button');
btn.dataset.md = 'mycmd';
btn.className = 'toolbar-btn';
btn.innerHTML = '<i class="ph ph-star text-sm"></i>';
document.getElementById('md-toolbar').appendChild(btn);
Register a settings tab
window.hyprcrd.settings.registerTab({
id: 'mything',
label: 'my thing',
icon: 'ph-star',
onShow: (panelEl) => {
panelEl.innerHTML = `<p>Settings UI here</p>`;
},
});
// Open it programmatically:
window.hyprcrd.settings.openTab('mything');
Register a backend API route (in backend.php)
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route === '/api/myplugin/things' && $method === 'GET') {
header('Content-Type: application/json');
echo json_encode(['ok' => true]);
exit;
}
});
Make a CSRF-protected API call (frontend)
const result = await window.hyprcrd.api('api/myplugin/things', {
method: 'POST',
body: { name: 'foo' },
});
Encrypt credentials (in backend.php, reusing the vault key)
function myEncKey(): string {
$db = Database::connect();
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
if ($vk) return substr(hash('sha256', $vk), 0, 32);
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
}
function myEncrypt(string $plain): string {
$key = myEncKey();
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $cipher);
}
function myDecrypt(string $encoded): string {
$key = myEncKey();
$raw = base64_decode($encoded);
if (!$raw || strlen($raw) < 17) return '';
return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
}