Project Rules
Where files go, and the check behind each rule.
The layout and loading rules every plugin follows. ubd create scaffolds a plugin that already follows them, and lint rules from @unbound-app/cli/oxlint plus ubd build checks catch most departures.
| Rule | Checked by |
|---|---|
| Layout | Build check layout (warning) |
| Features Start Themselves | Addon review |
| Lazy Lookups | Lint unbound/no-module-scope-lookup |
| Platform Code | Lint unbound/platform-check-only-in-platform-file (allows src/native/ and platform.ts), build check layout |
| Scoped API | Lint unbound/no-window-unbound |
| Generated Capabilities | Build check capabilities (error) |
| Native Views | Addon review |
| Bundle Size | Build report |
Layout
Split the plugin by kind: what it patches, intercepts, draws and computes. The entry only lists features. Never nest deeper than src/<folder>/<file>, except platform-native code in src/native/ios/<file> and src/native/android/<file>. See Plugin Anatomy.
| Location | Holds |
|---|---|
src/ root | Only index.tsx, settings.tsx, actions.ts, patches.ts, commands.ts, and the generated unbound-settings.d.ts and unbound-i18n.d.ts |
src/patches/, src/commands/ | patches.ts or commands.ts once it grows, as named files such as patches/message-rows.ts |
src/native/ | hooks.ts, views.ts, worklets.ts, calls.ts, split per platform into native/android/<kind>.ts and native/ios/<kind>.ts |
src/flux/ | interceptors.ts, listeners.ts |
src/components/ | Components and their hooks |
src/lib/ | The plugin's own logic: data, network, storage, helpers. No Discord patches |
Beside src/ | settings.ts, i18n/, assets/, tests/, ADDON.md, media/ |
ubd build warns for each departure. Warnings do not fail the build.
Features Start Themselves
src/index.tsx calls each feature. The feature checks its setting, finds its own modules and makes only the patches it needs. A function's name says its kind: patchX, interceptX, listenToX, registerX, hookX or drawX.
The feature's JSDoc names every patch it makes and every Discord module or store it depends on.
import { metro, patcher } from '@unbound-app/api';
/**
* @description Logs each message the user sends.
* Patches `sendMessage` on Discord's message actions module.
*/
export function patchSendMessage() {
const Messages = metro.findByProps('sendMessage', 'receiveMessage');
if (!Messages) {
logger.warn('Greeting skipped: Discord moved sendMessage.');
return;
}
patcher.before(Messages, 'sendMessage', ({ args }) => logger.log('Sending to', args[0]));
}Lazy Lookups
Top-level code runs on every start, before start, and delays Discord at launch. Make no eager search there: no metro.find, findBy* or flux.getStore with { lazy: false }.
import { flux, metro } from '@unbound-app/api';
// Lazy: safe at the top of a file.
const Messages = metro.findByProps('sendMessage', 'receiveMessage');
const UserStore = flux.getStore('User');
// Eager: move it into the function that uses it.
const Button = metro.findByName('Button', { lazy: false });Platform Code
Platform code lives in src/native/. One small file there may branch on the platform; a larger one splits into src/native/android/<kind>.ts and src/native/ios/<kind>.ts, and definePlatform in src/native/<kind>.ts picks between them. Nowhere else checks the platform.
export function platformName() {
return 'Android';
}export function platformName() {
return 'iOS';
}import { definePlatform } from '@unbound-app/api/native';
import { platformName as android } from '~/native/android/views';
import { platformName as ios } from '~/native/ios/views';
export const platformName = definePlatform({ ios, android });ubd build reads the definePlatform keys to learn which platforms the plugin supports.
Scoped API
Import from @unbound-app/api, which reads the plugin's Addon scope. window.unbound is unscoped, so anything registered through it outlives the plugin.
Generated Capabilities
ubd build writes platforms and each platform's Capabilities into dist/manifest.json, from the native calls each handler can reach. Your manifest.json never lists them. A hand-written capabilities or platforms field fails the build, and so does dynamic access such as native.objc[name].
Native Views
Change how Discord's native views look from a worklet on the UI thread, so the change never flashes in. See UI Thread.
Bundle Size
The bundle loads on every start. Keep it under its budget, 50kb by default. Import a library the client provides, such as lodash, by name, not by file path, so the bundle carries no second copy. ubd build warns past a budget, and --strict-budget fails the build. See CLI.
All Checks
ubd build runs these after bundling. Errors fail the build; warnings print.
| Build check | Reports |
|---|---|
naming | Warnings for a folder, id, name or description that breaks the manifest naming rules |
capabilities | Errors for hand-written Capabilities or platforms, and dynamic native access |
layout | Warnings for each departure from the layout |
page | Errors and warnings for an ADDON.md that breaks the addon page rules |
ubd check runs these checks and the lint without building. See CLI.
A scaffolded plugin's .oxlintrc.json turns on the three lint rules:
{
"jsPlugins": [{ "name": "unbound", "specifier": "@unbound-app/cli/oxlint" }],
"rules": {
"unbound/no-window-unbound": "error",
"unbound/platform-check-only-in-platform-file": "error",
"unbound/no-module-scope-lookup": ["error", { "lazyByDefault": true }]
}
}