General coding standards for all code in the GROWI monorepo.
ALWAYS create new objects, NEVER mutate:
// ❌ WRONG: Mutation
user.name = name;
pages[index].title = newTitle;
// ✅ CORRECT: Immutable update
return { ...user, name };
const updatedPages = pages.map(p => (p.id === id ? { ...p, title: newTitle } : p));
MANY SMALL FILES > FEW LARGE FILES:
When a framework-specific wrapper (React hook, Express middleware, CodeMirror extension handler, etc.) contains non-trivial logic, extract the core logic as a pure function and reduce the wrapper to a thin adapter. This enables reuse across contexts and makes unit testing straightforward.
// ✅ Pure function — testable, reusable from hooks, keymaps, shortcuts, etc.
// services-internal/markdown-utils/toggle-markdown-symbol.ts
export const toggleMarkdownSymbol = (view: EditorView, prefix: string, suffix: string): void => {
// Pure logic
};
// React hook wrapper
export const useInsertMarkdownElements = (view?: EditorView) => {
return useCallback((prefix, suffix) => {
if (view == null) return;
toggleMarkdownSymbol(view, prefix, suffix);
}, [view]);
};
// Emacs command wrapper
EmacsHandler.addCommands({
markdownBold(handler: { view: EditorView }) {
toggleMarkdownSymbol(handler.view, '**', '**');
},
});
Applies to: React hooks, Express/Koa middleware, CLI command handlers, CodeMirror extension callbacks, test fixtures — any framework-specific adapter that wraps reusable logic.
Replace conditional branching on mode/variant names with declared metadata that consumers filter generically. This eliminates the need to update consumers when adding new modes.
// ❌ WRONG: Consumer knows mode-specific behavior
if (keymapModeName === 'emacs') {
return sharedKeyBindings;
}
return [formattingBindings, ...sharedKeyBindings];
// ✅ CORRECT: Module declares its overrides, consumer filters generically
// Keymap module returns: { overrides: ['formatting', 'structural'] }
const activeBindings = allGroups
.filter(group => group.category === null || !overrides?.includes(group.category))
.flatMap(group => group.bindings);
When a module produces a value that needs consumer-side configuration (precedence, feature flags, etc.), bundle the metadata alongside the value in a structured return type. This keeps decision-making inside the module that has the knowledge.
// ❌ WRONG: Consumer decides precedence based on mode name
const wrapWithPrecedence = mode === 'vim' ? Prec.high : Prec.low;
codeMirrorEditor.appendExtensions(wrapWithPrecedence(keymapExtension));
// ✅ CORRECT: Factory encapsulates its own requirements
interface KeymapResult {
readonly extension: Extension;
readonly precedence: (ext: Extension) => Extension;
readonly overrides: readonly ShortcutCategory[];
}
// Consumer applies generically:
codeMirrorEditor.appendExtensions(result.precedence(result.extension));
When a module grows beyond ~200 lines or accumulates multiple distinct responsibilities, split into submodules by responsibility domain (not by arbitrary size). Each submodule should be independently understandable.
// ❌ WRONG: One large file with mixed concerns
keymaps/emacs.ts (400+ lines: formatting + structural + navigation + save)
// ✅ CORRECT: Split by responsibility
keymaps/emacs/
├── index.ts ← Factory: composes submodules
├── formatting.ts ← Text styling commands
├── structural.ts ← Document structure commands
└── navigation.ts ← Movement and editing commands
Treat a directory as a module with a single public entry point (index.ts). The barrel declares the public API; everything else is an implementation detail.
index.ts is the sole export surface. Siblings/parents import only from the barrel, never reach into internal files.import { X } from './dom' over import { X } from './dom/widget'. Reaching through a barrel keeps the coupling at the module level, not the file level.y-rich-cursors/ refactor)y-rich-cursors/
├── index.ts ← Public API: only yRichCursors() + YRichCursorsOptions
├── plugin.ts ← Internal orchestrator
├── activity-tracker.ts ← Internal
├── local-cursor.ts ← Internal
├── viewport-classification.ts ← Internal
└── dom/
├── index.ts ← Sub-barrel: exposes widget/theme/indicator to siblings only
├── widget.ts
├── theme.ts
└── off-screen-indicator.ts
Before the refactor, index.ts re-exported RichCaretWidget, createOffScreenIndicator, ScrollCallbackRef, etc. — internal details leaked as public API. After: the top-level barrel exposes the single yRichCursors entry point, and DOM concerns live behind dom/index.ts so that sibling modules (plugin.ts) can consume them without making them part of the module's external contract.
When adding a new file, ask: is this intended for external callers? If no, it does not belong in the top-level barrel. If it is one of several related internals, consider a subdirectory.
Button.tsx)page-utils.ts)features/page-tree/, utils/Prefer named exports over default exports:
// ✅ Good
export const MyComponent = () => { };
// ❌ Avoid (exception: Next.js pages)
export default MyComponent;
Named exports give reliable IDE rename, better tree shaking, and unambiguous import names.
Always provide explicit types for function parameters and return values. Use import type for type-only imports.
function createPage(path: string, body: string): Promise<Page> { /* ... */ }
import type { PageData } from '~/interfaces/page';
Handle errors comprehensively — log with context, rethrow with a user-friendly message:
try {
return await riskyOperation();
} catch (error) {
logger.error('Operation failed:', { error, context });
throw new Error('Detailed user-friendly message');
}
Prefer async/await over .then() chains.
Write comments in English. Only comment when the WHY is non-obvious (hidden constraints, invariants, workarounds). Do not restate what the code does — let naming do that work.
Co-locate tests with source files.
*.spec.{ts,js}*.integ.ts*.spec.{tsx,jsx}Conventional commits:
<type>(<scope>): <subject>
<body>
Types: feat, fix, refactor, test, docs, chore.
GROWI must work on Windows, macOS, and Linux. Never use platform-specific shell commands in npm scripts.
// ❌ WRONG: Unix-only
"clean": "rm -rf dist"
// ✅ CORRECT: Cross-platform
"clean": "rimraf dist"
rimraf instead of rm -rfcpy-cli, cpx2) instead of cp, mv, echo, lsBefore marking work complete:
console.log (use logger)index.ts re-exports only what external callers need; internals stay unexported