Skip to content

Code Patterns

This page documents the coding conventions and patterns used across the tripplan.ing codebase. Follow these when contributing to maintain consistency.

Svelte 5

tripplan.ing uses Svelte 5 with runes. Do not use legacy reactive syntax ($:, export let).

Props

svelte
<script lang="ts">
  let { value = '', onchange }: {
    value?: string;
    onchange?: (v: string) => void;
  } = $props();
</script>

Reactive state

svelte
<script lang="ts">
  let count = $state(0);
  let doubled = $derived(count * 2);

  $effect(() => {
    console.log('count changed:', count);
  });
</script>

Key rules

  • Use $props() for component inputs, not export let
  • Use $state() for local reactive state
  • Use $derived() for computed values (replaces $: assignments)
  • Use $effect() sparingly — prefer derived values over effects
  • Destructure props with defaults in the $props() call

Server-side patterns

Accessing the runtime environment

Always use getRuntimeEnv() to get database, KV, and blob handles:

typescript
import { getRuntimeEnv } from '$lib/server/runtime/index.js';

export const load: PageServerLoad = async ({ platform, locals }) => {
  const env = await getRuntimeEnv(platform);
  const db = env.db;
  const eventId = locals.eventId;
  // ...
};

Never import runtime implementations directly — always go through the index.ts entry point.

Form actions

Use SvelteKit form actions for mutations:

typescript
export const actions = {
  default: async ({ request, platform, locals }) => {
    const env = await getRuntimeEnv(platform);
    const formData = await request.formData();
    const name = formData.get('name') as string;

    // Validate
    if (!name?.trim()) {
      return fail(400, { error: 'Name is required' });
    }

    // Mutate
    await env.db.insert(table).values({ ... });

    return { success: true };
  }
};

Error handling

Use SvelteKit's error() and redirect():

typescript
import { error, redirect } from '@sveltejs/kit';

if (!item) throw error(404, 'Not found');
if (!authorized) throw redirect(302, '/auth');

Import order

Follow this order, separated by blank lines:

typescript
// 1. SvelteKit
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

// 2. External packages
import { eq, and } from 'drizzle-orm';

// 3. Server-side lib imports
import { getRuntimeEnv } from '$lib/server/runtime/index.js';
import { rsvps } from '$lib/server/db/schema';

// 4. Shared lib imports
import type { EventConfig } from '$lib/types';

// 5. Relative imports
import { formatDate } from './utils';

Cloudflare Workers constraints

Cloudflare Workers run on V8, not Node.js. These constraints are critical for production:

No Node.js APIs

Do not use:

  • crypto module → use crypto.subtle (Web Crypto API)
  • fs, path, os → not available
  • Buffer → use Uint8Array / TextEncoder
  • http/https modules → use fetch()

Stripe

Must use the fetch-based HTTP client:

typescript
import Stripe from 'stripe';

const stripe = new Stripe(key, {
  httpClient: Stripe.createFetchHttpClient()
});

For webhook verification, use SubtleCryptoProvider:

typescript
import { SubtleCryptoProvider } from 'stripe';

const event = await stripe.webhooks.constructEventAsync(
  body, signature, secret,
  undefined,
  new SubtleCryptoProvider()
);

Mailgun

Use raw fetch() — no SDK:

typescript
await fetch(`https://api.mailgun.net/v3/${domain}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Basic ${btoa(`api:${apiKey}`)}`,
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  body: new URLSearchParams({ from, to, subject, text })
});

Dynamic imports

The Node runtime (node.ts) is loaded via dynamic import() to prevent better-sqlite3 from being bundled into the Worker:

typescript
if (platform?.env) {
  return getCloudflareRuntime(platform.env);
}
const { getNodeRuntime } = await import('./node.js');  // Dynamic import
return await getNodeRuntime();

Database conventions

Queries always scope by event_id

typescript
// Correct
await db.select().from(rsvps).where(
  and(eq(rsvps.eventId, eventId), eq(rsvps.status, 'confirmed'))
);

// Wrong — leaks data across events
await db.select().from(rsvps).where(eq(rsvps.status, 'confirmed'));

IDs are UUID strings

typescript
await db.insert(rsvps).values({
  id: crypto.randomUUID(),
  eventId,
  // ...
});

Timestamps are ISO strings

typescript
createdAt: new Date().toISOString()

Naming conventions

Files

TypeConventionExample
Svelte componentsPascalCasePhotoCard.svelte
TypeScript lib filescamelCase or kebab-casesettings.ts, resolve-event.ts
RoutesLowercase with hyphensadmin/custom-fields/

Variables

ContextConventionExample
TypeScript/JavaScriptcamelCasecontactEmail, rsvpId
Database columnssnake_casecontact_email, rsvp_id
Environment variablesSCREAMING_SNAKE_CASESTRIPE_SECRET_KEY
CSS custom properties--kebab-case--color-primary

Functions

PatternConventionExample
Actions/handlersverbNounhandleSubmit, createSession
Boolean gettersisX, hasX, canXisLocalDev, hasPermission

TypeScript

  • No any — use unknown and narrow with type guards
  • No non-null assertions (!) — handle nullability explicitly
  • Explicit return types on exported functions
  • Use type for unions and function types, interface for object shapes
  • Export shared types from $lib/types.ts

Tailwind CSS

Class order

Follow this order: layout → sizing → typography → visual → interactive:

html
<div class="flex items-center gap-4 p-4 text-sm font-medium bg-white rounded-lg shadow hover:shadow-md">

Theme colors

Use semantic color names, not raw Tailwind colors:

html
<!-- Correct -->
<div class="bg-primary text-white">

<!-- Avoid -->
<div class="bg-blue-600 text-white">

Theme colors (--color-primary, --color-accent) are set dynamically from event settings in +layout.svelte.

Released under the MIT License.