From 8632a27f781b2db651cae2635159e020196ac8ff Mon Sep 17 00:00:00 2001 From: prism Date: Fri, 15 May 2026 20:19:13 +0200 Subject: [PATCH] add some cool stuff (#1086) --- .github/workflows/autofix.yml | 33 ++++ .rules | 345 ++++++++++++++++++---------------- package.json | 5 +- scripts/lintIntl.mjs | 54 ++++++ scripts/lintPatches.mjs | 70 +++++++ 5 files changed, 346 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/autofix.yml create mode 100644 scripts/lintIntl.mjs create mode 100644 scripts/lintPatches.mjs diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml new file mode 100644 index 000000000..d4f9ddb09 --- /dev/null +++ b/.github/workflows/autofix.yml @@ -0,0 +1,33 @@ +name: autofix.ci + +on: + pull_request: + +permissions: + contents: read + +jobs: + autofix: + name: autofix + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - name: Use Node.js 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run lint fix + run: pnpm lint:fix + + - name: Run stylelint fix + run: pnpm lint-styles:fix + + - uses: autofix-ci/action@v1 diff --git a/.rules b/.rules index f5d7fa075..d29198c0f 100644 --- a/.rules +++ b/.rules @@ -1,213 +1,238 @@ -# Equicord Development Guidelines +# Equicord Development Rules -## Critical Rules +## Philosophy -- **NEVER** edit existing license headers. New files use 2026. -- Push back when the user idea is wrong or not correct. -- **NEVER** git checkout files without explicit permission. -- Always verify components/functions arent deprecated before using. -- **NEVER** use `any` for Discord objects. Import proper types from `@vencord/discord-types`. -- Fix with smallest possible change. No refactoring unless explicitly asked. -- Solve the problem at hand only. No patterns for patterns sake. -- **NEVER** overengineer. No premature abstractions, no helpers for one-time operations, no building for hypothetical futures. Three similar lines is better than a premature abstraction. -- Write modern, robust, professional code. Use current language features, proper error handling, and clean structure. -- Write natural human text in errors, descriptions, messages. No dashes or robotic formatting. Write like "Module not found" not "Module - not found". Keep it simple and conversational. - -## Code Rules - -- **DELETE** dead code, dont comment it. Git preserves history. -- No hardcoded values, unused imports, magic numbers. -- Import `classNameFactory` from `@utils/css` (NOT `@api/Styles` which is deprecated). **ALWAYS** use `cl()` for class names, never hardcode strings like `"vc-plugin-name-class"`. -- When combining multiple class names use `classes()` from `@utils/misc`, not template strings like `` `${a} ${b}` ``. -- Use utilities from `@utils/`, `@api/`, `@components/`. -- Use `Logger` from `@utils/Logger`, not `console.log`. -- Descriptions: capital first letter, end with period. -- Less code with same functionality is **ALWAYS** better. If it can be done in fewer lines without losing clarity, do it. -- Keep logic flat. Avoid deep nesting, prefer early returns, guard clauses, and simple control flow. -- **NEVER** add comments unless explicitly asked. -- Keep existing comments, they contain important context. -- Use `Map`/`Set`, `.some`/`.find`, no spread in loops, `Promise.all`. -- KISS over clever, flat over nested, explicit over implicit. +- Solve the problem at hand. No refactors unless asked. +- Smallest diff that fixes it. Three similar lines beats a premature helper. +- Less code with the same behavior wins. +- Never add comments unless asked. Keep existing comments, they carry context. +- Delete dead code. Don't comment it out. +- Natural human text in errors, descriptions, toasts. No dashes, no robotic phrasing. Descriptions start capitalized and end with a period. ## TypeScript -- **Prefer:** optional chaining (`?.`), nullish coalescing (`??`), `const`, arrow functions -- **Use:** destructuring, template literals, object shorthand, array methods -- **Style:** early returns, trust inference, inline single-use variables +- `strict: true`, `noImplicitAny: false`, `noUncheckedIndexedAccess: false`. Annotate params explicitly. Array index access is not auto `undefined`. +- Never `any`. Use `unknown` and narrow, or type webpack results via the `find*` generic. +- Never `as unknown as`. Write a type guard or use `satisfies`. +- Never `@ts-ignore`. Use `@ts-expect-error` with a one line reason only when unavoidable. +- Never `!` non null assertion. Narrow with `if` or early return. +- Never plain `enum`. Use `const enum` for internal closed sets. Use `as const` object plus literal union for cross boundary values. +- Never `namespace`. Modules only. +- Never relative `../../` when a `@alias` exists. +- Don't optional chain through values guaranteed to exist (`settings.store`, plugin proxies, find proxies). Optional chaining is for actually optional values. +- `import type` for type only imports. -## React +## Plugin Policy -- Wrap complex components with `ErrorBoundary.wrap(Component, { noop: true })`. -- Return `null` for conditional rendering, not `undefined`. -- **Forbidden:** `React.cloneElement`, `React.isValidElement`, `React.memo()`, `React.lazy`, `React.Children` -- Always return cleanup functions in `useEffect`. +What does NOT get merged: -## Forbidden +- Selfbot, automation of user actions, client state spoofing, anything abusing the API. +- Plugins requiring user supplied API keys or untrusted third party endpoints. +- Plugins targeting specific third party bots. Official Discord apps are fine. +- CSS only plugins that just hide or restyle UI. +- Functionality already trivial via built in plugins or Discord features. +- New dependencies without strong justification. -### Code +## Plugin Structure -- **No raw DOM manipulation.** No `document.querySelector`, no `MutationObserver`, no `element.style`. Always use webpack patches and React. -- Empty catch blocks. -- Hardcoded minified vars in patches (`e,t,n`). -- **NEVER** use `settings.use()` with arrays. Mutate then reassign. +- Folder per plugin. `src/plugins//index.tsx` or `src/equicordplugins//index.tsx`. Single file plugins still get a folder. +- Folder suffixes the build system filters on: `.desktop`, `.web`, `.dev`, `.discordDesktop`, `.vesktop`, `.equibop`. +- `export default definePlugin({ ... })`. Never named. +- Required fields: `name`, `description`, `authors`. Use `Devs` or `EquicordDevs`, never inline objects. +- List `dependencies` for every API plugin used. +- Prefer the folder suffix to lock environment. The `target?: "WEB" | "DESKTOP" | "BOTH"` field exists but is rarely used. Runtime `IS_DISCORD_DESKTOP`, `IS_WEB`, `IS_VESKTOP` (build time replaced) for partial gating inside a mixed plugin. +- `requiresRestart` defaults to true when `patches` exist. Set it only to override. -### Plugins +### Declarative over imperative -- **No selfbot or API abuse.** Nothing that automates user actions, spoofs client state, or abuses Discord's API in any way. -- No plugins that only do something trivially achievable with existing built-in plugins or Discord features. -- No CSS-only plugins that only hide or redesign UI elements. -- No plugins targeting specific third-party bots (official Discord apps are fine). -- No untrusted third-party APIs. No plugins requiring users to supply their own API keys. -- Do not introduce new dependencies unless strictly necessary and well justified. +Use these plugin object fields instead of calling `add*` or `remove*` manually. `PluginManager` handles lifecycle and auto enables the matching API plugin. -## Anti-Patterns +`commands`, `contextMenus`, `flux`, `onBeforeMessageSend`, `onBeforeMessageEdit`, `onMessageClick`, `chatBarButton`, `chatBarButtonWrapper`, `messagePopoverButton`, `renderMessageAccessory`, `renderMessageDecoration`, `renderMemberListDecorator`, `renderNicknameIcon`, `renderProfileCollection`, `renderProfileSection`, `headerBarButton`, `userAreaButton`, `userProfileBadge`, `managedStyle`, `toolboxActions`, `audioProcessor`. -- `value !== null && value !== undefined` ⟹ `value != null` -- `array && array.length > 0` ⟹ `array.length` (only when array is guaranteed to exist) -- `settings?.store?.value` ⟹ `settings.store.value` -- `value || defaultValue` ⟹ `value ?? defaultValue` +Wrapper style fields take `{ render | wrapper, priority }`, never a bare function. + +Imperative `add*` and `remove*` is only valid when registering multiple items of the same kind, registering conditionally, or registering after `start()`. Always pair with removal in `stop()`. Never mix the two for the same item. + +### Lifecycle + +- `start()` runs at `StartAt.WebpackReady` by default. +- `stop()` must reverse everything `start()` did. +- Never throw from `start` or `stop`. +- Plugin methods called via `$self` must be on the plugin object. Closures over module locals aren't callable. +- Never `Vencord.Plugins.plugins["Name"]`. Import the plugin directly and use `isPluginEnabled(name)` from `@api/PluginManager`. + +## Settings + +- `definePluginSettings({...})` from `@api/Settings`. Assign to `settings`. +- Every non SELECT setting needs `default`. SELECT puts `default: true` on one option. +- `restartNeeded: true` only when the value feeds a patch replacement or eager load decision. +- `hidden` and `disabled` can be functions for conditional UI. Never delete keys to "remove" them. Write a migration. +- `withPrivateSettings()` for internal state. Never widen the global `Settings` interface. +- Renames go at module top before `definePlugin`. Use `migratePluginSettings("New", "Old", ...)` and `migratePluginSetting(...)`. + +### Reading + +- React: `settings.use([...specificKeys])`. Hoist the keys array to module scope or memoize it. A fresh array literal resubscribes every render. +- Never `settings.use()` with no argument inside list rendered components. +- Hot paths: destructure `settings.store.x` once. Never re read in loops or render rows. +- `OptionType.COMPONENT` components must be module level. Use the `setValue` and `setError` props. Never write `settings.store.x` directly inside them. + +## Flux + +- Plugin lifetime subscriptions: `flux: { ACTION_TYPE(payload) { ... } }` on the plugin object. +- Component scoped or one shot: `FluxDispatcher.subscribe` inside `useEffect`, paired with `unsubscribe(sameRef)` in cleanup. +- Never declare both `flux` and manual `subscribe` for the same event in one plugin. ## Patching -### Core Principles +- One patch per concern. Minimum code touched. +- `find` is a string when possible. Anchor on stable tokens: intl keys (`#{intl::KEY}`), method names with trailing punctuation, action type literals, distinct CSS or path literals. +- Prefer intl key anchors over English copy. +- `match` is always a RegExp. Use `\i` for every minified identifier. Never letters or named patterns. +- Bound every gap with `.{0,N}?`. Never `.+?` or `.*?` unbounded. Cap at the smallest N that works. +- Use lookbehind and lookahead to inject without re consuming context. +- Capture only what you reuse. Convert reused only groups to `(?:...)` or move to lookarounds. +- `#{intl::KEY}` in match. The build hashes it. Never hardcode hashed forms like `.aA4Vce`. +- `/g` only when intentionally hitting multiple sites in one module. +- Replace: `$&` keeps the match, `$1` and `$2` for captures. Call into plugin code via `$self.fn(...)`. +- Never inject a statement where Discord expects an expression. +- `group: true` when multiple replacements in one patch must land together. +- Scope conditional patches with `predicate`, `fromBuild`, `toBuild`. Never branch inside `replace`. +- `all: true` for intl patches that may match across multiple bundles (English fallback plus user locale). +- Never `try/catch` to silence a failing replacement. Fix the match. +- Never depend on argument names or object key order. Use `arguments[i]`. +- Prefer multiple small patches with tight `find`s over one patch with a broad find and many replacements. -- Minimum code touched. Stability over cleverness. One patch per concern. -- Try multiple approaches, keep simplifying until you find the cleanest solution. -- Remove any regex character that doesnt change the match result. -- Not overengineered, not overlooked, just clean and future-proof. - -### Find Strings - -- Use `#{intl::KEY}` in find when possible, most stable anchor. -- If find matches multiple modules, add nearby stable string like `"),icon:"` or function name. -- `"#{intl::PIN_MESSAGE}),icon:"` is better than generic strings. - -### Match Patterns - -- Match only what you need to replace, let find do the targeting. -- Can use `#{intl::KEY}` in match too, gets canonicalized to hash. -- `/#{intl::KEY}\)/` beats `/label:\i\.pinned\?.{0,60}#{intl::KEY}\)/` -- `$&` keeps original, append/prepend to it. -- Use bounded gaps: `.{0,50}` not `.+?` or `.*?` -- Only use capture groups if reusing in replace. - -### NEVER Do - -- **Hardcoded minified vars:** `e,t,n,r,i,o,s,l,c,u,$_,xx,eD,eH,eW` — use `\i` instead -- **Minified chains:** `\i\.\i` alone — surround with stable strings -- **Unbounded gaps:** `.+?` or `.*?` — use `.{0,N}` -- **Generic patterns:** `/className:\i/` alone — add anchor -- **Raw intl hashes:** `.aA4Vce` — use `#{intl::KEY_NAME}` - -### Patch Examples - -**Clean replacement:** +### Examples ```js +// clean replacement find: "#{intl::PIN_MESSAGE}),icon:" match: /#{intl::PIN_MESSAGE}\)/ replace: "$self.getPinLabel(arguments[0]))" -``` -**Appending:** - -```js +// appending find: "#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}" match: /#{intl::VIEW_AS_ROLES_MENTIONS_WARNING}.{0,100}(?=])/ replace: "$&,$self.renderTooltip(arguments[0].guild)" -``` -**Wrapping:** - -```js +// wrapping with lookbehind find: "#{intl::SEVERAL_USERS_TYPING_STRONG}" match: /(?<="aria-atomic":!0,children:)\i/ replace: "$self.renderTypingUsers({ children: $& })" ``` -## Plugin Interop +## Webpack access -- `isPluginEnabled(name)` from `@api/PluginManager` takes a string, checks required/isDependency/enabled. -- Import the plugin directly from its path to access `.name` and functions. -- Dont use `Vencord.Plugins.plugins` or `plugin.started`. Dont use `"as unknown as"` casting. +- Top level: lazy finders only. `findByPropsLazy`, `findByCodeLazy`, `findComponentByCodeLazy`, `findStoreLazy`, `findCssClassesLazy`, `getUserSettingLazy`, `proxyLazyWebpack`. +- Direct `find(...)` only inside `start()` or later runtime callbacks. +- Use `findComponentByCode` or `waitForComponent` for Discord components, not `findByProps`. Survives bundle reshuffles. +- `proxyLazy` for values needing same tick destructuring. `makeLazy` for plain deferred values. +- `@webpack/common` exports backed by `waitFor` (stores, `FluxDispatcher`, `Parser`, `Alerts`, hooks) are `undefined` until webpack is ready. Reference inside functions, not at module top. +- No `eagerPatches` or `wreq(id)` brute forcing. -```ts -import { isPluginEnabled } from "@api/PluginManager"; -import otherPlugin from "@equicordplugins/otherPlugin"; -if (!isPluginEnabled(otherPlugin.name)) return null; -otherPlugin.someFunction(); -``` +## React + +- Import hooks and `React` from `@webpack/common`. Never `import React from "react"`. Only `import type` from `"react"` (`ReactNode`, `ComponentType`, etc.). +- Forbidden: `React.FC`, `React.memo`, `React.lazy`, `React.Children`, `React.cloneElement`, `React.isValidElement`. Type props with a named `interface`. +- Conditional rendering returns `null`, never `undefined` or `false`. +- Wrap every component injected by a patch with `ErrorBoundary.wrap(C, { noop: true })`. Same for components exported as plugin surface. +- `LazyComponent(() => findByCode(...))` for webpack sourced components used before webpack is ready. +- `useStateFromStores([Store1, ...], () => derive(), deps?, areEqual?)`. List every store the selector reads. Pass `areEqual` when the selector returns objects or arrays. Selector must be pure and synchronous. + +## CSS / Classes + +- `classNameFactory` from `@utils/css`. Never from `@api/Styles`. That re export is deprecated, kept for Vesktop backcompat. +- Prefix is `vc--`. Usage: `cl("base", ["a", "b"], { active: cond })`. +- Combine classes with `classes(a, b)` from `@utils/misc`. Never template strings, never `+`. +- Discord mangled classes via `findCssClassesLazy("propA", ...)`. Never hardcode mangled suffixes. +- Static stylesheet: `import "./style.css"`. Runtime toggleable: `import name from "./style.css?managed"` plus `enableStyle`, `disableStyle`, `toggleStyle`, `setStyleClassNames` from `@api/Styles`. +- CSS variable selectors in managed CSS use `[--varName]`. The build rewrites these via the `setStyleClassNames` map. Never hardcode mangled selectors in CSS. +- Every selector in plugin CSS starts with `.vc--`. No bare element selectors, no `:root`, no `@import`. +- Prefer Discord CSS vars like `var(--background-primary)` over literal colors. +- Inline `style={{}}` only for dynamic per render values. +- No `!important` except to override a Discord rule unbeatable by specificity. + +## Native / Desktop + +- Desktop only plugin: folder suffix `.desktop`. Mixed target plugins with a `native.ts` do not use the suffix. +- `native.ts` next to `index.tsx`. Exports auto register as IPC handlers via `~pluginNatives`. +- Handler first arg is `IpcMainInvokeEvent`. Renderer side: + ```ts + const Native = VencordNative.pluginHelpers.MyPlugin as PluginNative; + ``` +- `pluginHelpers` key matches `definePlugin.name`. +- Renderer must never import `fs`, `path`, `net`, `https`, `child_process`, `electron`. Go through `native.ts` and validate input there. +- File pickers: `saveFile` and `chooseFile` from `@utils/web`. Cross platform, no IPC needed. +- Only `src/preload.ts` may `contextBridge.exposeInMainWorld`. + +## Never hardcode + +- CDN URLs (`cdn.discordapp.com/...`) → `IconUtils`. +- API paths (`/api/v9`, `/users/@me`) → `Constants.Endpoints` or `RestAPI`. +- Intl strings → `#{intl::KEY}`. +- Mangled CSS classes → `findCssClassesLazy`. +- Minified vars in patches (`e,t,n,r,i,o,s,...`) → `\i`. +- `console.log`, `warn`, `error` → `new Logger("name")` from `@utils/Logger`. +- Toast type strings → `Toasts.Type.*`. +- Class name concatenation → `classes(...)`. + +## Forbidden + +- Raw DOM. No `document.querySelector`, `MutationObserver`, `element.style`. Use webpack patches and React. +- Empty `catch` blocks. +- `settings.use()` with arrays mutated in place. Reassign with a new array. ## Reference -### Types +### Types from `@vencord/discord-types` +`Channel`, `Guild`, `GuildMember`, `User`, `Role`, `Message`, `Command`, `FluxEvents`, store types. -`Channel`, `Guild`, `GuildMember`, `User`, `Role`, `Message` from `@vencord/discord-types` +### `@api/*` +`Settings`, `Commands`, `DataStore`, `ContextMenu`, `Badges`, `Notices`, `Notifications`, `MessageEvents`, `MessageUpdater`, `MessageAccessories`, `MessageDecorations`, `MessagePopover`, `MemberListDecorators`, `ServerList`, `ChatButtons`, `HeaderBar`, `UserArea`, `NicknameIcons`, `ProfileCollections`, `ProfileSections`, `AudioPlayer`, `UserSettings`, `Styles`, `PluginManager`. -### Components +Deprecated: `@api/Themes`, and the `classNameFactory` re export from `@api/Styles`. -`Paragraph`, `BaseText`, `Flex`, `Button`, `ErrorCard` from `@components/` +### `@utils/*` +- `Logger`. `new Logger(name)`. +- `css`. `classNameFactory`, `classNameToSelector`. +- `misc`. `classes`, `sleep`, `isObject`, `isObjectEmpty`, `pluralise`, `parseUrl`, `identity`, `interpolateIfDefined`, `tryOrElse`, `isPluginDev`, `removeFromArray`. +- `text`. `formatDuration`, `formatDurationMs`, `humanFriendlyJoin`, `makeCodeblock`, `toInlineCode`, `escapeRegExp`, `stripIndent`, `ZWSP`. +- `react`. `useAwaiter`, `useForceUpdater`, `useIntersection`, `useTimer`, `useFixedTimer`, `useCleanupEffect`, `NoopComponent`. +- `lazy`. `makeLazy`, `proxyLazy`. +- `lazyReact`. `LazyComponent`. +- `modal`. `openModal`, `openModalLazy`, `closeModal`, `closeAllModals`, `openMediaModal`, `ModalRoot`, `ModalHeader`, `ModalContent`, `ModalFooter`, `ModalCloseButton`, `ModalSize`. +- `discord`. `getCurrentChannel`, `getCurrentGuild`, `getIntlMessage`, `openUserProfile`, `openPrivateChannel`, `sendMessage`, `insertTextIntoChatInputBox`, `copyWithToast`, `getUniqueUsername`, `fetchUserProfile`, `openImageModal`, `openInviteModal`, `getTheme`, `Theme`, `hasGuildFeature`. +- `clipboard`. `copyToClipboard`, `readClipboard`. +- `margins`. `Margins`. +- `guards`. `isTruthy`, `isNonNullish`. +- `web`. `saveFile`, `chooseFile`. +- `constants`. `Devs`, `EquicordDevs`, `IS_WINDOWS`, `IS_MAC`, `IS_LINUX`, `IS_MOBILE`. +- `types`. `OptionType`, `StartAt`, `ReporterTestable`, `definePlugin`. -### Settings +### `@components/*` +`Paragraph`, `BaseText`, `Flex`, `Button`, `ErrorCard`, `ErrorBoundary`. -Use `definePluginSettings` from `@api/Settings`, not inline settings object. +### `@webpack/common` -### Utilities +Stores. `UserStore`, `GuildStore`, `GuildRoleStore`, `GuildMemberStore`, `GuildChannelStore`, `ChannelStore`, `SelectedChannelStore`, `SelectedGuildStore`, `MessageStore`, `PresenceStore`, `RelationshipStore`, `PermissionStore`, `ReadStateStore`, `EmojiStore`, `StickersStore`, `ThemeStore`, `VoiceStateStore`, `TypingStore`, `DraftStore`, `WindowStore`, `AccessibilityStore`, `PendingReplyStore`, `UserProfileStore`, `StreamerModeStore`, `SpotifyStore`, `NotificationSettingsStore`, `LocaleStore`, `ExperimentStore`, `QuestStore`. Full list in `src/webpack/common/stores.ts`. -- **@utils/clipboard** — `copyToClipboard` -- **@utils/discord** — `insertTextIntoChatInputBox`, `getCurrentChannel`, `getCurrentGuild`, `getIntlMessage`, `openUserProfile`, `openPrivateChannel`, `sendMessage`, `copyWithToast`, `getUniqueUsername`, `fetchUserProfile` -- **@utils/css** — `classNameFactory`, `classNameToSelector` -- **@utils/misc** — `classes`, `sleep`, `isObject`, `isObjectEmpty`, `pluralise`, `parseUrl`, `identity` -- **@utils/text** — `formatDuration`, `formatDurationMs`, `humanFriendlyJoin`, `makeCodeblock`, `toInlineCode`, `escapeRegExp` -- **@utils/modal** — `openModal`, `closeModal`, `ModalRoot`, `ModalHeader`, `ModalContent`, `ModalCloseButton` -- **@utils/margins** — `Margins` (`Margins.top8`, `Margins.bottom16`, etc.) -- **@utils/guards** — `isTruthy`, `isNonNullish` -- **@utils/web** — `saveFile`, `chooseFile` +React and hooks. `React`, `useState`, `useEffect`, `useLayoutEffect`, `useMemo`, `useRef`, `useReducer`, `useCallback`, `useStateFromStores`. -### Webpack Common +Components. `Tooltip`, `TextInput`, `TextArea`, `Select`, `SearchableSelect`, `Slider`, `Checkbox`, `Avatar`, `Menu`, `Popout`, `Dialog`, `TabBar`, `Clickable`, `MaskedLink`, `Timestamp`, `FocusLock`, `UserSummaryItem`, `ColorPicker`, `OAuth2AuthorizeModal`, `ScrollerThin`, `ScrollerAuto`, `ScrollerNone`, `ListScrollerThin`, `ListScrollerAuto`. -#### IconUtils +Utils. `FluxDispatcher`, `RestAPI`, `Constants`, `SnowflakeUtils`, `Parser`, `PermissionsBits`, `Alerts`, `Toasts`, `showToast`, `moment`, `lodash`, `i18n`, `IconUtils`, `ColorUtils`, `ImageUtils`, `DateUtils`, `UsernameUtils`, `DisplayProfileUtils`, `URLUtils`, `Humanize`, `EmojiUtils`, `UserUtils`, `UploadHandler`, `UploadManager`, `ComponentDispatch`, `ExpressionPickerStore`, `ReadStateUtils`, `PopoutActions`, `ApplicationAssetUtils`. -From `@webpack/common`. **NEVER** hardcode `cdn.discordapp.com` URLs. +Routers. `NavigationRouter`, `ChannelRouter`, `SettingsRouter`. -- `IconUtils.getUserAvatarURL(user, canAnimate?, size?)` -- `IconUtils.getDefaultAvatarURL(id)` -- `IconUtils.getUserBannerURL({ id, banner, canAnimate?, size })` -- `IconUtils.getGuildIconURL({ id, icon, size?, canAnimate? })` -- `IconUtils.getGuildBannerURL(guild, canAnimate?)` -- `IconUtils.getChannelIconURL({ id, icon })` -- `IconUtils.getEmojiURL({ id, animated, size })` -- `IconUtils.getApplicationIconURL(data)` -- `IconUtils.getGameAssetURL(data)` +Action creators. `MessageActions`, `ChannelActionCreators`, `UserProfileActions`, `InviteActions`, `GuildActions`, `VoiceActions`, `DraftActions`, `PinActions`, `MessageCache`. -#### Stores +Menu. `Menu`, `ContextMenuApi` (`openContextMenu`, `closeContextMenu`, `openContextMenuLazy`). -`UserStore`, `GuildStore`, `ChannelStore`, `GuildMemberStore`, `SelectedChannelStore`, `SelectedGuildStore`, `PresenceStore`, `RelationshipStore`, `MessageStore`, `EmojiStore`, `ThemeStore`, `PermissionStore`, `VoiceStateStore` from `@webpack/common` +### Lazy webpack imports +From `@webpack`. `findByPropsLazy`, `findByCodeLazy`, `findComponentLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy`, `findStoreLazy`, `findCssClassesLazy`, `findLazy`, `proxyLazyWebpack`, `LazyComponentWebpack`, `mapMangledModuleLazy`, `extractAndLoadChunksLazy`, `waitFor`. -#### Actions +`waitForStore` and `waitForComponent` live in `@webpack/common/internal.tsx`. They're used by `@webpack/common` itself, not directly by plugins. -`RestAPI`, `FluxDispatcher`, `MessageActions`, `NavigationRouter`, `ChannelRouter`, `ChannelActionCreators`, `SettingsRouter` from `@webpack/common` - -#### Utils - -`Constants` (`Constants.Endpoints`), `SnowflakeUtils`, `Parser`, `PermissionsBits`, `moment`, `lodash`, `ColorUtils`, `ImageUtils`, `DateUtils`, `UsernameUtils`, `DisplayProfileUtils`, `URLUtils`, `Humanize`, `EmojiUtils` from `@webpack/common` - -#### Components - -`Tooltip`, `TextInput`, `TextArea`, `Select`, `Slider`, `Avatar`, `Menu`, `Popout`, `ScrollerThin`, `Timestamp` from `@webpack/common` - -#### Toasts - -`Toasts`, `showToast` from `@webpack/common` - -### Imports - -- **Lazy** from `@webpack` — `findByPropsLazy`, `findStoreLazy`, `findComponentByCodeLazy`, `findExportedComponentLazy` -- **Common** from `@webpack/common` — `useState`, `useEffect`, `useCallback`, `useStateFromStores` - -### Never Hardcode - -- `"cdn.discordapp.com/avatars/"` etc. ⟹ `IconUtils` -- `"/api/v9"`, `"/users/@me"` ⟹ `Constants.Endpoints` or `RestAPI` -- `console.log/warn/error` ⟹ `Logger` from `@utils/Logger` -- `` `${a} ${b}` `` for class names ⟹ `classes(a, b)` from `@utils/misc` +### `IconUtils` methods +`getUserAvatarURL(user, canAnimate?, size?, format?)`, `getUserBannerURL({ id, banner, canAnimate?, size })`, `getGuildIconURL({ id, icon, size?, canAnimate? })`, `getGuildBannerURL(guild, canAnimate?)`, `getGuildSplashURL`, `getGuildDiscoverySplashURL`, `getGuildHomeHeaderURL`, `getChannelIconURL({ id, icon })`, `getResourceChannelIconURL`, `getEmojiURL({ id, animated, size })`, `getApplicationIconURL`, `getGameAssetURL`, `getVideoFilterAssetURL`, `getAvatarDecorationURL`, `getGuildMemberAvatarURL`, `getGuildMemberAvatarURLSimple`, `getGuildMemberBannerURL`, `getDefaultAvatarURL(id)`, `hasAnimatedGuildIcon`, `isAnimatedIconHash`. diff --git a/package.json b/package.json index 579fa0e79..23d9f6f59 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,11 @@ "repair": "node scripts/runInstaller.mjs -- --repair", "lint": "eslint", "lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins", + "lint-styles:fix": "pnpm lint-styles --fix", "lint:fix": "pnpm lint --fix", - "test": "pnpm buildStandalone && pnpm testTsc && pnpm lint:fix && pnpm lint-styles && pnpm generatePluginJson", + "lint:intl": "node scripts/lintIntl.mjs", + "lint:patches": "node scripts/lintPatches.mjs", + "test": "pnpm buildStandalone && pnpm testTsc && pnpm lint:fix && pnpm lint-styles && pnpm lint:intl && pnpm lint:patches && pnpm generatePluginJson", "testWeb": "pnpm lint:fix && pnpm buildWeb && pnpm testTsc", "testTsc": "tsc --noEmit", "depCheck": "node scripts/depCheck.mjs" diff --git a/scripts/lintIntl.mjs b/scripts/lintIntl.mjs new file mode 100644 index 000000000..c85812aa9 --- /dev/null +++ b/scripts/lintIntl.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); +const VALID_MODIFIERS = new Set(["raw", "hash"]); +const MARKER_RE = /#\{intl::([\w$+/]*)(?:::(\w+))?\}/g; +const HASH_RE = /["'`]\s*\.([A-Za-z][A-Za-z0-9+/]{5})\b/g; + +const tracked = execFileSync("git", ["ls-files", "src"], { cwd: ROOT, encoding: "utf8" }) + .split("\n") + .filter(p => /\.(ts|tsx|js|jsx|mjs)$/.test(p)) + .map(p => p.replace(/\//g, sep)); + +let errors = 0; +let warnings = 0; + +for (const rel of tracked) { + const text = readFileSync(join(ROOT, rel), "utf8"); + if (!text.includes("#{intl::") && !text.includes("find:") && !text.includes("match:")) continue; + + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const at = `${rel.replace(/\\/g, "/")}:${i + 1}`; + + if (/#\{intl::[^}]*$/.test(line)) { + console.error(`${at}: ERROR unclosed #{intl:: marker`); + errors++; + } + for (const [full, key, modifier] of line.matchAll(MARKER_RE)) { + if (!key) { + console.error(`${at}: ERROR empty intl key: ${full}`); + errors++; + } + if (modifier && !VALID_MODIFIERS.has(modifier)) { + console.error(`${at}: ERROR unknown intl modifier "${modifier}": ${full}`); + errors++; + } + } + if (/\b(find|match)\s*:/.test(line) && !line.includes("#{intl::")) { + for (const [, hash] of line.matchAll(HASH_RE)) { + if (!/[0-9+/]/.test(hash)) continue; + console.warn(`${at}: WARN baked intl hash ".${hash}", prefer #{intl::KEY}`); + warnings++; + } + } + } +} + +console.log(`\nintl lint: ${errors} error(s), ${warnings} warning(s)`); +process.exit(errors > 0 ? 1 : 0); diff --git a/scripts/lintPatches.mjs b/scripts/lintPatches.mjs new file mode 100644 index 000000000..33a08483c --- /dev/null +++ b/scripts/lintPatches.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); +const VERBOSE = process.env.LINT_PATCHES_VERBOSE === "1" || process.argv.includes("--verbose"); + +const tracked = execFileSync("git", ["ls-files", "src"], { cwd: ROOT, encoding: "utf8" }) + .split("\n") + .filter(p => /^src\/(plugins|equicordplugins)\/.*\.(ts|tsx)$/.test(p)) + .map(p => p.replace(/\//g, sep)); + +let errors = 0; +let warnings = 0; + +function fail(at, rule, msg) { + console.error(`${at}: ERROR ${rule} ${msg}`); + errors++; +} +function warn(at, rule, msg) { + if (VERBOSE) console.warn(`${at}: WARN ${rule} ${msg}`); + warnings++; +} + +for (const rel of tracked) { + const text = readFileSync(join(ROOT, rel), "utf8"); + if (!text.includes("match:") && !text.includes("find:")) continue; + + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const at = `${rel.replace(/\\/g, "/")}:${i + 1}`; + + const matchM = line.match(/\bmatch\s*:\s*\/((?:\\.|[^/\\\n])+)\/[gimsuy]*/); + if (matchM) { + const src = matchM[1]; + const unbounded = src.match(/\.[+*]\??/); + if (unbounded) warn(at, "P002", `unbounded ${unbounded[0]} in match`); + const minified = src.match(/\b[a-z]\.[a-z]\b/); + if (minified) fail(at, "P001", `hardcoded minified var "${minified[0]}", use \\i.\\i`); + } + + const findM = line.match(/\bfind\s*:\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\/(?:\\.|[^/\\\n])+\/[gimsuy]*)/); + if (findM) { + const window = lines.slice(i, i + 12).join("\n"); + if (/\b(replacement|match)\s*:/.test(window)) { + const v = findM[1]; + if (v === '""' || v === "''" || v === "``") { + fail(at, "P003", "find is empty"); + } else if (v.startsWith("/")) { + const src = v.slice(1, v.lastIndexOf("/")); + if (/\\i/.test(src) && !/[A-Za-z]{3,}/.test(src) && !src.includes("#{intl::")) { + warn(at, "P004", "find regex has no string anchor"); + } + } + } + } + + const replaceM = line.match(/\breplace\s*:\s*(["'`])((?:\\.|(?!\1).)*)\1/); + if (replaceM && /\btry\s*\{[\s\S]*?\}\s*catch\b/.test(replaceM[2])) { + warn(at, "P005", "replace uses try/catch, fix the match instead"); + } + } +} + +const tail = warnings && !VERBOSE ? " (run with --verbose to list)" : ""; +console.log(`\npatch lint: ${errors} error(s), ${warnings} warning(s)${tail}`); +process.exit(errors > 0 ? 1 : 0);