impro_flake#
Nix flake + direnv workspace for developing Impro and its plugins. Contains three independent git repositories side by side:
| Path | Purpose | Remote |
|---|---|---|
impro/ |
Impro web client (Eleventy SPA). The plugin host. | tangled-7778777:7778777.online/impro |
impro-sample-plugin/ |
Reference plugin. Starter template for new plugins. | tangled-7778777:7778777.online/impro-sample-plugin |
. (this repo) |
The flake + direnv configuration that wraps both projects. | local-only at present |
The flake repo deliberately ignores both project subdirectories (see
.gitignore). This is what stops nix build / nix develop from copying
their node_modules/, build/, and plugin output into the nix store on every
rebuild — flake evaluation only sees flake.nix, flake.lock, .envrc,
and this README.md.
Prerequisites#
- Nix 2.20+ with flakes enabled (
experimental-features = flakes nix-command) - direnv, hooked into your shell (
eval "$(direnv hook zsh)"in~/.zshrc)
Both are already configured on this machine.
First-time setup#
cd ~/impro_flake
direnv allow # loads the dev shell from flake.nix
cd impro && npm ci # install host deps
cd ../impro-sample-plugin && npm ci # install plugin SDK + esbuild
If npm ci complains about un-reviewed install scripts, run
npm approve-scripts --all once in each project. This is npm 11+ policy.
What the dev shell provides#
flake.nix exposes a single devShells.default containing:
| Tool | Why |
|---|---|
nodejs_22 (Node 22.x, npm) |
Runtime + package manager for both projects |
typescript-language-server |
LSP for TypeScript / JavaScript (editor-agnostic) |
typescript (tsc) |
Backing tsserver for the LSP |
vscode-langservers-extracted |
HTML, CSS, JSON, ESLint language servers |
prettier |
Code formatter (impro also has a local copy via npm) |
playwright-driver |
End-to-end browser binaries for npm run test:e2e |
git |
Version control |
PLAYWRIGHT_BROWSERS_PATH is exported to the nix-installed browser bundle,
so Playwright won't try to re-download Chromium/Firefox/WebKit into
~/.cache/ms-playwright.
Build, run, test, debug#
All commands run inside the relevant project subdirectory.
impro/ (host)#
| Task | Command |
|---|---|
| Dev server | npm run start (live-reload on :8080) |
| Production build | npm run build (outputs to build/) |
| Serve built bundle | npm run serve:static |
| Unit tests | npm run test:unit |
| Unit tests + coverage | npm run test:unit:coverage |
| E2E tests | npm run test:e2e |
| Bundle client libs | npm run bundle:capacitor, npm run bundle:lit-html |
| Debug node process | node --inspect-brk <script> |
Pre-commit hook (.husky/pre-commit) runs prettier --staged + unit tests.
impro-sample-plugin/ (and any plugin you fork from it)#
| Task | Command |
|---|---|
| One-shot build | npm run build (esbuild → main.js) |
| Watch mode | npm run watch / npm start |
| Debug esbuild | node --inspect-brk node_modules/.bin/esbuild src/main.js --bundle --format=cjs --outfile=main.js |
Editor debug: use node --inspect-brk and attach via chrome://inspect
or VS Code's "Attach to Node Process" command.
Plugin development#
The Impro plugin system runs each plugin in a sandboxed Web Worker. Plugins
talk to the host via the @impro.social/impro-plugin SDK, which ships its
own RPC layer (see impro/impro-plugin/main.js for the host-side source).
Recommended workflow#
-
Develop against the local SDK. The sample plugin's
package.jsonpulls@impro.social/impro-pluginfrom a public registry, but the up-to-date SDK source lives atimpro/impro-plugin/main.jsin this workspace. To develop against the local copy, point npm at it:cd impro-sample-plugin npm install --save ../impro/impro-pluginThen
npm run buildwill bundle the latest local SDK into your plugin. (Revert this before publishing.) -
Symlink your plugin into the host. Already done for the sample plugin:
ln -s ../../impro-sample-plugin impro/plugins-local/impro-sample-pluginThe target is relative to the symlink's location (
impro/plugins-local/), so it takes two..to climb out ofimpro/before enteringimpro-sample-plugin/. A single..will silently resolve to a non-existent path and break the build with anENOENTfromfs.realpathSyncineleventy.config.js.While the dev server is running, Impro copies any symlinked plugin from
impro/plugins-local/intobuild/plugins-local/, appending__LOCALto its id, and regeneratesindex.json. The plugin shows up at http://localhost:8080/settings/plugins/community asimpro-sample-plugin__LOCAL. -
Iterate. Run two terminals:
# terminal 1: rebuild plugin on save cd ~/impro_flake/impro-sample-plugin && npm run watch # terminal 2: impro dev server (auto-reloads on plugin main.js change) cd ~/impro_flake/impro && npm run startToggle the plugin on in http://localhost:8080/settings/plugins/community, then exercise its features (sidebar lightning-bolt icon, post context-menu item, settings tab).
Publishing a plugin version#
Per Impro convention (impro/plugins.md):
- Tag a commit with the bare version number (e.g.
0.2.0, novprefix) and push it to your public git remote. - To get into the Community Plugins listing, open a PR to
improsocial/impro-releaseswith the plugin info.
Plugin API reference (cheat-sheet)#
All exports below come from @impro.social/impro-plugin. Source of truth:
impro/impro-plugin/main.js.
Lifecycle#
import { Plugin } from "@impro.social/impro-plugin";
export default class MyPlugin extends Plugin {
async onload() {
this.settings = { ...(await this.loadData()) }; // persisted per-user
// register UI, listeners, settings, etc. here
}
onunload() { /* cleanup */ }
}
Plugin.register() is the entrypoint called by the host; subclasses must
set themselves as the default export.
UI registration methods (on Plugin)#
| Method | Effect |
|---|---|
addSidebarItem(icon, title, cb) |
Adds a left-sidebar entry; cb invoked on click. |
addSettingTab(tab) |
Registers a PluginSettingTab instance. |
addFeedFilter(cb(uri, post)) |
Filters posts in feeds; cb returns truthy to keep. |
registerRichTextTransform(cb, opts) |
Rewrites the rich-text token stream per post. |
registerSlot(name, cb) |
Renders custom HTML into a named host slot. |
app.on(event, listener) |
Subscribes to host events (see below). |
Host events (app.on)#
post-context-menu—(menu) => {}— add items to post menuprofile-context-menu—(menu) => {}— add items to profile menupost-composer-open—(composer) => {}— mutate the composer
menu.addItem(item => item.setTitle(...).setIcon(...).onClick(...)).
composer.setText/appendText/prependText(text) / .setCursor(index).
Host bridge (read-only data access)#
const post = await this.app.data.getPost(uri);
const profile = await this.app.data.getProfile(did);
const record = await this.app.data.getRecord(repo, collection, rkey);
const me = this.app.currentUser; // populated before onload()
For network access, use the SDK's fetch() (whitelist-gated, not the global
one). Returns a PluginResponse with .status / .ok / .headers / .text() / .json().
UI primitives#
VirtualEl— tag/attrs/text/children builder; usecreateEl,createDiv,createSpan,setText,addClass,setAttr,onClick/onChange/onInput. Specialised constructors:createProfilesList,createPostsFeed,createIcon.Modal— subclass and overrideonOpen/onClose; build intothis.contentEl/this.titleEl; call.open()/.close().Notice— toast:new Notice("hi", 5000).setMessage(...).hide().StyleSnippet— injected CSS:new StyleSnippet(".x { ... }"),.readypromise,.remove().Setting— setting row builder:new Setting(containerEl).setName(...).setDesc(...).addText/addToggle/addDropdown/addButton(...).PluginSettingTab— subclass, overridedisplay(), callthis.refresh({ reset: true })to re-render after data changes.FlattenedTokens/flattenForScan(tokens)— helpers for rich-text transforms that need to pattern-match across token boundaries.
Constraints#
Plugins cannot make arbitrary network requests or read/modify page HTML
directly. Everything happens through the SDK's RPC bridge. See
impro/plugins.md for the canonical "can / cannot" list.
Git identity#
All commits across all three repositories in this workspace are authored as:
Nameless 7778777 <7778777@7778777.online>
This is enforced three ways (defence in depth):
- Per-repo local config —
git config --local user.{name,email}is set in.git/configforimpro/,impro-sample-plugin/, and the outer flake repo. - Direnv env vars —
.envrcexportsGIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL/GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL. These shadow the shell's ownGIT_*exports (which point at a different identity) whenever you are inside~/impro_flake/, including subdirectories. - No cryptographic signing — per setup choice "identity only"; no GPG/SSH signing is configured.
To verify before committing:
git config user.name && git config user.email
echo "$GIT_AUTHOR_NAME <$GIT_AUTHOR_EMAIL>"
Troubleshooting#
direnv: error / nix-direnv: ...— rundirenv allowafter any change to.envrcorflake.nix. The cache lives in.direnv/(gitignored).nix flake lock: bad optional access— usually a Nix-evaluation error masquerading as a C++ exception. Runnix flake lock --show-traceto see the real cause.nodePackages has been removed— current nixpkgs moved most node tools to top-level (pkgs.typescript-language-serveretc.). The flake already uses the new names.package-lock.jsonshows a different version thanpackage.json— the lockfiles in both upstream repos are out of sync with the bumpedpackage.jsonversions. Runningnpm installregenerates them; commit or discard as you see fit.npm approve-scripts— npm 11+ blocks dependency install scripts until you approve them. Runnpm approve-scripts --all(writes anallowScriptsmap intopackage.json).- Playwright tries to download browsers — the dev shell sets
PLAYWRIGHT_BROWSERS_PATH; if it is unset, direnv hasn't loaded. Re-rundirenv allowand verify withecho $PLAYWRIGHT_BROWSERS_PATH.