- Indentation: 2 spaces
- Quotes: Double quotes (
") for all strings - Semicolons: Always
- Trailing commas: Always in multi-line constructs
- Line width: Prettier default (80 chars)
- kebab-case for all files:
vps-card.tsx,vm-payment-flow.tsx,account-settings.tsx - Components and pages:
.tsx - Hooks and utilities without JSX:
.ts
- No enforced grouping or blank-line separation between groups
- General tendency: CSS imports first, then external libraries, then local modules
- No alphabetical ordering enforced
Use function declarations, not arrow functions:
// Correct
export default function VpsCard({ spec }: { spec: VmTemplate }) {
// Avoid
const VpsCard = ({ spec }: { spec: VmTemplate }) => {- Pages and single-export components use
export default function - Secondary exports from a file use named
export
- PascalCase, no prefix (no
Ifor interfaces, noTfor types) interfacefor object shapes,typefor unions/intersections/simpler prop types- Both are acceptable; no strict rule between them
interface ModalProps { ... }
type AsyncButtonProps = { ... }
type PaymentFlowType = "lightning" | "revolut";PascalCase name, PascalCase members, string values in lowercase:
enum DiskType {
SSD = "ssd",
HDD = "hdd",
}- Global state: Custom
ExternalStore(from@snort/shared) +useSyncExternalStore. SingletonLoginStateinsrc/login.ts. - Local state: Plain
useStatehooks - No Redux, Zustand, or other state libraries
- Nostr context:
SnortContext.Providerfrom@snort/system-reactinmain.tsx
- Use the
LNVpsApiclass fromsrc/api.ts - Authenticated calls via login hook:
login?.api.someMethod() - Unauthenticated calls:
new LNVpsApi(ApiUrl, undefined) - Data fetching in
useEffector via theuseCachedhook - No React Query, SWR, or other data-fetching libraries
useprefix, function declarations, placed insrc/hooks/- Return objects with named fields (not tuples/arrays):
export function useCached<T>(...): { data: T | undefined; loading: boolean; error: string | undefined; reloadNow: () => void }try/catch with instanceof Error check, store message in useState<string>:
try {
await login?.api.someAction();
} catch (e) {
if (e instanceof Error) {
setError(e.message);
}
}- Display errors inline:
{error && <b className="text-red-500">{error}</b>} - Non-critical errors:
console.error(e)in catch block - The API class throws
new Error(message)on non-OK responses
- Use optional chaining (
?.) and nullish coalescing (??) extensively - Early returns for missing data:
if (!state) return <h2>No VM selected</h2>;
if (!login?.api) return;- Non-null assertion (
!) used sparingly, only when guaranteed
- Tailwind CSS utility classes inline in JSX
classnameslibrary (imported asclassNames) for conditional classes- No CSS modules, no styled-components
- Theme colors: Always use
cyber-*color classes for light/dark theme support. Seesrc/index.cssfor available colors. - Only custom CSS file:
src/components/spinner.css
react-router-domv7 withcreateBrowserRouterinmain.tsx- State-based navigation (passing objects via
navigate("/path", { state })) - Access state via
useLocation()with type assertion:
const location = useLocation() as { state?: VmInstance };- Minimal commenting; no JSDoc
- Brief
//comments only when context is needed - No block comment documentation
Use ES private fields (#field, #method()) not TypeScript private keyword:
class LNVpsApi {
#url: string;
async #handleResponse<T>(rsp: Response): Promise<T> { ... }
}Long-form articles (kind:30023) are tagged with language using NIP-32 self-labels (ISO-639-1):
["L", "ISO-639-1"],
["l", "zh", "ISO-639-1"]d tag convention — addressable events require a unique kind:author:d key, so translations must use distinct d tags. Use a language suffix on the slug:
| Version | d tag |
language tag |
|---|---|---|
| English original | my-article |
none (or ["l", "en", "ISO-639-1"]) |
| Chinese translation | my-article-zh |
["l", "zh", "ISO-639-1"] |
| French translation | my-article-fr |
["l", "fr", "ISO-639-1"] |
The client groups articles by canonical slug (stripping the -<lang> suffix) using src/utils/news-locale.ts, then picks the best version for the active locale: locale → en → unlabelled → any.
useLatestNews,useNewsPost, andNewsPageall apply this filtering automatically.- English articles do not need a language tag or suffix — they are the default fallback.
-
Use
<FormattedMessage>component directly in JSX for all user-facing strings -
Never use
formatMessage({...})as JSX children or text output — use<FormattedMessage>instead:// Wrong — function call in JSX children <button>{formatMessage({ defaultMessage: "Save" })}</button> // Correct — component in JSX <button><FormattedMessage defaultMessage="Save" /></button>
-
formatMessage({...})is only acceptable for attributes that cannot accept JSX nodes (e.g.,placeholder,title,aria-label, string arguments toconfirm()) -
Auto-generated message IDs (no
idordefaultMessageindefineMessage) -
Never use
defineMessage/Messagesconstant pattern except for API error messages -
All dynamic strings in JSX should be wrapped in
<FormattedMessage> -
Translation files:
src/locales/{locale}.json(auto-generated viayarn locale:extract)