The largest Kurdish publishing platform — a monorepo with a Next.js 16 frontend, Express API with 16 middleware layers, 36 Mongoose models, 38 route files, real-time Socket.IO, Backblaze B2 media pipeline, Puppeteer PDF generation, and a badge achievement engine.
bnusa.krd is a production Kurdish publishing platform connecting readers, writers, and publishers across books, articles, reviews, collaborative writing (KtebNus), social feeds, and community activities. The codebase is a monorepo containing four distinct applications.
| Directory | Runtime | Purpose |
|---|---|---|
| bnusa/ | Next.js 16 + React 18 | Main web app: reading, writing, profiles, feeds, notifications |
| backend/ | Express 4 + Node.js | REST API: 38 route files, 16 middleware layers, 36 Mongoose models |
| admin/ | Next.js (separate) | Admin dashboard: user management, content moderation, badge grants |
| account/ | Next.js (separate) | Account settings: profile editing, security, connected accounts |
backend/ — directory tree backend/ server.js // 674 lines — Express boot, middleware ordering, route groups socket.js // 126 lines — Socket.IO server, user tracking, emitters config/ db.js // MongoDB connection with ServerApiVersion.v1 badges.js // Badge definitions: normal, luxurious, achievement validateEnv.js // Fail-fast env validation + placeholder detection middleware/ // 6 files: auth, csrf, rateLimiter, sanitizer, security, replay models/ // 36 Mongoose schemas routes/ // 38 Express routers services/ // emailService.js, pdfGenerationService.js utils/ // imageUpload, badgeAchievements, notificationHelper, redisCache
bnusa/src/ — directory tree bnusa/src/ app/ // Next.js App Router: ~30 route folders layout.tsx // Root layout: fonts, metadata, 5 context providers globals.css // Global styles [feature]/ // page.tsx per route (articles/[slug], books/[id]...) components/ // 90+ React components contexts/ // AuthContext, SocketContext, ToastContext utils/ // api.ts, imageUpload.ts, indexedDB.ts, sanitize.ts lib/ // firebase.ts, fetchPublicMeta.ts, seo.ts, slugify.ts
The platform is a layered system: UX layer (Next.js), API layer (Express), persistence (MongoDB via Mongoose), external services (B2, Firebase, Brevo), and a real-time channel (Socket.IO). Every layer fails safely and independently.
Before Express starts, validateEnv() checks 7 required env vars (MONGODB_URI, JWT_SECRET, B2 keys, API_KEY), adds 2 more in production (CORS_ALLOWED_ORIGINS, COOKIE_DOMAIN), and rejects placeholder values like “changeme” for secret keys. If anything is missing, it calls process.exit(1).
backend/config/validateEnv.js — Boot guard const required = [ 'MONGODB_URI', 'JWT_SECRET', 'B2_KEY_ID', 'B2_APP_KEY', 'B2_BUCKET_NAME', 'B2_ENDPOINT', 'API_KEY', ]; // In production, reject placeholder values for secrets const placeholders = ['your-secret-key', 'changeme', 'secret']; const insecure = secretKeys.filter(key => { return placeholders.some(p => val === p || val.includes('your')); });
backend/server.js — Middleware ordering (674 lines, excerpt) // 1. Parse app.use(express.json({ limit: '10mb' })); app.use(cookieParser()); // 2. Compress (skip binary/streaming) app.use(compression({ filter: customFilter })); // 3. Security headers app.use(helmet(helmetConfig)); // 4. CORS (subdomain-aware) app.use(cors(corsOptions)); // 5. Sanitize all input early app.use(sanitizer()); // 6. Auth routes (before CSRF so login can issue tokens) app.use('/auth', authRouteLimiter, authRoutes); // 7. CSRF boundary — all routes below require valid CSRF token app.use(validateCsrf); // 8-11. Public → Special → Auth-only → Private route groups
backend/config/db.js await mongoose.connect(process.env.MONGODB_URI, { serverApi: { version: ServerApiVersion.v1, strict: true, deprecationErrors: true }, serverSelectionTimeoutMS: 15000, socketTimeoutMS: 45000, });
The stack reflects a practical constraint: build fast UI with Next.js and keep server-side logic explicit with Express + Mongoose. Operational middleware is included because publishing platforms are exposed to untrusted traffic.
| Layer | Package | Why it matters |
|---|---|---|
| Web | next@^16 | App Router, server components, fast page transitions |
| Web | [email protected] | Client components, hooks, context API |
| Web | socket.io-client@4 | Real-time notification delivery to browser |
| API | [email protected] | Explicit middleware pipeline and routing |
| API | [email protected] | Schema validation, embedded subdocs, compound indexes |
| API | [email protected] | Security headers + CSP in production |
| API | express-rate-limit@7 | Per-endpoint rate limiting with Kurdish error messages |
| API | sanitize-html@2 | XSS prevention: strict and content-safe allowlists |
| API | firebase-admin@13 | Server-side Firebase ID token verification |
| API | [email protected] | Image resize, format conversion, metadata strip |
| API | @aws-sdk/client-s3@3 | S3-compatible API for Backblaze B2 storage |
| API | puppeteer@24 | Headless Chromium for KtebNus book PDF generation |
| API | @sib-api-v3-sdk | Brevo transactional email delivery |
| API | redis@4 | Optional cache for like counts and user like status |
The backend defines 36 Mongoose models spanning user identity, content types, social interactions, moderation, and system internals. Every model uses schema-level validation, compound indexes, and deliberate denormalization.
| Model | Key Fields | Notable Features |
|---|---|---|
| User | firebaseUid, name, username, email, password, role, followers[], badges | Pre-save bcrypt hashing (salt=12), comparePassword method, social links, compound indexes |
| Writer | user (ref), bio, featured, articlesCount, followers[], categories | Text index on bio+categories, virtual for full profile |
| UserImage | userId (unique), profileImage, bannerImage | Static syncWithUser() upsert method |
backend/models/User.js — Password hashing userSchema.pre('save', async function(next) { if (!this.isModified('password')) return next(); const salt = await bcrypt.genSalt(12); this.password = await bcrypt.hash(this.password, salt); next(); });
| Model | Key Fields | Notable Features |
|---|---|---|
| Book | title, writer, description, genre, year, image, slug | Pre-save slug generation from title, text indexes for search |
| Article | title, content, author (ref), categories[], likes[], status, readTime | Pre-save readTime calculation (wordCount/200), array normalization |
| KtebNusBook | author (ref), coWriters[], genre[], publisher, status, coverImage, pdfUrl | Multi-genre, revealOwner toggle, draft/published, PDF export metadata, counters |
| Review | title, content, genre, rating, recommended, author (ref), status | Moderation flow (pending/accepted/rejected), edit count tracking |
backend/models/Article.js — Read-time calculation articleSchema.pre('save', function(next) { if (this.content) { const plainText = this.content.replace(/<[^>]*>/g, ''); const wordCount = plainText.split(/\s+/).filter(Boolean).length; this.readTime = Math.ceil(wordCount / 200); } next(); });
| Model | Key Fields | Notable Features |
|---|---|---|
| Activity | authorId, type (help/research/debate), title, comments[Comment], likes[] | Embedded CommentSchema with nested ReplySchema, virtual commentCount/likeCount |
| FeedPost | author (ref), content, comments[], likes[], writersOnly, isDeleted | Embedded comments with replies, writers-only visibility, soft delete |
| Comment | articleId, userId, parentId, content, replyingTo | Threaded via parentId, virtual isTopLevel + directRepliesCount |
| BoardPost | profileOwnerId, authorId, content, likes[], replies[], isPinned | Profile board with pinning, compound index on pinned+createdAt |
| Notification | userId, fromUserId, type, relatedId, relatedType, relatedSlug, isRead | Type enum: follow, like_article, comment, mention, badge_unlock, new_book, etc. |
| ReadingBookmark | userId, bookId, chapterId, paragraphId, startOffset, endOffset, selectedText | Text-position tracking for “Continue Reading” widget, one active per user |
backend/models/Activity.js — Embedded threads + virtuals const CommentSchema = new mongoose.Schema({ authorId: { type: ObjectId, ref: 'User', required: true }, content: { type: String, required: true, maxlength: 10000, trim: true }, replies: [ReplySchema], }); activitySchema.virtual('commentCount').get(function() { let count = this.comments.length; for (const c of this.comments) count += (c.replies?.length || 0); return count; });
The backend applies middleware in a strict, deterministic order. Each layer has a single responsibility, and the ordering ensures security checks happen before business logic.
| # | Middleware | File | Responsibility |
|---|---|---|---|
| 1 | JSON parser | express.json() | Parse request bodies up to 10MB limit |
| 2 | URL-encoded | express.urlencoded() | Parse form data with extended mode |
| 3 | Cookie parser | cookie-parser | Parse cookies for session JWT and CSRF tokens |
| 4 | Compression | compression | Gzip responses >1KB, skip socket.io/images/pdf paths |
| 5 | Helmet | helmet | Security headers, CSP with CDN + Firebase allowlists |
| 6 | CORS | cors | Subdomain-aware: *.bnusa.krd + configured origins |
| 7 | Sanitizer | sanitizer.js | Recursive HTML sanitization (strict + content-safe modes) |
| 8 | Auth routes | authRoutes.js | Login/signup/OAuth — mounted BEFORE CSRF |
| 9 | CSRF | csrf.js | Cookie-header token matching for state-changing requests |
| 10 | Security | securityMiddleware.js | API key + timestamp + signature validation + IP rate limiting |
| 11 | Method restrictor | methodRestrictor.js | Only GET on public routes (explicit POST allowlist) |
| 12 | Rate limiters | rateLimiter.js | 8 distinct configs: public, auth, comments, likes, reviews, etc. |
| 13 | Auth verifier | auth.js | Firebase token / JWT cookie verification, user lookup |
| 14 | Replay protection | replayProtection.js | In-memory nonce tracking with 5-minute TTL |
| 15 | Role check | (inline) | Admin/writer role verification on protected routes |
| 16 | Multer | imageUpload.js | Memory storage file upload with type filtering |
Implements in-memory rate limiting per IP + endpoint + method, API key validation, timestamp freshness, and request signature verification supporting multiple signature formats for backward compatibility.
backend/middleware/securityMiddleware.js — Multi-format signatures const sigFormats = [ // Format 1: method + path + timestamp + apiKey crypto.createHmac('sha256', apiKey) .update(`${method}${path}${timestamp}${apiKey}`).digest('hex'), // Format 2: method + fullUrl + timestamp + apiKey crypto.createHmac('sha256', apiKey) .update(`${method}${fullUrl}${timestamp}${apiKey}`).digest('hex'), // Format 3: method + path + timestamp + nonce + apiKey crypto.createHmac('sha256', apiKey) .update(`${method}${path}${timestamp}${nonce}${apiKey}`).digest('hex'), ]; const isValid = sigFormats.some(s => s === signature);
backend/middleware/csrf.js const validateCsrf = (req, res, next) => { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); if (authHeader.startsWith('Bearer ')) return next(); // API clients skip CSRF const cookieToken = req.cookies['csrf-token']; const headerToken = req.headers['x-csrf-token']; if (!cookieToken || !headerToken || cookieToken !== headerToken) return res.status(403).json({ error: 'CSRF token mismatch' }); next(); };
Supports three authentication strategies in priority order: (1) Firebase ID token in Authorization header, (2) JWT session cookie, (3) fallback admin JWT. On success, it finds or creates the MongoDB User document and attaches req.user with roles.
Recursively walks request body, query, and params. Strict mode strips all HTML. Content-safe mode for rich-text endpoints allows minimal tags: <p>, <br>, <strong>, <em>, <a>, <ul>, <ol>, <li>, <blockquote>. Binary upload paths are skipped entirely.
| Limiter | Window | Max | Key |
|---|---|---|---|
| publicApiLimiter | 1 min | 100 | IP |
| authRouteLimiter | 15 min | 20 | IP |
| commentLimiter | 1 min | 10 | IP + route |
| likeLimiter | 1 min | 30 | IP + route |
| reviewLimiter | 15 min | 5 | IP |
| ktebnusBookLimiter | 15 min | 10 | IP |
| boardPostLimiter | 1 min | 10 | IP |
| followLimiter | 1 min | 20 | IP |
In-memory nonce tracking with 5-minute TTL. Each caller (user ID or IP) gets a nonce store. Duplicate nonce + timestamp within the window returns 409 Conflict. Expired entries are garbage-collected on a periodic interval.
The server groups all 38 route files into four security tiers: public, special, auth-only, and private. Each group gets a deterministic middleware stack applied via forEach loops.
backend/server.js — Route grouping pattern const publicRoutes = [ { path: '/api/books', router: bookRoutes }, { path: '/api/writers', router: writers }, { path: '/api/stats', router: statsRoutes }, ]; publicRoutes.forEach(({ path, router }) => { app.use(path, publicApiLimiter, securityMiddleware, methodRestrictor, router); }); const authOnlyRoutes = [ { path: '/api/notifications', router: notificationRoutes }, { path: '/api/bookmarks', router: bookmarkRoutes }, { path: '/api/users', router: userRoutes }, ]; authOnlyRoutes.forEach(({ path, router }) => { app.use(path, authenticatedLimiter, securityMiddleware, authMiddleware, router); });
| Route File | Size | Domain |
|---|---|---|
| userRoutes.js | 72KB | Profile CRUD, follow/unfollow, search, settings, avatar |
| adminRoutes.js | 62KB | User management, content moderation, analytics, badges |
| ktebnusRoutes.js | 41KB | KtebNus public book/chapter browsing, search, comments |
| ktebnusPrivateRoutes.js | 35KB | KtebNus authoring: create/edit books, chapters, co-writers |
| reviewRoutes.js | 34KB | Review CRUD, moderation status, comments, likes |
| articles.js | 33KB | Article listing, filtering, search (public) |
| activityRoutes.js | 31KB | Activity CRUD, embedded comments/replies, likes |
| feedPostRoutes.js | 25KB | Social feed, comments, likes, reposts, writers-only |
| authRoutes.js | 20KB | Firebase auth, Google OAuth, session, logout |
| imageRoutes.js | 19KB | Image upload (single + multi), profile/banner, B2 |
| articleRoutes.js | 18KB | Article CRUD (auth-required) |
| commentRoutes.js | 16KB | Article threaded comments via parentId |
| inlineCommentRoutes.js | 16KB | Inline text-selection comments for KtebNus chapters |
| boardRoutes.js | 16KB | Profile board posts with replies and pinning |
| publicMetadataRoutes.js | 13KB | SEO metadata endpoints for SSR/SSG |
| repostRoutes.js | 12KB | Feed post repost/unrepost with notifications |
| reviewCommentRoutes.js | 12KB | Review comment threads |
| forYouRoutes.js | 10KB | Personalized "For You" feed algorithm |
| ktebnusPdfRoutes.js | 10KB | PDF generation and download for KtebNus books |
Public routes are restricted to GET only by default. The method restrictor maintains an explicit allowlist for POST endpoints (image uploads, ktebnus comments/likes) and blocks all other non-GET methods with 405.
Social systems have a classic correctness issue: the UI may accept input the server rejects. bnusa.krd enforces max length at two layers: route-level validation (fast fail) and schema-level (database integrity).
backend/routes/activityRoutes.js — Route-level check if (content.trim().length > 10000) { return res.status(400).json({ success: false, message: 'Content too long (max 10000)' }); }
The platform uses Socket.IO for real-time notification delivery. The backend tracks connected users by socket, and the frontend maintains a resilient connection with iOS PWA reconnect handling.
Initializes Socket.IO with CORS, authenticates users from JWT session cookies, tracks user → socket mappings in memory, and exposes sendNotificationToUser() and sendAdminMessage() emitters.
backend/socket.js — User tracking + targeted emit const userSockets = new Map(); // userId -> Set<socketId> io.on('connection', (socket) => { const userId = socket.handshake.auth?.userId; if (userId) { socket.join(userId); // join personal room if (!userSockets.has(userId)) userSockets.set(userId, new Set()); userSockets.get(userId).add(socket.id); } }); function sendNotificationToUser(userId, notification) { io.to(userId).emit('notification', notification); }
backend/utils/notificationHelper.js — @mention parsing const mentions = text.match(/@([a-zA-Z0-9_]+)/g); const usernames = [...new Set(mentions.map(m => m.slice(1)))]; const users = await User.find({ username: { $in: usernames } }); const fromName = fromUser?.name || 'کەسێک';
Creates and manages the Socket.IO connection with several resilience features:
Every image uploaded to bnusa.krd passes through a multi-stage validation and processing pipeline before reaching Backblaze B2 cloud storage and being served via CDN.
imageUpload.js performs two-layer MIME validation: first checking the file extension, then reading the file's magic bytes (first 12 bytes) to detect actual file type. This prevents attackers from uploading malicious files with renamed extensions.
backend/utils/imageUpload.js — Magic-byte sniffing function sniffMimeType(buffer) { const hex = buffer.slice(0, 12).toString('hex'); if (hex.startsWith('89504e47')) return 'image/png'; if (hex.startsWith('ffd8ff')) return 'image/jpeg'; if (hex.startsWith('47494638')) return 'image/gif'; if (hex.startsWith('52494646') && hex.includes('57454250')) return 'image/webp'; return null; }
After validation, images are processed with Sharp: resized to max dimensions, converted to WebP for optimal size, metadata stripped for privacy, and named with a SHA1 hash of the content for cache-friendly deduplication.
Processed images are uploaded to B2 via the @aws-sdk/client-s3 S3-compatible API. The bucket is configured with a CDN-friendly URL at cdn.bnusa.krd for low-latency global delivery.
backend/server.js — Compression exclusion logic app.use(compression({ threshold: 1024, filter: (req, res) => { if (req.path.startsWith('/socket.io')) return false; if (req.path.startsWith('/api/images')) return false; if (req.path.includes('/pdf/') || req.path.includes('/download')) return false; return compression.filter(req, res); }, }));
bnusa.krd includes a gamification system that automatically unlocks badges at publishing milestones, plus admin-grantable luxurious badges. The platform also generates downloadable PDF books via headless Chromium.
checkAndUnlockBadges(userId, type) runs after each article publish, review acceptance, or book publish. It counts published items, compares against milestone thresholds (1, 5, 10, 25, 50), and unlocks all qualifying badges in a single $addToSet update.
backend/utils/badgeAchievements.js — Milestone check async function checkAndUnlockBadges(userId, type) { const milestones = ACHIEVEMENT_BADGES[type]; const user = await User.findById(userId).select('unlockedBadges').lean(); const alreadyUnlocked = new Set(user.unlockedBadges || []); const toCheck = milestones.filter(m => !alreadyUnlocked.has(m.id)); const count = await getPublishedCount(userId, type); const newlyUnlocked = toCheck.filter(m => count >= m.count); await User.findByIdAndUpdate(userId, { $addToSet: { unlockedBadges: { $each: newIds } }, }); // Real-time notification for each unlock for (const m of newlyUnlocked) { await notifyBadgeUnlock(userId, m.id, m.messageKu); } }
Admins can grant luxurious badges via adminGrantBadge() and revoke them via adminRevokeBadge(). Revocation also clears the user's selectedBadge if they had the revoked badge selected.
pdfGenerationService.js uses Puppeteer to render KtebNus books as styled PDFs. It pre-loads the Rabar Kurdish font as base64 from multiple candidate directories (supporting local dev, Docker, and Coolify deployments), builds a full HTML print template with theme support (light/dark), cover page, chapter pages with RTL layout, and watermark branding.
backend/services/pdfGenerationService.js — Font loading strategy const candidateFontDirs = [ process.env.PDF_FONT_DIR, // Coolify override path.resolve(__dirname, '../../bnusa/public/fonts/reader'), // Monorepo path.resolve(process.cwd(), 'bnusa/public/fonts/reader'), // CWD = repo root path.resolve(__dirname, '../public/fonts/reader'), // Backend copy ].filter(Boolean); for (const dir of candidateFontDirs) { if (fs.existsSync(path.join(dir, 'Rabar_021.woff'))) { rabar400Base64 = fs.readFileSync(...).toString('base64'); break; } }
emailService.js sends transactional emails via the Brevo API. The welcome email uses the "Apple approach" for Gmail iOS dark mode compatibility: white backgrounds (#ffffff, #f5f5f7) with dark text (#1d1d1f) that Gmail auto-inverts. The Rabar font is loaded via @font-face from bnusa.krd/fonts/Rabar_021.woff.
The frontend is a Next.js 16 application using the App Router with 90+ React components, 3 context providers, and a comprehensive utility layer for API communication, offline storage, and image handling.
layout.tsx wraps the entire app with five nested context providers, loads fonts (Rabar Kurdish, Geist Sans, Geist Mono), sets metadata + viewport, and renders global UI components.
bnusa/src/app/layout.tsx — Provider nesting order <AuthProvider> <SocketProvider> <ThemeProvider> <ToastProvider> <ConfirmDialogProvider> <Navbar /> {children} <Footer /> <AccessibilitySettings /> <ScrollToTop /> </ConfirmDialogProvider> </ToastProvider> </ThemeProvider> </SocketProvider> </AuthProvider>
Manages authentication state with Firebase. On mount, calls /api/users/me with retry logic for rate limiting (up to 3 retries with exponential backoff). Sign-in flow: Firebase signInWithEmailAndPassword → get ID token → POST to /auth/firebase → backend sets JWT session cookie → reload session. Sign-out cleans up Firebase state, localStorage (firebase keys, image caches, pending uploads), sessionStorage, and API caches.
bnusa/src/contexts/AuthContext.tsx — Rate limit retry while (true) { try { const me = await api.noCache.get('/api/users/me'); setCurrentUser(me?.user || null); break; } catch (e) { if (e?.code === 'RATE_LIMIT' || e?.status === 429) { attempt++; if (attempt <= 3) { const retryAfterMs = e.retryAfter * 1000 || 500 * attempt; await new Promise(r => setTimeout(r, retryAfterMs)); continue; } } throw e; } }
bnusa/src/lib/firebase.ts — Auth initialization const app = getApps().length ? getApp() : initializeApp(firebaseConfig); export const auth = getAuth(app); // Persistence: try localStorage, fallback to sessionStorage export const authReady = (async () => { try { await setPersistence(auth, browserLocalPersistence); } catch { await setPersistence(auth, browserSessionPersistence); } })(); // Google sign-in via popup (more reliable on iOS Safari) export async function signInWithGooglePopup(): Promise<string> { await authReady; const result = await signInWithPopup(auth, googleProvider); return await result.user.getIdToken(); }
| Route | Page |
|---|---|
| /articles/[slug] | Article detail with comments, likes, inline actions |
| /books/[id] | Book detail with chapters list, bookmarks |
| /reviews/[slug] | Review detail with comments, ratings |
| /profile/[username] | User profile with board, articles, badges |
| /ktebnus/[slug] | KtebNus book reader with chapters |
| /feed | Social feed with posts, comments, reposts |
| /activities | Activity hub (help, research, debate) |
| /notifications | Notification center |
| /dashboard | Writer dashboard with content management |
| /signin, /signup | Authentication pages |
The frontend api.ts (319 lines) is a comprehensive API utility that handles request caching, HMAC signature generation for secure communication, image load tracking, and multiple cache management strategies.
Every API request includes security headers generated client-side: a timestamp, nonce, and HMAC-SHA256 signature computed from the HTTP method, URL path, timestamp, and API key. This matches the backend's securityMiddleware.js validation.
bnusa/src/utils/api.ts — Signature generation function generateSignature(method: string, url: string) { const timestamp = Date.now().toString(); const nonce = crypto.randomUUID(); const path = new URL(url).pathname; const signature = hmacSHA256( `${method}${path}${timestamp}${nonce}${API_KEY}`, API_KEY ); return { timestamp, nonce, signature }; }
indexedDB.ts (277 lines) manages an IndexedDB database with 6+ object stores for offline capability:
| Store | Key | Indexes | Purpose |
|---|---|---|---|
| pendingUploads | auto-increment | timestamp | Queue uploads for retry when offline |
| articles | id | slug (unique), timestamp | Cached article data |
| userData | id | — | Cached user profile data |
| cachedImages | url | timestamp | Image blob cache |
| offlineArticles | id | slug, savedAt | Saved-for-offline articles |
| offlineReviews | id | slug, savedAt | Saved-for-offline reviews |
| offlineBooks | id | slug, savedAt | Saved-for-offline KtebNus books |
backend/server.js — Subdomain-aware CORS const corsOptions = { origin: function(origin, callback) { if (!origin) return callback(null, true); const h = new URL(origin).hostname.toLowerCase(); if (h === 'bnusa.krd' || h.endsWith('.bnusa.krd')) return callback(null, true); return callback(null, allowedOrigins.includes(origin)); }, credentials: true, };
bnusa.krd is engineering for cultural infrastructure: the core value is not only features, but a stable system that keeps publishing workflows trustworthy as the platform grows.