Skip to content

Automation first

Rules defined in the funkwhale eslint config are not documented in this document. See Getting started for a guide on how to set up tooling locally.

Code style

This document outlines the enforceable programming conventions for the Funkwhale frontend. See the Architecture guide for the reasoning behind these rules.

Vue component structure

Component section order

Rule: Always order Single File Components like this:

  1. script setup (imports, defineProps, defineModel),
  2. template
  3. style module

The script defines the interface of the module. Especially with deep modules and narrow interfaces, knowing what data goes in and out is the key to understanding the functionality of a module within its feature.

vue
<script setup lang="ts">
// imports here
const {
  a = true, // defaults
  ...props
} = defineProps<{
  a?: boolean
}>()
const songs = defineModel<Song[]>()
</script>

<template>
  <span />
</template>

<style module>
</style>

Type prop declarations

Rule: Always use explicit type-based declarations (defineProps<{ ... }>()) rather than runtime declarations or any.

Strict boundaries at the component edge prevent bugs deep in the tree. See Parse, don't validate.

Explicit CSS modules

Rule: Use <style module> and explicitly bind classes using $style.

Modules guarantee strict locality and zero CSS leakage. Read more about Locality in our Architecture guide.

vue
<template>
  <div :class="$style.section">
    ...
  </div>
</template>

<style module>
.section {
    background: var(--color-background);
}
</style>

State and reactivity

Limit watchers

Rule: Avoid watch and watchEffect when synchronizing internal component state. Use them exclusively for triggering side-effects with external sources (LocalStorage, WebAudio API, DOM elements).

Complex systems of watchers are hard to debug. Default early, derive late to avoid intermediate state and invisible dependencies.

vue
<template>
  <Button :aria-busy="albumData.status === 'loading'">
    Save
  </Button>
</template>

Separate features

Rule: Never import from one feature into another.

We keep features independent from each other so that fixing one does not create new bugs in another.

Exceptions:

  • ~/pages can import from other features
  • ~/shared can import from other features

Only share features used in multiple other features

Rule: Place composables, helpers and components into ~/shared if they are consumed by at least two different features. If they are only consumed by modules in a single feature, they belong into that feature. If a module is only consumed by one other module, inline it.

Keeping ~/shared lean and flat helps reduce coupling in thecodebase long-term.

Avoid defensive programming

Rule: Remove all undesired codepaths.

Typecastingas any or non-null assertion variable! usually indicate that the shape of data isnot fully known. With a strict schema for API data and typed Web APIs, there is rarely need for these patterns.

If you find yourself programming for unneeded states,

Styling and design system

Use design tokens where available

Rule: Instead of hardcoded color values (e.g., #FF0000) or absolute dimensions (14px), use context-aware CSS variables.

Hardcoded values can break themes, consistency and accessibility. See Theming for an overview and Design system for a detailed reference of available classes, attributes and properties.

template
  <nav
    :class="[
      props.class,
      isBlock && 'layout-row',
    ]"
    ui-block-size=48
  >

Naming

Use domain language and canonical names

Rule: For Funkwhale date, stick to vocabulary as defined by API schema and avoid aliases. Never abbreviate (idx -> index; sug -> suggestion ) and never append types to names (userArray -> users). Mirror semantic and syntactic function with grammar:

  • Type -> Noun
  • Collection -> Plural
  • Function -> Imperative verb
  • Builder function -> with*
  • Accessor -> get*
  • State discriminator -> Gerund
  • Event discriminator -> Perfect tense verb
  • Boolean -> Predicate

Only for indices and "piped" variables, use single letters where it avoids duplication.:

ts
const items = [{ type: 'a' }, { type: 'b' }] as const
return items
  .filter(i => i.type === 'a')
  .map(i => ({ ...i, type: i.type.toUpperCase() }))

Consistent naming and Reduces cognitive load. A developer should not have to memorize a frontend-specific alias for a backend concept. See Natural and canonical names

Reuse variables

Rule: Use the same name for the same thing.

  • Do not rename variables mid-script
  • Do not add redundant names when structuring and destructuring records, including component props and attributes
  • Use consistent names for items across modules
  • Where a name is set by an API, use that name.
template
<component
  :is
  :on-click
  :aria-busy
>

Inline documentation

Rule: Use // comments to explain why a specific, non-obvious technical decision was made in the body of the code. Use /** */ (JSDoc) exclusively above exports, interfaces, props and record fields to document the API.

JSDoc blocks are parsed by language servers (providing hover-text in code editors).

Function syntax

Rule: Use arrow functions without braces () => ... for pure functions/derivations. Use arrow functions with braces () => { ... } or the function keyword when the block has side effects.

Provides an immediate visual cue to the reader about whether a function will mutate state.

ts
const getAvatarUrl = (hash: string) => `https://node/avatar/${hash}.png`

const startPlayback = () => {
  audioEngine.play()
  isPlaying.value = true
}

Prefer string unions over enums

Rule: Use string literal unions (type Status = 'loading' | 'success') instead of enum.

Unions remove an unnecessary layer of abstraction.

Parameter order

Rule: When composable and functions have several optional parameters, combine them into a strictly typed Config parameter.

Config objects are easier to parse than lists of values because of their shape and the named fields.

Example: The Url param composable ~/routing/urlParam accepts a name string and a config object and returns a type automatically derived from the type of allowed values.

ts
export function useUrlParamStore<
  const T extends string,
  const C extends {
    allowedValues?: readonly T[] | readonly [undefined, ...T[]] | 'array' | 'singleton' | 'nonnegativeInteger'
  }
>(
  name: string,
  config: C = {} as C
): Ref<InferDerivedType<C>> {
  // implementation
}
ts
const scope = useUrlParamStore('scope', {
  allowedValues: [undefined, 'subscribed', ...tabs.map(t => t.name)] as const
})

Architecture boundaries

Colocate Single-Use Utils

Rule: If a utility function, interface, or constant is only used by one component, place it inside that component's file or right next to it in its feature folder.

Locality over DRY. Prevents code rot, and makes deleting and refactoring features safe.