309 lines
17 KiB
Markdown
309 lines
17 KiB
Markdown
# 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()
|
|
│ └── 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
|
|
|
|
```js
|
|
window.hyprcrd.markdown.registerFencedBlock('mylang', (content, { esc }) => {
|
|
return `<div class="my-block">${esc(content)}</div>`;
|
|
});
|
|
```
|
|
|
|
### Register an editor toolbar command
|
|
|
|
```js
|
|
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
|
|
|
|
```js
|
|
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)
|
|
|
|
```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)
|
|
|
|
```js
|
|
const result = await window.hyprcrd.api('api/myplugin/things', {
|
|
method: 'POST',
|
|
body: { name: 'foo' },
|
|
});
|
|
```
|
|
|
|
### Encrypt credentials (in backend.php, reusing the vault key)
|
|
|
|
```php
|
|
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)) ?: '';
|
|
}
|
|
```
|