Monorepo of plugins built with the Unoff framework, organized by design tool platform.
unoff-plugins/
├── penpot/
│ ├── token-lint/
│ └── calendar-and-schedule-generator/
├── figma/ (ready, empty until the first plugin lands)
├── framer/ (ready, empty)
├── sketch/ (ready, empty)
├── scripts/ build.mjs, changelog.mjs — the tooling behind the commands below
└── dist/ shared build output: dist/<platform>/<name>/ (gitignored)
Each plugin is a normal npm workspace: its own package.json, its own build
scripts, its own README.md with plugin-specific setup. This root README only
covers what's common across all of them.
| Plugin | Platform | Status |
|---|---|---|
| Token Lint | Penpot | 🧪 Testing |
| Calendar and Schedule Generator | Penpot | 🚧 Work in progress |
This monorepo is specific to this project. Unoff as a framework scaffolds
one plugin per its own standalone repo, each with its own independent release
lifecycle — that's still the default @unoff/cli produces. Bundling several
plugins into one repo with a shared build/CI setup is a deliberate choice made
here, not how the framework works in general.
npm install # installs every plugin's dependencies from the rootEach plugin also needs its own .env.local and .env.sentry-build-plugin for
local development — see that plugin's own README for which keys it expects.
Three contexts, each a pure pass-through to what a plugin's own
package.json already defines — nothing root-level reimplements a plugin's
build or dev logic:
npm run start -- <plugin>— build (dev mode, watching) and serve, together. Delegates entirely to that plugin's ownstart:dev(build watch- its own
vite previewserver on:4400) — identical to running it from insidepenpot/<plugin>/directly. Takes exactly one plugin: its preview server owns a fixed port, so more than one at once would collide.
- its own
npm run dev [-- target]— build only, dev mode (watches for changes), no server. Runs that plugin's ownbuildscript.npm run build [-- target]— build only, production mode. Runs that plugin's ownbuild:prodscript.
dev and build take an optional target after -- — a flag choosing which
plugin(s) to apply to. Pass one, or omit it entirely:
- omitted → every plugin, every platform
- a platform name (
penpot) → every plugin under that platform - a bare plugin name (
token-lint) → just that plugin, resolved automatically wherever it lives - an explicit
platform/name→ same, disambiguated (only needed if a bare name exists under more than one platform)
start only ever takes a single plugin — there's no "every plugin" form,
since it starts a server.
npm run build # production build, every plugin
npm run build -- penpot # production build, every plugin under penpot/
npm run build -- token-lint # production build, just that plugin
npm run build -- penpot/token-lint # same, explicit path
npm run dev -- token-lint # same targeting, development mode
npm run build:penpot / dev:penpot # shorthands for `-- penpot`
npm run start -- token-lint # build (dev) + serve, one pluginWhichever way a build is invoked, it lands in the same
dist/<platform>/<name>/ tree — each plugin's vite.config.ts resolves its
own outDir there — also exactly the shape CI deploys to GitHub Pages.
Each plugin's own scripts (build:prod, start:dev, typecheck, lint,
format, ...) still work unchanged from inside its own folder.
- Scaffold it under the right platform folder with
npx @unoff/cli create .... - If it's the first plugin on a brand new platform, add that platform's
glob to
workspacesin the rootpackage.json.
Nothing else — CI (build.yml, release.yml) auto-detects any plugin
folder with a package.json, and its build output lands in the shared
dist/ tree automatically.
build.ymlvalidates every plugin touched by a push or PR todev— no release, just a build.release.ymlis what actually ships a plugin. See below for the process, and Secrets and variables for how it's configured per plugin.
-
Work on a branch, adding entries to that plugin's own
CHANGELOG.mdunder[Unreleased]as you go (see Changelog), and get it merged intodev. -
From the repo root, run the release script with the plugin and the new version:
npm run release -- token-lint minor # or patch / major npm run release -- penpot/token-lint 0.5.0 # or an explicit version
The plugin is a bare name (
token-lint) orplatform/namewhen the same name exists on several platforms.patch/minor/majorare computed from the version currently onorigin/dev. The script:- checks that the working tree is clean, that the version is newer than
the one on
origin/dev, that the tag doesn't exist yet, and that[Unreleased]isn't empty; - creates
release/<plugin>-<version>fromorigin/dev(or reuses it if it already exists), e.g.release/token-lint-0.5.0; - sets the version in the plugin's
package.jsonand inpackage-lock.json; - moves
[Unreleased]to[<version>] - <date>in the plugin'sCHANGELOG.md; - commits it as
chore(release): <platform>/<plugin> v<version>.
Options:
--dry-runprints the plan and changes nothing,--pushalso pushes the branch and opens the PR intodev(needs theghCLI), and--forceallows an empty[Unreleased]. One run releases one plugin: to release several, run it once per plugin and merge each PR. - checks that the working tree is clean, that the version is newer than
the one on
-
Merge the PR into
dev(if you didn't use--push, push the branch and open it yourself; therelease/prefix is whatrelease.yml's trigger checks for).release.ymlfires, detects which plugin(s) changed in that PR, and for each one:- builds it in production mode
- takes the
[<version>]section of itsCHANGELOG.mdand creates a GitHub Release tagged<platform>-<name>-v<version>, using that section as the release notes - uploads the build as a zip release asset
- deploys the build to GitHub Pages at
/<platform>/<name>/— the static files that URL serves are now updated, without touching any other plugin already published there
The changelog is bumped in the release PR itself, so what gets reviewed is
what gets released and CI never commits back to dev.
release.yml can also be triggered manually (workflow_dispatch, same
plugin/platform/bare-name targeting as the local commands) to release
outside the branch/PR flow. If that plugin's changelog was never bumped, the
notes fall back to its [Unreleased] section.
Every plugin's own GitHub Environment (Settings → Environments →
penpot-token-lint, penpot-calendar-and-schedule-generator, ...) holds
everything that plugin's build needs — there's nothing at the repository
level. Each plugin's config is fully self-contained instead of partly
depending on shared repo state, even where two plugins currently happen to
use the same value.
Within an environment, split by kind:
- Secrets (masked in logs,
secrets.*in the workflow) — keys and tokens:VITE_SUPABASE_PUBLIC_ANON_KEY,VITE_MIXPANEL_TOKEN,VITE_NOTION_API_KEY,VITE_LEMONSQUEEZY_API_KEY,VITE_TOLGEE_API_KEY,SENTRY_AUTH_TOKEN. - Variables (plain text,
vars.*) — everything else: URLs, IDs, org/project names (VITE_SUPABASE_URL,VITE_SENTRY_DSN,VITE_AUTH_WORKER_URL,VITE_AUTH_URL,VITE_ANNOUNCEMENTS_WORKER_URL,VITE_CORS_WORKER_URL,VITE_NOTION_ANNOUNCEMENTS_ID,VITE_NOTION_ONBOARDING_ID,VITE_LEMONSQUEEZY_URL,VITE_TOLGEE_URL,SENTRY_ORG,SENTRY_PROJECT).
Add entries to a plugin's [Unreleased] section in its own CHANGELOG.md as
you work. npm run release turns that section into [<version>] - <date>
(leaving an empty [Unreleased] above it) in the release PR, and
release.yml uses the version's section as the GitHub release notes. The
root itself is just where commands are run from, not a released thing — it
has no changelog of its own.