Development Standards

These standards govern all Swift Web projects (framework / starter / downstream business projects generated by sw create), covering everything from file naming to Git collaboration.

Two layers of enforcement:

  • Most hard rules are baked into ESLint / Stylelint configs, so violations fail at pre-commit / CI time.
  • The AI-compact version is also injected into downstream CLAUDE.md via the @swift-web/standards sync skill, so Claude / Cursor / Copilot load the guardrails before writing any code.

This page is the human-readable version (lots of code examples + design rationale); the AI-compact version lives in rules.md in the standards repo. This page covers a subset of the source of truth — for the complete set of rules, the standards repo rules/ is authoritative. Topics recently added to the source — "global CSS selector constraints", "reflow-safe focus", "touch targets and hit areas", "standard dev workflow SOP", "client-side security (cache utils / cookies)", "usage rules (usage-review numbering)" — are not yet expanded on this page and will be added as needed.

Rule Priority

When rules conflict, apply them in this order:

OrderSourceScope
1P0 rules / engineering gates at the top of each repo's CLAUDE.mdP0 guardrails / ownership / release / doc sync (highest)
2This document (rules/ in standards repo)Shared baseline for all appliers + standard dev workflow SOP
3Project-specific CLAUDE.md rulesPer-repo directory layout, naming, test conventions, internal APIs
4Other AI tool config files.cursorrules / .github/copilot-instructions.md, etc.

Higher layers override lower ones. When a conflict surfaces, proactively notify the user to fix the lower layer.


1. File Naming Standards

Naming-style violations are P0. Before creating any new file, check the rules first — wrong names break grep and Nuxt auto-import paths.

1.1 Vue Components / Pages / Layouts

LocationNaming StyleExamples
app/components/PascalCaseLoginForm.vue / AdSwiper.vue / PaginationBar.vue
app/pages/kebab-caseabout-us.vue / product-detail.vue / [id].vue
app/layouts/all lowercasedefault.vue / empty.vue
Good  app/components/LoginForm.vue
Good  app/pages/product-detail.vue
Good  app/layouts/default.vue

Bad   app/components/login-form.vue       (should be PascalCase)
Bad   app/pages/ProductDetail.vue         (should be kebab-case)
Bad   app/layouts/Default.vue             (should be all lowercase)

Why three different styles? Nuxt 4 conventions: components/ uses PascalCase so <LoginForm /> reads cleanly in templates; pages/ uses kebab-case so URLs match file names; layouts/ is all lowercase by Nuxt default.

1.2 TypeScript / JavaScript Files

LocationNaming StyleExamples
app/composables/use + PascalCaseuseModal.ts / useToast.ts / useLoginCheck.ts
app/store/kebab-caseuser-info.ts / system-info.ts
app/plugins/kebab-caseanalytics.client.ts / error-handler.ts
app/middleware/kebab-case + .global suffixlogin.global.ts / guest.global.ts
app/utils/kebab-caseaes-dynamic.ts / format-date.ts
server/api/kebab-case + HTTP method suffixhealth.get.ts / user-info.post.ts
app/api/generated by generate-api, never hand-editindex.ts / modules/ / types/
Good  app/composables/useToast.ts          (use + PascalCase)
Good  app/store/user-info.ts               (kebab-case)
Good  app/middleware/login.global.ts       (kebab + .global)
Good  server/api/health.get.ts             (kebab + HTTP method)

Bad   app/composables/use-toast.ts         (should be use + PascalCase)
Bad   app/composables/UseToast.ts          (use must be lowercase)
Bad   app/store/userInfo.ts                (should be kebab-case)
Bad   app/middleware/login.ts              (global middleware must include .global)

1.3 Quick Reference

Object TypeStyleExample
Business componentPascalCaseLoginForm.vue
Core componentSw + PascalCase (framework)SwAsyncText.vue
Page filekebab-caseproduct-detail.vue
Layout fileall lowercasedefault.vue
Composable fileuse + PascalCaseuseToast.ts
Utils filekebab-caseaes-dynamic.ts
Store filekebab-caseuser-info.ts
Middleware filekebab-case + .globallogin.global.ts
Plugin filekebab-case + .client / .serveranalytics.client.ts
Server API filekebab-case + HTTP methodhealth.get.ts

MR review template: File <file> violates the naming style (should be <style>) and breaks the directory convention. grep and auto-import paths will be affected — please rename.


2. Component Structure

2.1 Three-Layer Component Architecture

app/pages/                    Route pages: data fetching, layout composition, route param handling
    ↓ uses
app/components/               Business components: reusable page-level UI logic
    ↓ uses (Nuxt auto-import)
@swift-web/framework          Framework components: Sw* base components (SwImage, SwButton, SwModal, ...)

Core principles:

  • A page's <template> should mainly compose components. Keep a single page file ≤ 150 lines.
  • Move complex UI logic into app/components/; keep pages thin.
  • Business components may use framework Sw* components directly, but framework components must not reverse-import business components (violates the "lower layers don't depend on higher layers" rule).

Bad:

<!-- Bad: pages/product-detail.vue exceeds 300 lines, mixing data fetching and complex UI -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: product } = await useAsyncData(() => api.product.detail({ id: route.params.id }))
const { data: comments } = await useAsyncData(() => api.comment.list({ productId: route.params.id }))
const isFavorited = ref(false)
const showShareDialog = ref(false)
const shareToWechat = () => { /* 30 lines */ }
const shareToWeibo = () => { /* 30 lines */ }
const handleAddToCart = () => { /* 50 lines */ }
// ...
</script>

<template>
  <div class="sw-product-detail">
    <!-- 80-line hero section -->
    <!-- 60-line comments list -->
    <!-- 40-line share dialog -->
    <!-- 50-line add-to-cart overlay -->
  </div>
</template>

Good:

<!-- Good: pages/product-detail.vue split into 4 components -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: product } = await useAsyncData(() => api.product.detail({ id: route.params.id }))
useSeo({
  title: product.value?.name,
  description: product.value?.summary,
  image: product.value?.cover,
})
</script>

<template>
  <div class="sw-product-detail">
    <ProductDetailHero :product="product" />
    <ProductDetailDescription :product="product" />
    <ProductDetailComments :product-id="route.params.id" />
    <ProductDetailActions :product="product" />
  </div>
</template>

2.2 Component Header JSDoc (required)

Every component under app/components/ must include a complete JSDoc block at the top of <script setup> (Props / events / ≥ 1 usage example, ≥ 3 for complex components).

Full template:

<script setup lang="ts">
/**
 * LoginForm — User login form, used on the login page and the login dialog.
 *
 * ── Props ────────────────────────────────────────────────────────
 * @prop mode        {'page' | 'dialog'}  Required. Display mode.
 *                   - page: page mode, full background
 *                   - dialog: dialog mode, no background
 * @prop redirectUrl {string}             Optional. Redirect target after a successful login. Defaults to home.
 *
 * ── Events ───────────────────────────────────────────────────────
 * @event success    Fired on successful login, carries user info.
 * @event cancel     Fired when the user cancels (dialog mode only).
 *
 * ── Usage examples ───────────────────────────────────────────────
 *
 * // Example 1: page mode
 * <LoginForm mode="page" />
 *
 * // Example 2: dialog mode + event listeners
 * <LoginForm
 *   mode="dialog"
 *   redirect-url="/mine"
 *   @success="onLoginSuccess"
 *   @cancel="closeDialog"
 * />
 *
 * // Example 3: combined with useModal
 * const modal = useModal()
 * modal.open(LoginForm, { mode: 'dialog' })
 */

interface Props {
  mode: 'page' | 'dialog'
  redirectUrl?: string
}

const props = withDefaults(defineProps<Props>(), {
  redirectUrl: '/',
})

const emit = defineEmits<{
  success: [userInfo: UserInfo]
  cancel: []
}>()
</script>

Comment language rules:

  • All explanatory comment text is written in the project's primary documentation language.
  • Prop names, types, and event names stay in English (code identifiers are not translated).

Why is JSDoc required? Swift Web relies heavily on AI coding assistants. The component header is the primary semantic source an AI (Claude / Cursor) consults when maintaining a component — much faster than reverse-engineering through grep / call sites. MR reviews also rely on this block to judge whether a change is reasonable.

MR review template: Business component is missing the JSDoc header (Props / events / usage examples). Both AI maintenance and review depend on it — please add one.

2.3 Props Design

Rule 1: Optional props must have an explicit default value

// Good: declare defaults explicitly via withDefaults
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',     // meaningful default
  disabled: false,
  label: undefined,       // when no good default exists, write undefined explicitly
})

// Bad: rely on TS implicit undefined
const props = defineProps<Props>()

Rule 2: Use strict union types for enums; never use bare string

// Good: union types give callers IDE completion + type safety
interface Props {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
}

// Bad: bare string accepts anything that compiles
interface Props {
  variant?: string
  size?: string
}

Rule 3: Prefer the options-object pattern (fn({ a, b })) over multi-arg signatures (fn(a, b, c))

// Good: options object — adding parameters later does not break callers
interface UploadOptions {
  file: File
  onProgress?: (percent: number) => void
  retries?: number
}
function uploadFile(opts: UploadOptions): Promise<string> { /* ... */ }

uploadFile({ file, onProgress, retries: 3 })

// Bad: positional params — adding a 4th argument forces every call site to change
function uploadFile(file: File, onProgress?: (p: number) => void, retries?: number): Promise<string> { /* ... */ }

Rule 4: Pass through attributes with v-bind="$attrs"

Let callers forward native HTML attributes (disabled / aria-* / data-*, etc.):

<template>
  <!-- Good: a wrapped business button -->
  <button v-bind="$attrs" class="sw-primary-btn" @click="onClick">
    <slot />
  </button>
</template>

<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>

Callers can then write:

<PrimaryBtn :disabled="loading" aria-label="提交订单" data-testid="submit">
  提交
</PrimaryBtn>

3. CSS Standards

Full reference (token list / Tailwind parity / responsive utilities): Styling & Tokens. This section is the CSS coding convention (quick reference for AI use and MR reviews).

3.1 Root Class Constraint (required)

On the root DOM element of a Vue component / page, if a class is written, it must start with .sw-<component-kebab> (consistent with framework's Sw* prefix family); a root that writes no class at all is automatically exempt (e.g. wrapper components with no DOM output of their own such as <NuxtLayout> / <ClientOnly> / <DialogRoot>).

<!-- Good: app/components/LoginForm.vue → root class .sw-login-form -->
<template>
  <form class="sw-login-form">
    <input class="email" />
    <input class="password" />
    <button class="submit">登录</button>
  </form>
</template>

<!-- Good: app/pages/about-us.vue → root class .sw-about-us -->
<template>
  <div class="sw-about-us">
    <h1>关于我们</h1>
  </div>
</template>

<!-- Good: root is a wrapper component with no class — auto-exempt -->
<template>
  <ClientOnly>
    <div class="sw-inner">...</div>
  </ClientOnly>
</template>

<!-- Bad: a class is written but lacks the sw- prefix -->
<template>
  <form class="login-form">...</form>
  <form class="my-login">...</form>
</template>

Four edge cases:

  1. Multi-root components (Portal / Teleport produces sibling roots): each root is judged independently — if it writes a class, it must take a compound sw-<component>-<part> prefix; roots without a class are left alone.
    <template>
      <Teleport to="body">
        <div class="sw-toast-overlay">
          <div class="sw-toast-content">{{ message }}</div>
        </div>
      </Teleport>
    </template>
    
  2. Pass-through wrapper (no DOM of its own): apply the class to the actually rendered DOM.
    <!-- Wraps SwImage but does not render its own wrapper -->
    <template>
      <SwImage class="sw-product-cover" v-bind="$attrs" />
    </template>
    
  3. Pure-logic components (no DOM output, e.g. only emits global events): naturally a no-op — no annotation required; to make it explicit you may optionally add a @no-root-class file-header comment.
    <script setup lang="ts">
    /**
     * GlobalEventBus — pure logic component, no DOM output
     * @no-root-class (optional annotation, explicitly stating "the root class rule does not apply to this component")
     */
    onMounted(() => { /* ... */ })
    </script>
    <template></template>
    
  4. Conditional-render multi-branch (v-if switches the root element): each branch is judged independently per the rule — the sw- prefix is only checked when a class is written.
    <template>
      <article v-if="isArticle" class="sw-content-article">
        <slot />
      </article>
      <section v-else class="sw-content-section">
        <slot />
      </section>
    </template>
    

Design rationale (AI-friendly):

  • The root class maps strictly to the file name → grep / Claude / Cursor can reverse-look-up a component file from a style or DOM hit.
  • Aligned with framework's .sw- prefix → naming is uniform across the stack; AI doesn't have to switch between two naming styles ("built-in vs business").
  • Mandatory prefix removes the "should I add .sw- here?" guess; AI just applies it when scaffolding new components.
  • "No class → auto-exempt" avoids false positives on wrapper components (NuxtLayout / ClientOnly, etc.).

Tip: the .sw- class prefix does not replace Vue's multi-word-component-names rule. The former prevents CSS class collisions; the latter prevents component names from colliding with native HTML tags. They are complementary, not redundant.

Lint / Format backstop:

  • @swift-web/standards/eslint ships the root-class-sw-prefix rule (v2: only enforced when a class is written; roots without class are auto-exempt). Default severity is warn; CI can hard-fail with eslint --max-warnings=0 if you want.
  • @swift-web/standards/prettier + @swift-web/standards/stylelint centralize base configs (each repo's prettier.config.ts / stylelint.config.ts consumes them via import base + spread override — uniform upgrades + project-level customization).
  • MR review still relies on humans + AI as the second line of defense.

3.2 Child Element Classes (flat kebab)

Scoped styles isolate children naturally — use plain kebab-case without the sw- prefix:

<template>
  <form class="sw-login-form">
    <div class="header">登录</div>
    <div class="body">
      <input class="email" />
      <input class="password" />
    </div>
    <div class="footer">
      <button class="submit">登录</button>
      <button class="cancel">取消</button>
    </div>
  </form>
</template>

<style scoped>
.sw-login-form .header { font-size: 1.25rem; }
.sw-login-form .body { padding: 1rem; }
.sw-login-form .submit { background: var(--primary); }
</style>

3.3 State Modifiers (is- / has- prefix)

State classes use the is- or has- prefix to distinguish them from child classes (which are nouns):

/* Good: states are adjectives with is/has prefix */
.sw-login-form .field.has-error { border-color: var(--destructive); }
.sw-login-form .btn.is-loading { opacity: 0.6; cursor: wait; }
.sw-login-form .btn.is-disabled { opacity: 0.4; pointer-events: none; }
.sw-product-card.is-selected { box-shadow: 0 0 0 2px var(--ring); }

/* Bad: states as nouns collide with child elements */
.sw-login-form .error { /* is this a child or a state? */ }
.sw-login-form .loading { /* same problem */ }

Semantic mapping:

CategoryFormMeaningExamples
Child elementnoun, plain kebab"what something is".header / .error-msg / .submit
State modifieradjective, is-<x> / has-<x>"what state it is in".is-loading / .is-selected / .has-error

3.4 Forbidden Patterns

/* Bad: BEM double-underscore (inconsistent with framework style) */
.sw-login-form__header { }
.sw-login-form__submit--disabled { }

/* Bad: child class with sw- prefix (redundant under scoped) */
.sw-login-form .sw-header { }
.sw-login-form .sw-body { }

/* Bad: root class without sw- prefix (violates the root class rule) */
.login-form { }
.app-navbar { }

/* Bad: stacking the component root prefix on child classes (overkill) */
.sw-login-form-header-item-label { }

/* Bad: state expressed as a noun (ambiguous with child element) */
.sw-btn .loading { } /* should be .is-loading */
.sw-form .error { }  /* should be .has-error */

3.5 Nesting Strategy

  • Shallow nesting (1–2 levels) is recommended for semantic grouping.
  • CSS nesting structure is not required to mirror DOM hierarchy strictly (scoped already isolates; flat is fully acceptable).
  • Deep nesting (≥ 3 levels) is discouraged — AI editors easily lose track of selector scope.
/* Good: shallow nesting for semantic grouping */
.sw-login-form {
  display: flex;
  flex-direction: column;

  .field {
    margin-bottom: 1rem;
    &.has-error { border-color: var(--destructive); }
  }

  .submit {
    background: var(--primary);
  }
}

/* Good: fully flat (scoped handles isolation) */
.sw-login-form { display: flex; flex-direction: column; }
.sw-login-form .field { margin-bottom: 1rem; }
.sw-login-form .field.has-error { border-color: var(--destructive); }
.sw-login-form .submit { background: var(--primary); }

/* Bad: deep nesting strictly mirroring DOM (4 levels) */
.sw-card {
  .header {
    .row {
      .col {
        .title { color: red; }
      }
    }
  }
}

3.6 CSS Tokens (Shadcn v4 semantic naming)

  • All colors, radii, shadows, and layout sizes must use the Shadcn v4 semantic tokens injected by the framework.
  • Do not write fallbacks: :root defaults in framework's tokens.css already cover that.
  • Hard-coded color values are zero-tolerance (exceptions: transparent / currentColor / gradient stops / media-context-only values).
/* Good: semantic tokens, no fallback */
.sw-ad-swiper {
  background: var(--background);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--foreground);
}

.sw-error-banner {
  background: var(--destructive);
  color: var(--destructive-foreground);
}

/* Bad: with fallback (redundant — :root already covers it) */
color: var(--primary, #3b82f6);

/* Bad: removed legacy token names (standalone values, no longer exist) */
background: var(--color-bg-dark);
border-color: var(--border-color);
padding: var(--spacing-section);

/* Bad: hand-written CSS referencing Tailwind @theme bridge vars — use the semantic token var(--primary) */
color: var(--color-primary);

/* Bad: hard-coded color values */
background: #f5f5f5;
color: #1a1a1a;
border: 1px solid #e5e5e5;

Common token quick reference (full list in Styling & Tokens):

CategoryVariables
Base colors--background / --foreground
Primary--primary / --primary-foreground
Muted--muted / --muted-foreground
Borders--border / --input / --ring
Semantic--destructive / --success / --warning / --info
Sizes--radius / --shadow-sm / --shadow-md / --shadow-lg
Layout--nav-height / --container-padding / --content-max-width

MR review template: Hard-coded color / referencing a removed legacy token / fallback used here — switch to var(--xxx) semantic tokens (full list in Styling & Tokens).

3.7 Unified Hover Effect via filter

Drop the separate hover-color tokens (--primary-hover, etc.) and use filter: brightness() — one line, with automatic dark-mode symmetry:

/* Good: filter brightness one-liner, dark mode automatic */
.submit {
  background: var(--primary);
  color: var(--primary-foreground);
  transition: filter 0.15s ease;
}
.submit:hover  { filter: brightness(0.92); }
.submit:active { filter: brightness(0.85); }

.danger-btn {
  background: var(--destructive);
  color: var(--destructive-foreground);
  transition: filter 0.15s ease;
}
.danger-btn:hover { filter: brightness(0.92); }

/* Bad: maintaining a separate hover-color token */
.submit { background: var(--primary); }
.submit:hover { background: var(--primary-hover); } /* --primary-hover is deprecated */

Why filter? A brightness filter naturally produces symmetric hover states in light themes (darken) and dark themes (lighten) — the background's own lightness decides which way hover should move, so a separate token set is unnecessary.

3.8 CSS Unit Constraints

Core constraints (rem-first; px for pixel-precise details — consistent with the standards css-tokens rule):

Property categoryUnit
font-size / letter-spacing / text-indentrem (a11y-friendly)
line-heightunitless number (e.g. 1.5); px / rem forbidden
padding / margin / gap / width / height / top/right/bottom/leftrem
border-radius ≥ 0.5rem (8px)rem
border-radius < 8px (small radius)px exception (sub-pixel rendering stability)
border-width / box-shadow / text-shadow offsetspx
@media query breakpointspx
Physically fixed elements (small icons / dividers, etc.)px
emforbidden (SwIcon's 1em is the only exception)

Good:

.sw-product-card {
  padding: 1.5rem;                   /* Good: spacing in rem */
  font-size: 1rem;                   /* Good: font in rem */
  line-height: 1.6;                  /* Good: line-height unitless */
  border-radius: 0.75rem;            /* Good: large radius in rem */
  border: 1px solid var(--border);   /* Good: border width in px */
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); /* Good: shadow offset in px */
}

.tag {
  border-radius: 4px;                /* Good: small radius (< 8px) px exception */
}

@media (max-width: 768px) {          /* Good: media query in px */
  .sw-product-card { padding: 1rem; }
}

Bad:

.sw-product-card {
  padding: 24px;                     /* Bad: spacing should be rem */
  font-size: 16px;                   /* Bad: font should be rem */
  line-height: 26px;                 /* Bad: line-height should be unitless (e.g. 1.6) */
  margin: 0.5em 1em;                 /* Bad: em forbidden (compounds across nesting, hard to reason about) */
}

@media (max-width: 48em) {           /* Bad: media query should be px */
  .sw-product-card { padding: 1rem; }
}

Rationale: rem first — a11y-friendly (layout follows when users bump up browser font size); px exceptions cover detail-level pixel precision (borders / shadows) and breakpoint stability (media queries); em is forbidden because nested compounding is hard to track.

stylelint declaration-property-unit-allowed-list is configured; both pre-commit and CI run at error level.

3.9 Responsive Breakpoints

Keep these aligned with framework's responsive.css:

BreakpointRange
mobile< 768px
tablet768px – 1023px
desktop≥ 1024px
/* Good: aligned with framework breakpoints */
.sw-product-list { display: grid; grid-template-columns: 1fr; }

@media (min-width: 768px) {
  .sw-product-list { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 1024px) {
  .sw-product-list { grid-template-columns: 1fr 1fr 1fr; }
}

4. TypeScript Standards

4.1 Forbid any and Implicit any

Use unknown + type assertions when flexibility is needed:

// Good: unknown forces callers to narrow the type
function parseResponse(data: unknown): UserInfo {
  // In real code, validate the structure first
  return data as UserInfo
}

interface ApiEnvelope {
  code: number
  data: unknown
}
function unwrap<T>(envelope: ApiEnvelope): T {
  if (envelope.code !== 0) throw new Error(`API ${envelope.code}`)
  return envelope.data as T
}

// Bad: any opts out of type checking entirely
function parseResponse(data: any): any {
  return data
}

// Bad: no types on params / return (implicit any)
function format(date) {
  return date.toLocaleString()
}

MR review template: No any allowed — switch to unknown + type assertion; fill in interface fields.

4.2 Lock Literal Types with as const

Use as const on constant arrays / objects to lock literal types, then derive a union via [number] so you don't have to maintain a hand-written union that drifts:

// Good: as const + [number] auto-derives the union
const VARIANTS = ['primary', 'secondary', 'ghost'] as const
type Variant = (typeof VARIANTS)[number]
// Variant = 'primary' | 'secondary' | 'ghost'

const RENDER_MODES = ['ssr', 'csr', 'isr', 'ssg'] as const
type RenderMode = (typeof RENDER_MODES)[number]

// Good: as const on objects to lock structure
const STATUS_LABELS = {
  pending: '待支付',
  paid: '已支付',
  cancelled: '已取消',
} as const
type Status = keyof typeof STATUS_LABELS

// Bad: literal info lost (inferred as string[])
const VARIANTS = ['primary', 'secondary', 'ghost']
// typeof VARIANTS = string[] — cannot derive precise types

// Bad: hand-written union (can drift out of sync with the array)
type Variant = 'primary' | 'secondary' | 'ghost'
const VARIANTS: Variant[] = ['primary', 'secondary'] // ← missing 'ghost', still compiles

4.3 Variable Naming

// ── Regular variables / functions: camelCase ──
const pageTitle = ref('')
const userInfo = useUserStore()
function fetchUserInfo() {}
async function uploadFile(opts: UploadOptions): Promise<string> {}

// ── Booleans: prefix with is- / has- / can- / should- ──
const isLoading = ref(false)
const hasError = computed(() => !!errorMsg.value)
const canSubmit = computed(() => form.valid && !isLoading.value)
const shouldShowDialog = ref(false)

// ── Module-level true constants: SCREAMING_SNAKE_CASE ──
const MAX_RETRY = 3
const DEFAULT_PAGE_SIZE = 20
const API_TIMEOUT_MS = 10_000

// ── Types / interfaces: PascalCase, no I prefix ──
interface UserInfo { id: number; name: string }
type ApiResponse<T> = { data: T; code: number }
// Bad: interface IUserInfo (do not use I prefix)

// ── Module-internal helpers: leading underscore ──
function _deriveKey(requestId: string) {}
function _validateInternal(payload: unknown) {}

4.4 Template Ref Naming

Template refs end with Ref so they're easy to spot at a glance:

// Good: Ref suffix
const inputRef     = ref<HTMLInputElement>()
const containerRef = ref<HTMLDivElement>()
const listRef      = ref<HTMLUListElement>()
const swImageRef   = ref<InstanceType<typeof SwImage>>()

// In the template:
// <input ref="inputRef" />
// <SwImage ref="swImageRef" />

// Bad: visually indistinguishable from regular refs
const input = ref<HTMLInputElement>()
const container = ref<HTMLDivElement>()

4.5 Error Handling Conventions

Throwing:

// Good: in Nuxt / Nitro paths use createError (statusCode the framework understands)
throw createError({
  statusCode: 404,
  statusMessage: '商品不存在',
  data: { productId: id },
})

// Good: in pure-logic paths use Error subclasses
throw new Error('解析失败:缺少必填字段 phone')

// Bad: throwing string literals (loses stack trace)
throw '登录失败'

e is unknown in catch clauses:

// Good: narrow the type before reading .message
try {
  await someApi()
} catch (e) {
  if (e instanceof Error) {
    console.error(e.message, e.stack)
  } else {
    console.error('未知错误', e)
  }
}

// Good: domain errors as custom Error subclasses
export class LoginExpiredError extends Error {
  constructor() {
    super('登录已过期')
    this.name = 'LoginExpiredError'
  }
}

// In a catch handler
try { await fetchProfile() }
catch (e) {
  if (e instanceof LoginExpiredError) {
    await navigateTo('/login')
    return
  }
  throw e
}

// Bad: swallowing errors (no console.warn, no throw)
try { await someApi() } catch {}

// Bad: directly accessing e.message (TS reports ts-2571)
catch (e) {
  console.error(e.message) // e is unknown
}

4.6 Where to Put Types

  • Cross-module shared types go in a dedicated types.ts or in the target module's index.ts.
  • Module-local types stay next to their use site — don't preemptively externalize for "reusability".
// Good: cross-module shared — app/types/user.ts
export interface UserInfo { id: number; name: string; phone: string }
export type UserRole = 'admin' | 'member' | 'guest'

// Good: module-local — defined right next to its use
// app/composables/useLoginForm.ts
interface LoginFormState {  // not externalized
  phone: string
  code: string
  agreed: boolean
}
export function useLoginForm() {
  const state = reactive<LoginFormState>({ phone: '', code: '', agreed: false })
  return { state }
}

// Bad: hoisting a module-local type into types/, polluting the global namespace
// app/types/login-form-state.ts ← no second consumer; externalizing is premature abstraction

5. i18n Key Naming

5.1 Core Rule: Use the Chinese Source Text as the Key

// Recommended: use the Chinese source text directly
$t('登录')
$t('请输入手机号')
$t('订单总计 {amount} 元', { amount: 99 })
$t('欢迎 {username},今天是 {date}', { username, date })

// Discouraged: invented English-style keys
$t('login.button')
$t('order.total.amount')
$t('common.welcome.message')

Why not English keys:

  • Readability: business code shows the source phrase directly — no jumping between template and locale files to figure out what a key means.
  • AI-friendly: Claude / Cursor can read the semantics straight off the key without relying on a "convention-encoded description".
  • Reduces dead-key risk: English keys tend to accumulate dead entries; with Chinese-source keys, changing the copy means changing the key (so migration is naturally explicit).
  • Smooth multilingual expansion: the Chinese locale is an identity mapping (key === value); other locales simply translate by key.

5.2 Long Copy

Key length limit: ≤ 50 characters. When that's exceeded, use a short Chinese description as the key and keep the full text in the translation files:

// Good: long copy uses a short description as key
$t('首页SEO描述')
$t('关于页SEO描述')
$t('用户协议第一段')

// Bad: using a long sentence directly as a key
$t('一个现代化的 Nuxt 4 脚手架模板,内置 lint、测试、国际化、主题等一切所需。')

5.3 Exceptions (English / short-code keys allowed)

ScenarioKey exampleRationale
Routes / pathnamesroutes.homeTightly bound to URLs; Chinese isn't appropriate.
Status enum mappingstatus.pending / status.paidBackend returns English enums; need bidirectional mapping.
Very long paragraphs (> 200 chars)legal.terms.section1A Chinese key would hurt code readability.
Rich text with HTML tagstooltip.html.welcomeSource contains tags that disrupt code formatting.

5.4 Placeholder Convention

Use named placeholders with curly braces — no positional arguments:

// Good: named placeholders
$t('订单总计 {amount} 元', { amount: 99 })
$t('欢迎 {username},今天是 {date}', { username, date })

// Bad: positional arguments (clashes with the Chinese-source key style)
$t('订单总计 {0} 元', [99])
$t('Welcome {0}, today is {1}', [username, date])

5.5 Translation File Format

Flat structure, sorted by key (lexicographic):

// Good: i18n/locales/zh-CN.json
{
  "关于页SEO描述": "了解 Swift Web:一个基于 Nuxt 4 的现代化工程模板...",
  "立即开始": "立即开始",
  "登录": "登录",
  "首页": "首页",
  "首页SEO描述": "一个现代化的 Nuxt 4 脚手架模板,内置 lint、测试、国际化..."
}
// Good: i18n/locales/en.json (keys remain Chinese source text)
{
  "关于页SEO描述": "Learn about Swift Web: a Nuxt 4 project template...",
  "立即开始": "Get Started",
  "登录": "Sign In",
  "首页": "Home",
  "首页SEO描述": "A modern Nuxt 4 starter template with everything you need..."
}

All three locale files (zh-CN / zh-TW / en) must have complete coverage of every key; fall back to the Chinese source when a translation is missing.

5.6 Plural / Gender (advanced)

Plural forms use vue-i18n's $tc with | separators:

// Chinese has no singular/plural distinction; all three branches stay in Chinese
$tc('{n} 条评论 | {n} 条评论 | {n} 条评论', count, { n: count })

// Other locales expand the rules
// en.json: { "{n} 条评论 | {n} 条评论 | {n} 条评论": "no comments | one comment | {n} comments" }

6. Rendering Strategy Declaration

💡 Quick start: Rendering Mode Quick Guide (5-minute decision matrix + detail-page decision tree + anti-pattern cheatsheet). This chapter 6.x lists the mandatory rules; for deep theory see Rendering Strategy Guide (Deep Dive).

Framework positioning slogan: ISR first + SSR guards paid content + CSR for user state + Hybrid hydrate for mixed pages. Detailed business profile + decision matrix in the Quick Guide.

6.1 Mandatory Rule

Every app/pages/**/*.vue must call defineRouteRules() at the top level of <script setup>. ESLint enforces this; missing calls are errors.

A page without an explicit rendering strategy triggers full server-side rendering on every request — the leading cause of high CPU on the Node server. Used properly, ISR turns most pages into static HTML responses, multiplying response speed.

Additional hard constraints (auto-enabled by @swift-web/framework/eslint):

  • Paid content body (swift-web/no-isr-on-paid-paths, 9 default segments: /read/**, /play/**, /chapter/**, /video/**, /watch/**, /audio/**, /episode/**, /player/**, /viewer/**): isr / swr / prerender are forbidden; only ssr: true or ssr: false is allowed. Reason: prevent DOM-tampering paywall bypass (WSJ-style enforcement)
  • User-state / real-time interaction (swift-web/no-isr-on-user-state-paths, 14 default segments: /mine/**, /orders/**, /settings/**, /chat/**, /live/**, /profile/**, /account/**, /wallet/**, /vip/**, /subscription(s)/**, /dashboard/**, /message(s)/**): must use ssr: false

These are the default segment names; appliers can override them via the two rules' segments options.

  • No query reads on cacheable pages (swift-web/no-query-in-cacheable-ssr): pages declaring isr / prerender / swr / cache must not read route.query on the SSR execution path. The HTML cache is keyed path-only at the reverse-proxy layer (all query strings ignored), so reading query means ?order=hot hits the cache entry produced for ?order=new — content mismatch. See the Rendering Strategies guide for the fixes and the boundary requirements for path segments.
  • No ineffective routeRules (swift-web/no-ineffective-route-rules): never write swr / cache / static inside defineRouteRules, and never write an empty object {}. Caching is always declared as isr; framework translates it at build time into the cache rule that node-server actually consumes.

6.2 Strategy Quick Reference + Decision Tree

StrategyCodeWhen to use
ISR (incremental static regeneration){ isr: 3600 }Pages updated on a regular cadence (lists / home)
ISR (detail / static pages){ isr: 3600 }Infrequently updated detail and static pages (upper bound; isr: true was removed, fw#414)
SSG (prerender){ prerender: true }Truly static content (about, terms)
CSR (client-only){ ssr: false }Login-required personal pages
SSR (per-request render){ ssr: true } or {}Requires real-time content (use sparingly)

Decision tree:

Business traitRecommended strategyKey reason
0–2 stable params (category / pagination / tag)RESTful URL paths + ISRNormalized cache keys + SEO-friendly + single canonical
3+ filter dimensions (search / faceted filters)CSR (ssr: false)Cache explosion + duplicate-content risk + low SEO upside
Detail page (stable content)ISR with long maxAge / on-demand revalidateHigh hit rate; SEO is mandatory
List / content homepageISR with short maxAgeBalance freshness vs. hit rate
User-state pages (profile / admin / orders)CSR (ssr: false)Sensitive data — ISR is forbidden
Error pages / 404SSR + useSeo({ robots: 'noindex,nofollow' })Avoid baking errors into cache + prevent SEO leakage
Rarely-changing pages (about / terms)prerender: true (SSG)Generated once at build time, zero runtime cost; not for dynamic params

Detailed decisions and pitfalls: Nuxt rendering modes guide.

6.3 How ISR Works

1st request   → SSR → HTML cached → returned to user
Within TTL    → cached HTML returned directly (zero Node.js cost)
After TTL     → next request still gets the stale cache (no user wait)
                meanwhile re-render in background → cache updated
  • isr: 3600: the upper bound, revalidated in the background once an hour. Recommended for detail and static pages. (isr: true was removed, fw#414)
  • isr: 600 / isr: 1800: recommended for home / list pages (new content shows within 10–30 minutes; isr only accepts an integer between 60 and 3600)

6.4 Code Examples per Strategy

<!-- Good: detail page — infrequent updates, ISR forever -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
</script>
<!-- Good: home / list page — refresh hourly -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
</script>
<!-- Good: about / privacy — fixed content, generated at build time -->
<script setup lang="ts">
defineRouteRules({ prerender: true })
</script>
<!-- Good: profile / favorites / history — login required, client-only -->
<script setup lang="ts">
defineRouteRules({ ssr: false })
</script>
<!-- Good: rare cases that genuinely need real-time data (search results, order list) -->
<script setup lang="ts">
defineRouteRules({ ssr: true })
</script>

Note: defineRouteRules is a compile-time macro — its argument must be a literal object; variables are not allowed. For batch rules across many routes, declare them centrally in nuxt.config.ts's routeRules; the page-level declaration takes precedence.

6.5 The Worst Risk: User-State Leakage (read this first)

ISR bakes the HTML seen by the first visitor into a cache shared with everyone. If the first visitor is logged in, their private data gets broadcast to every subsequent visitor — that's a security / privacy incident, far more serious than cache explosion or stale content.

Severity ranking: user-state leakage > cache fingerprint mismatch (performance) > cache content pollution (functional).

Code patterns that cause leakage (any of these on an ISR / prerender page is high-risk):

  • useFetch forwards cookies to the backend by default → backend reads the cookie and returns personalized data.
  • Calls to a private API (marked x-sw-visibility: private by the backend; OpenAPI sources expose app/api/visibility.json, YAPI sources fall back to the runtime L5 guard).
  • useRequestHeaders(['cookie', 'authorization']) reading user-state headers.
  • event.context.user (a user object injected by server middleware).
  • Private pages (profile / orders / admin) without ssr: false.

Four lines of defense (in priority order):

  1. Mark user-state pages with ssr: false (simplest).
  2. Physically split Public / Private APIs and force-split mixed pages (see §8).
  3. Constrain with Nitro cache.varies.
  4. Push variables into the URL path.

Self-check rule: the HTML output must be identical for all logged-out users. Anything that varies "by user" (login state / member price / favorites / orders / progress) goes CSR.

6.6 ISR Pitfalls Quick Reference

A. Cache fingerprint mismatch:

  • POST body is not part of the cache key — use GET or RESTful paths for paging / filtering.
  • Query order isn't normalized — high-cardinality queries cause cache explosion.
  • Context-sensitive params (i18n / login state / UA / multi-tenant host / geo) not in URL → must move to URL or cache.varies.

B. Cache content pollution:

  • Error responses get baked into the cache — when useFetch fails, you must throw createError(...).
  • set-cookie from an ISR page does not enter the cache → personalized side effects must move to the client.

C. Strategy anti-patterns:

  • Detail page on SSR → switch to ISR.
  • Profile page on ISR → switch to CSR.
  • High-cardinality filtering on ISR → switch to CSR.

D. SSR runtime environment (see §10):

  • Top-level access to window / document in runtime → SSR 500.
  • Cross-component shared state with ref → cross-request bleed; use useState instead.

F. Nitro defaults:

  • swr: true is on by default.
  • maxAge: 1 second by default.
  • cache key = path + searchParams; cookies / headers only enter the key via explicit varies.

6.7 Self-Check Checklist

  • Page has an explicit defineRouteRules({ ... }).
  • The SSR execution path of ISR pages calls no private API (calls inside onMounted / event handlers are fine).
  • ISR pages don't destructure ≥ 3 entries from useRoute().query.*.
  • ISR pages page through GET, not POST.
  • Error page has useSeo({ robots: 'noindex,nofollow', silenceLengthWarnings: true }).
  • Runtime modules don't access window / document / localStorage at the top level.
  • Cross-component shared state uses useState, not ref.

6.8 Standard Page Skeleton

A reference template:

<script setup lang="ts">
// 1. Rendering strategy (required; ESLint enforces this)
defineRouteRules({ isr: 3600 })

// 2. Page metadata
definePageMeta({ layout: 'default' })

// 3. Routing and i18n
const route = useRoute()
const { t } = useI18n()

// 4. Data fetching (await at the top of setup so SSR has the data)
const { data: product, status, error } = await useAsyncData(
  `product-${route.params.id}`,
  () => api.product.detail({ id: route.params.id }),
)

// 5. SEO (use useSeo — covers og / twitter / canonical / robots)
useSeo({
  title: product.value?.seo?.title ?? product.value?.name,
  description: product.value?.seo?.description ?? product.value?.summary,
  image: product.value?.cover,
  type: 'article',
  article: {
    author: product.value?.author,
    datePublished: product.value?.publishedAt,
    dateModified: product.value?.updatedAt,
  },
})

// 6. Error backstop: always throw createError so error pages don't get baked into ISR
if (error.value) {
  throw createError({
    statusCode: error.value.statusCode ?? 500,
    statusMessage: error.value.statusMessage ?? '商品加载失败',
  })
}
</script>

<template>
  <div class="sw-product-detail">
    <ProductDetailSkeleton v-if="status === 'pending'" />
    <ProductDetailContent v-else :product="product" />
  </div>
</template>

<style scoped>
.sw-product-detail {
  padding: 1.5rem;
  background: var(--background);
}
</style>

7. SEO Standards

7.1 useSeo() Replaces useSeoMeta()

framework ships a useSeo() composable that supersedes raw useSeoMeta(). useSeo() covers og / twitter / canonical / JSON-LD / robots / dev-time length warnings out of the box, and is the current recommended API.

Hard rule: every app/pages/**/*.vue and app/error.vue must call useSeo(); ESLint swift-web/require-useSeo-in-pages backstops this (missing calls are errors).

// Good: useSeo
useSeo({
  title: '商品详情',
  description: '高品质优选商品,限时折扣',
  image: '/og/product.jpg',
  type: 'article',
  article: {
    author: '张三',
    datePublished: '2026-01-01',
    dateModified: '2026-05-08',
  },
})

// Bad: using useSeoMeta directly
useSeoMeta({
  title: '商品详情',
  description: '高品质优选商品,限时折扣',
  ogTitle: '商品详情',
  ogDescription: '高品质优选商品,限时折扣',
  ogImage: '/og/product.jpg',
  twitterCard: 'summary_large_image',
  // ↑ og / twitter / canonical all hand-wired — repetitive and easy to forget
})

MR review template: This page uses useSeoMeta() — switch to useSeo(). It already covers og / twitter / canonical / JSON-LD / robots, so manual wiring isn't needed.

7.2 noindex on Private / Transitional Pages

Private pages (login / register / mine / profile/* / admin / orders, etc.) must use useSeo({ robots: 'private' }):

// Good: private page — noindex + silence length warnings
useSeo({
  title: '登录',
  description: '登录 Swift Web',
  robots: 'private',          // equivalent to noindex,nofollow
  silenceLengthWarnings: true, // private pages have naturally short title/desc; mute the warning
})

// Good: transitional / redirect page — noindex
useSeo({
  title: '正在跳转',
  robots: 'noindex',
  silenceLengthWarnings: true,
})

// Good: error page — noindex,nofollow
// app/error.vue
useSeo({
  title: '页面不存在',
  robots: 'noindex,nofollow',
  silenceLengthWarnings: true,
})

Why does every private / noindex page need silenceLengthWarnings: true? Their title/desc are naturally short, so the dev-console length warnings carry no signal — they spam the console and drown out real warnings.

7.3 Full SEO Fields for a Public Page (detail page)

useSeo({
  // ── Basics ──
  title: data.value?.seo?.title ?? data.value?.name,
  description: data.value?.seo?.description ?? data.value?.summary,
  keywords: data.value?.tags?.slice(0, 6).join('、'), // ≤ 10 keywords

  // ── Share-card image (required; detail pages use a real cover) ──
  image: data.value?.cover ?? '/og/default.jpg',

  // ── Article schema (required for detail pages) ──
  type: 'article',
  article: {
    author: data.value?.author,
    datePublished: data.value?.publishedAt,
    dateModified: data.value?.updatedAt,
  },

  // canonical defaults to the current URL — usually no need to set it manually
})

7.4 Field Length Recommendations

FieldRecommended lengthNotes
title10–70 charactersToo short: invisible in SERPs; too long: truncated by engines.
description50–200 charactersToo short: wastes optimization headroom; too long: truncated.
keywords≤ 10 items / 200 charsKeyword stuffing is counter-productive.

For deeper best practices (Core Web Vitals, semantic HTML, Schema.org, GEO, etc.) see Swift Web SEO best practices.


8. Public / Private API Separation

Full guide: Rendering modes guide §9.

8.1 The semantic red line (this is the whole chapter)

The SSR execution path of an ISR / prerender page must not call a private API.

private = the response varies by user (login state, identity, member pricing, favorite status, orders…). ISR bakes what the first visitor sees into a cache shared by everyone — mixing private data in means that first visitor's data is broadcast to all visitors. That is a security / privacy incident, far worse than cache explosion or stale data.

"SSR execution path" means the synchronous top level of <script setup>, plus the direct callbacks of SSR wrappers such as useAsyncData / useFetch. Calls inside event handlers, onMounted, or ordinary function bodies are client-triggered and not covered — that is precisely the legitimate shape used by the mixed-page split below.

The historical convention is dead: an early design used physical app/api/{public,private}/ directories plus a useFooPrivateApi naming prefix. Once codegen landed, the shape of app/api/ is decided by the generator, and no repository ever adopted that design. Do not write code or review PRs against it.

8.2 How you know an API is private

The source of truth is the backend annotation, not a frontend directory name:

Backend sourceAnnotationFrontend artifact
OpenAPI (self-built server)endpoints carry x-sw-visibility: public | privategenerate-api emits app/api/visibility.json (<module>.<fn> → public | private) and adds @swVisibility to the generated JSDoc
YAPI (legacy company backend)no annotation slotno manifest — the static checks go dark; the runtime L5 guard in §8.6 takes over

visibility.json exists only for OpenAPI-sourced projects. The legacy company backend uses YAPI and cannot carry the annotation, so isr-no-private-api in §8.5 silently no-ops there. Those projects are not unprotected, though — the runtime L5 guard in §8.6 needs no backend annotation at all and is their primary line of defence.

Building your own backend? Serve the API spec as OpenAPI and mark endpoints with x-sw-visibility, and you additionally get two development-time machine checks — which catch problems far earlier than L5 alone. See Codegen · generate-api dual sources.

The real shape of app/api/ is decided by generate-api (index.ts / modules/ / types/, plus visibility.json for OpenAPI sources) and must not be hand-edited. You consume it through the auto-imported global api object (api.movie.detail()) — pages contain no import statements for it.

8.3 Mixed-data pages must be split

Pages whose body is public but which also show private bits (detail pages, most commonly):

  • the main request uses a public API + ISR cache (SSR path);
  • the private extras (is_favorited, member price, personal recommendations, progress) are fetched separately after onMounted, client-side, and merged in.

8.4 Bad / good

// ❌ ISR page calling a private API on the SSR path — the user's orders get baked into the
//    shared cache and served to everyone
defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData(() => api.user.orders())

// ❌ Body and private extras in one request — is_favorited gets cached along with the body
const { data } = await useAsyncData(() =>
  api.movie.detail({ id, userId: useUserStore().info?.user_id }),
)
// ✅ Only public APIs on the SSR path
defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData(() => api.movie.detail({ id }))

// ✅ Private extras fetched client-side (a client path — the linter stays quiet, and it really is safe)
const isFavorited = ref(false)
onMounted(async () => {
  isFavorited.value = await api.movie.hasFavorite({ id })
})

8.5 ESLint enforcement (shipped)

framework guards "ISR must not touch user-state / paid content" mechanically by route path segment, via two rules under @swift-web/framework/eslint, error by default in recommended:

  • swift-web/no-isr-on-paid-paths: paid-content path segments (read / play / video / watch / audio, etc.; overridable via segments) forbid isr / swr / prerender.
  • swift-web/no-isr-on-user-state-paths: user-state path segments (mine / orders / wallet / vip, etc.; overridable via segments) must set ssr: false.

Two more routeRules rules ship in the same recommended preset, also at error:

  • swift-web/no-ineffective-route-rules: swr / cache / static and empty objects are rejected inside defineRouteRules. The first three are valid nitro keys, but every rule, doc and decision tree in this ecosystem is written around isr — writing them directly only makes the intent harder to read for both humans and AI, with zero upside. An empty object is "declaring nothing at all".
  • swift-web/no-query-in-cacheable-ssr: cacheable pages (isr / prerender / swr / cache) must not read route.query on the SSR path. This is the precondition for the path-only cache key at the reverse-proxy layer — reading query lets different parameters share one cache entry, producing content mismatch. See the Rendering Strategies guide.
    Detection is deliberately conservative (prefers false negatives over false positives): only three positions that provably evaluate during SSR are reported — a direct read at <script setup> top level, inside a computed(() => route.query.x) callback, and inside the direct callback of an SSR data wrapper (useAsyncData / useFetch / callOnce, …). Any ordinary function body (onMounted, event handlers, watch callbacks) is always allowed — that is precisely the CSR-backfill shape this rule recommends.

    ⚠️ computed is the main battleground: the shape appliers actually write is const order = computed(() => String(route.query.order ?? '')). Do not assume "it's lazy, so it isn't SSR" — once an SSR path consumes it, it runs during SSR.

They match by route path, complementing isr-no-private-api's match by the API you call: the latter is precise but needs visibility.json (OpenAPI sources only), while these two are coarse-grained yet work with any source.

  • swift-web/isr-no-private-api: the direct machine check for the red line in §8.1. It reads app/api/visibility.json (path overridable via the manifest option); it reports when the same file declares isr / prerender and a private API call appears on the SSR execution path, and stays quiet for client-triggered paths. ⚠️ It silently no-ops when the manifest is absent (zero impact on YAPI-sourced projects — meaning that check effectively does not exist for them; L5 below is what covers them).

8.6 Runtime guard L5: cacheable pages must not carry user identity on the SSR path

This is the only layer that works for YAPI-sourced projects, and the last line of defence for every project. It depends on no backend annotation whatsoever.

The incident it guards against: the very first visitor of an ISR page happens to be logged in → their token rides along with the SSR request → the backend returns personalised data (is_favorited, member price, progress) → that gets baked into the shared cache and broadcast to every visitor. Local dev never reveals it (dev does not cache).

The criterion is not "is this a private endpoint" but "can this render vary by user" — the same /movie/detail may return different content depending on whether a token is attached. And "is identity attached" is a precise, universal, zero-backend-dependency signal — which is exactly why it covers legacy YAPI projects.

request issued during SSR
  ├─ no token?                  → whole block skipped (anonymous visitor, zero overhead)
  ├─ is this page cacheable?    → check routeRules; any of isr / prerender / cache / swr counts
  │                               (if the route context is unavailable — e.g. a nitro task —
  │                                it declines to judge, avoiding false positives)
  └─ yes ⇒ tiered handling
       ├─ dev + real logged-in user (has user_id) → throw immediately, caught on the spot
       ├─ dev + device-anonymous only (no user_id) → console.warn, page keeps working
       └─ both dev and prod: strip the token and re-send
            └─ backend answers errorCode 2002 ("login required") → **throw explicitly**,
               never fall through to the silent re-login retry

Why stripping is safe: measured against the live legacy backend, all 7 public endpoints returned status=y without a token ⇒ it authenticates per endpoint, not at the gateway. Stripping the token therefore cannot break a healthy ISR page; only endpoints that genuinely depend on identity complain.

Why 2002 must throw: when a private endpoint of the legacy backend receives no token it answers HTTP 200 + errorCode 2002, not a 4xx. nitro's "status ≥ 400 is never cached" safeguard only looks at the HTTP status — a 200 is cached happily. On top of that, a request whose identity was just stripped must not go through the re-login retry (retrying would add the identity back, defeating the guard), hence the dedicated throwing branch.

Known boundary: L5 only covers framework's request() channel. When an app calls the backend with a raw useFetch / $fetch, Nuxt forwards cookies by default and L5 cannot see it. Always fetch through request() (or the api.* functions emitted by generate-api) on cacheable pages — do not bypass it.


9. Prefer Framework Components (don't reinvent the wheel)

If a PR introduces a custom implementation that overlaps in functionality with an existing framework component / utility, switch to the framework version.

Sign of a custom buildFramework alternative to use
Native <img> / hand-wrapped imageSwImage (built-in ratio + lazy loading + avif/webp srcset)
Native <button> / hand-wrapped buttonSwButton (standard button, pair with to prevent double-submit)
Native <a> / manual route pushSwLink (default for all links; <NuxtLink> only for the named custom v-slot exception)
Custom modal / dialogSwModal
Custom toast / messageuseToast()
Custom confirm dialoguseModal()
Theme switchinguseTheme()
AES encryption / decryptiondecryptBinary / decryptBase64 / encryptDynamic
MD5 / file hashingmd5 / md5File
IndexedDB opsdbPut / dbGet / dbClear
Local cache (with encryption)useLocal / useSession
HTTP requestsrequest / createApiFetch (unified AES + error handling)
Chunked file uploaduseUploader / createUploader
QR code generation / scanningSwQrcodeGenerator / SwQrcodeScanner
Video / audio playersSwVideoPlayer / SwAudioPlayer / SwVideoPreview
Rich-text editorSwEditor
Form field wrappersSwFormField
File uploadersSwUploadImage / SwUploadVideo
Global stateuseUserStore / useSystemStore (don't roll a new user/system store)
SEO metauseSeo() (built-in og / twitter / canonical / JSON-LD / robots)

Bad:

<!-- Bad: custom <img> — no ratio, no lazy loading, no modern formats -->
<template>
  <img :src="product.cover" :alt="product.name" />
</template>

Good:

<!-- Good: SwImage instead -->
<template>
  <SwImage
    :src="product.cover"
    :alt="product.name"
    :ratio="16 / 9"
    loading="lazy"
  />
</template>

MR review template: This <img> is missing ratio + lazy loading + avif/webp srcset — switch to SwImage. See framework/CLAUDE.md §6 + components/sw-image.


10. SSR Safety

10.1 No Top-Level Browser API Access

Top-level access to document / window / localStorage / sessionStorage in <script setup> (or in framework's src/runtime/) blows up during SSR.

// Bad: top-level access to window
<script setup lang="ts">
const userAgent = window.navigator.userAgent  // ReferenceError during SSR
const stored = localStorage.getItem('token')   // same
</script>

// Good: guard with onMounted (client-only)
<script setup lang="ts">
const userAgent = ref('')
const stored = ref<string | null>(null)
onMounted(() => {
  userAgent.value = window.navigator.userAgent
  stored.value = localStorage.getItem('token')
})
</script>

// Good: guard with import.meta.client
<script setup lang="ts">
let stored: string | null = null
if (import.meta.client) {
  stored = localStorage.getItem('token')
}
</script>

10.2 Use useState (not ref) for Cross-Component Shared State

// Bad: a module-level ref bleeds state across requests
// app/composables/useCart.ts
const items = ref<CartItem[]>([])
export function useCart() { return { items } }

// Good: useState gives each request its own instance
// app/composables/useCart.ts
export function useCart() {
  const items = useState<CartItem[]>('cart-items', () => [])
  return { items }
}

Why? Nuxt SSR runs as a single process serving many requests. A module-level ref is a singleton, so multiple users' requests share the same ref instance, leading to data bleed. useState binds state to the current request context, isolating it per request.

MR review template: Top-level window access blows up during SSR. Move it inside onMounted or guard it with import.meta.client.


11. Git Collaboration

11.1 Branch Naming

The branch model is the GitLab feature-branch flow — recommended and the default, not the only permitted way; the mechanical layer only protects the two fixed branches themselves: dev/main (testing line, default branch) and main (production line) are both protected with push = No one (maintainers cannot push either); every change goes through a short-lived branch + MR.

Applies to appliers (downstream business projects). The five internal swift-web repos (framework / cli / starter / docs / standards) actually develop by committing + pushing directly to dev, without MRs.

The full loop for one task:

git switch dev/main && git pull --ff-only          # 1. start from the latest dev/main (local dev/main is pull-only, never commit on it)
git switch -c feat/dark-theme                      # 2. one short-lived branch per task
# …develop, one atomic commit per task…
git push -u origin feat/dark-theme                 # 3. push the branch, open an MR → dev/main
git switch dev/main                                # 4. switch back right after opening the MR (switch back only if the bot asks for changes)
# MR merged → remote branch deleted automatically; locally: git pull --ff-only && git branch -D feat/dark-theme

Releases: dev/main → main is one MR (no splitting or squash), using the current review/check gates. CI does not deploy; full E2E runs locally with evidence, and a maintainer/operator manually deploys outside CI. Hotfixes go through an MR into dev/main, then a release MR.

Long-lived dev/vN branches are retired (2026-08, standards#51). Their selling point was "unprotected, push freely" — but a short-lived branch is just as free to push to. What dev/vN added was only "long-lived + shared", and every problem came from exactly that: it cannot be an MR source (merging deletes the source branch), cherry-picking forks its SHAs from dev/main forever, git log overstates what is unmerged, finished work sits on the branch invisible to CI — until we had to write a sync-devmain tool just to offset the model's inherent defect. Retirement steps for existing dev/v1 branches are in 11.10.

PrefixPurposeExample
feat/New featurefeat/sw-image-avif-srcset
fix/Bug fixfix/login-redirect-loop
perf/Performance (no behaviour change)perf/image-srcset
style/Formatting / style only, no logic changestyle/prettier-reflow
build/Build / bundling configbuild/vite-7
refactor/Refactor (no new feature, no bug fix)refactor/use-fetch-error-handling
chore/Tooling / dependencies / non-CI config / rules syncchore/fw-0.140.0
docs/Docs onlydocs/deploy-runbook
test/Tests onlytest/routes-smoke-samples
ci/CI onlyci/e2e-gate-split
hotfix/Emergency fix (still an MR into dev/main, then a release MR)hotfix/payment-callback-500
epic/Short-lived integration branch for work spanning several MRs (where a dev/v2 used to be opened): sub-branches merge into it, it periodically runs git merge origin/dev/main, and when done it lands in dev/main as one MR and is deleted; it has an end, and is never named dev/v*epic/v2-wallet

Naming rules:

  • Lowercase + hyphens — no underscores / camelCase / non-ASCII
  • Description part ≤ 30 characters
  • Keep the description to a noun phrase (the verb lives in the prefix): feat/dark-theme, not feat/add-dark-theme-support
  • The branch name may carry the issue number: fix/123-login-redirect
  • The prefix is a convention, not a gate: the first step of the verify job only prints a ⚠️ warning (with a suggested rename) for source branches that don't follow it — it never fails the pipeline or blocks the merge; names GitLab generates itself (revert-<sha> / cherry-pick-<sha> from the Revert / Cherry-pick buttons, <iid>-<slug> from an issue's "Create merge request" button) count as compliant and are kept as they are

Anti-patterns:

Anti-patternProblemFix
feature_dark_themeUnderscore + wrong prefixfeat/dark-theme
fix/LoginBugcamelCase + vague descriptionfix/login-redirect
feat/添加暗色主题切换功能Non-ASCII + too longfeat/dark-theme
Committing on dev/mainRejected by pre-commit; the server rejects the push toogit switch -c feat/xxx + MR
dev/v2 long-lived branchThe long-lived-branch workflow is retired (the name itself is not banned)Cut a short-lived branch from origin/dev/main
release/0.6.0Releases no longer get a branchdev/main → main MR + prod/* tag

11.2 Commit Message Format

A simplified Conventional Commits flavor:

<type>(<scope>): <description in the project's documentation language>

[optional body: explain why; the diff explains what]

[optional footer: Closes #N / Refs #N / Co-Authored-By: ...]

Format rules:

  • Subject is non-empty and ≥ 5 characters — enforced by the commit-msg hook (dist/commit-msg/cli.js from @swift-web/standards; empty messages or too-short subjects like wip / fix / . are rejected outright with exit 1).
  • Description must be substantive: placeholder text like update / 修改 / fix bug is forbidden, as are descriptions unrelated to the change (the hook can't catch this mechanically — it relies on review and AI-collaboration discipline).
  • Subject ≤ 72 characters (GitLab UI single-line cutoff).
  • No period at the end of the subject.
  • Body ≤ 100 chars per line (avoid horizontal scroll).
  • Blank line between subject and body.
  • The description body is written in the team's primary language (Chinese for this project); technical terms / API names / package names stay in English.
  • type / scope / footer trailers stay in English (toolchain parsing requires it).

Note: the commit examples in this document use Chinese descriptions to match the team's working language. English-speaking projects should adapt the description accordingly while keeping the same structure.

11.3 Type Enum

TypeUse case
featNew feature or page
fixBug fix
perfPerformance optimization (no behavior change)
refactorRefactor (no new feature, no bug fix)
styleFormatting (indent / spaces / semicolons); no logic change
testAdd / modify tests
docsDocumentation (CLAUDE.md / wiki / README)
choreBuild / dependency / config changes
ciCI/CD changes
revertRevert

11.4 Scope Suggestions

Use kebab-case scopes that point to the submodule / topic of the change — never a file path.

Example scopeModule
sw-imageframework SwImage component
use-modalframework useModal composable
requestframework HTTP request util
nuxt-configproject nuxt.config.ts
eslintESLint config
authbusiness module: login / auth
orderbusiness module: orders
seobusiness module: SEO

When a scope isn't obvious, omit it: feat: 支持暗色主题.

11.5 Commit Examples

Minimal:

feat(产品页): 添加产品详情页路由与数据获取
fix(导航): 修复移动端菜单关闭后滚动锁定问题
perf(图片): 使用 SwImage 替换所有原生 img 标签
chore(依赖): 升级 @swift-web/framework 至 v0.7.x

Full (with body + footer):

feat(sw-image): 新增 avif srcset 支持

旧版只输出 webp,部分新浏览器优先 avif(体积更小 30%)。
本次扩充 SwImage 的 generator script,构建期同时输出 avif/webp/jpg 三档。

Closes #88
fix(rendering): ISR 页面 useFetch 错误响应不再被烙缓存

发现 manga 详情页偶发 500 错误时,错误页被 ISR 缓存到所有用户。
统一在 useFetch 后加 throw createError 兜底,让 Nitro 不缓存错误响应。

Refs #102

11.6 Issue Linking

# Good: close an issue (auto-closed on merge)
git commit -m "fix(login): 修正 redirect 死循环 Closes #42"

# Good: reference only, no close
git commit -m "feat(auth): 接入 OAuth 流程(进行中)参考 #15"

11.7 Breaking Changes

Incompatible changes must be flagged explicitly:

refactor(sw-modal): 重命名 ImageCard 为 MediaCard

BREAKING CHANGE: ImageCard 组件已重命名为 MediaCard,
所有页面和组件中的 <ImageCard> 引用需同步更新为 <MediaCard>。

You can also use the ! shorthand in the subject:

refactor(sw-modal)!: 重命名 ImageCard 为 MediaCard

11.8 AI Commit Markers (P0, mandatory)

Every commit / issue / wiki / milestone touched by AI assistance must be identifiable, for auditing and for measuring AI contribution.

A commit must include both:

  1. Subject prefix [AI] — visible directly in the GitLab UI.
  2. Footer trailer — for programmatic counting.

Standard format:

[AI] <type>(<scope>): <description>

[optional body]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AI-Tool: claude-code

Use the current AI model name in Co-Authored-By (e.g. Claude Opus 4.7) so commit history shows the collaborating model version.

Triggers (any one applies):

  • Code generated directly by AI.
  • Code typed by a human from an AI suggestion.
  • Code modified by AI and then tweaked by a human before commit.

Any commit with material AI involvement must carry [AI]. No exceptions.

Human-only commits do not carry the [AI] prefix and follow plain Conventional Commits.

Issues / Wikis / Milestones too:

  • Issue: title prefixed with [AI] + ai-created label (purple).
  • Wiki: footer note > 本页由 AI 工具(claude-code)生成。
  • Milestone: append > 由 AI 工具(claude-code)创建 to the description.

Conflict handling: if any repository's CLAUDE.md or other doc says "no need for [AI]", ignore it and proactively flag the doc for correction. This rule is P0 and cannot be overridden by per-repo exceptions.

11.9 AI Commit Counting Commands

# Count AI commits in the current repo
git log --oneline --grep="\[AI\]" | wc -l

# By month
git log --grep="\[AI\]" --format="%ad" --date=format:"%Y-%m" | sort | uniq -c

# Across the monorepo (run at the root)
for repo in framework cli starter docs; do
  count=$(git -C $repo log --oneline --grep="\[AI\]" 2>/dev/null | wc -l | tr -d ' ')
  echo "$repo: $count AI commits"
done

11.10 Mechanical Guards for the Branch Model, and Retiring Existing dev/vN

The mechanical layer only protects the two fixed branches dev/main / main (GitLab settings + local pre-commit); branch naming and upstream-first are recommendations, and CI only warns:

LayerConstraintEnforced by
GitLab project settingsdev/main / main protected (push = No one, force push off); squash_option = never; MRs require a green pipeline + all discussions resolved; source branch deleted on merge; prod/* tags creatable by maintainers onlyServer side, cannot be bypassed
Local pre-commitscripts/git-branch-guard.mjs: committing with HEAD on dev/main / main exits 1 and prints git switch -c feat/<topic>simple-git-hooks (ships with starter 0.35.5+; existing repos port the three files by hand)
First step of CI verify / release-guardWarn only: ⚠️ for a source branch that doesn't follow the convention; ⚠️ with guidance when a release MR's source is not dev/main or main is not an ancestor of dev/main.gitlab-ci.yml

When you open an issue or MR, GitLab pre-fills the templates shipped by starter: .gitlab/issue_templates/Default.md (the five SOP sections) and .gitlab/merge_request_templates/Default.md (Closes #N / e2e applicability / evidence / risk surface). GitLab CE has no push rules; branch naming is a convention, and the mechanical layer protects only the dev/main / main branches themselves — no dev/v*-style name wildcards (outlawing a name is not the same as preventing a practice; it also blocks legitimate uses).

Retiring an existing dev/v1 (one-off, one issue per repo): confirm nobody is still committing to it → split the real diff into small MRs into dev/main → post the emptiness evidence → delete the branch. Do not judge emptiness with a bare git diff / git cherry / git log dev/main..dev/v1: a 2-dot diff also counts files where dev/main is ahead, and 3-dot / patch-id based checks are fooled by cherry-pick history. Use a throwaway merge:

git fetch origin dev/main dev/v1 -q
git worktree add --detach /tmp/retire-wt origin/dev/v1
git -C /tmp/retire-wt merge --no-edit origin/dev/main   # merge dev/main into a local copy of dev/v1; judge the direction of conflicting hunks first (see below)
git -C /tmp/retire-wt diff origin/dev/main --stat       # empty ⇒ dev/v1 holds nothing dev/main lacks — paste this output into the issue
git worktree remove --force /tmp/retire-wt
git push origin --delete dev/v1                         # irreversible: only after the evidence is posted and the owner signs off

Releases and rollbacks: release and ordinary MRs use the current review/check gates; merging never deploys. CI runs type, lint, unit/coverage and build checks only. Run full E2E locally and bind evidence to the final source SHA. A maintainer/operator manually deploys test and production outside CI, recording the environment, source/artifact, executor and business acceptance. Emergency rollback uses a verified historical commit/artifact, not a retry of a CI deploy job. The standards source and current repository configuration are authoritative.

Identical content on both sides converges inside the merge; whatever remains is the real difference. A conflicting hunk is not "a diff to merge" — it is "a direction to decide": the HEAD side is dev/v1, the origin/dev/main side is the target; find out which side is newer first. dev/v1 is usually the stale side (seen in practice: every conflicting file was the already-fixed / already-reverted version on dev/main), and treating conflicts as "merge everything in" would put reverted bad changes back. Only hunks where dev/v1 is confirmed newer become MRs. If the result is not empty, keep splitting MRs — do not actually push dev/main back into dev/v1 (that was the re-converge of the long-lived-branch era; retirement does not need it).


12. Other Common Nuxt 4 / Vue 3 Best Practices

12.1 Data Fetching

// Good: use useFetch / useAsyncData on the server (gets SSR + hydration optimization)
const { data: products } = await useAsyncData('products', () => api.product.list())

// Bad: direct fetch (loses SSR data prefetch + hydration sync)
const products = await api.product.list()   // bare call, no SSR prefetch

12.2 Always Provide :key in Lists

<!-- Good: stable id as key -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

<!-- Bad: index as key (patches mis-align when order changes) -->
<li v-for="(item, i) in items" :key="i">{{ item.name }}</li>

12.3 Virtualize Large Lists

Use vue-virtual-scroller or similar for 1000+ items; not needed for small lists (< 200).

12.4 Sanitize Before v-html

// Bad: rendering user input directly (XSS risk)
<div v-html="userInput" />

// Good: sanitize with DOMPurify before rendering
import DOMPurify from 'dompurify'
const safeHtml = computed(() => DOMPurify.sanitize(userInput.value))
// <div v-html="safeHtml" />

12.5 Parallelize Top-Level await in setup

// Bad: serial awaits (anti-pattern: B doesn't start until A is done)
const { data: a } = await useFetch('/api/a')
const { data: b } = await useFetch('/api/b')

// Good: parallel
const [{ data: a }, { data: b }] = await Promise.all([
  useFetch('/api/a'),
  useFetch('/api/b'),
])

12.6 No Side Effects Inside computed

// Bad: console.log / mutating other state inside computed
const total = computed(() => {
  console.log('计算 total')   // side effect
  fetchExtra()                 // worse: triggers a request
  return items.value.reduce((s, i) => s + i.price, 0)
})

// Good: side effects belong in watch / watchEffect
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))
watch(total, (n) => {
  if (n > 1000) showCouponHint()
})

12.7 Semantic HTML / Accessibility

  • Link navigation: routing via @click + router.push / + navigateTo makes the destination invisible to crawlers — they only follow <a href>. Default to <SwLink> (<NuxtLink> only for the named custom v-slot exception).
  • Heading hierarchy: one <h1> per page; don't skip levels for <h2>/<h3>; don't pick a heading level for visual size — adjust via class.
  • Semantic elements: use <header> / <nav> / <main> / <article> / <section> / <footer> / <aside> instead of <div> everywhere.
  • Image alt: <SwImage> requires alt (describing content/semantics); use alt="" for decorative images — never omit the attribute.
  • Form labels: every <input> needs an associated <label for=> or aria-label. placeholder does not replace a label.
  • Anchor text: link text should be descriptive (Learn SEO optimization tips is better than click here).
  • External link rel: outbound links should add rel="nofollow"; target="_blank" must be paired with rel="noopener noreferrer" (prevents tab-jacking).
<!-- Bad: onClick navigation, invisible to crawlers -->
<button @click="router.push('/products')">查看商品</button>

<!-- Good: NuxtLink / SwLink -->
<SwLink to="/products">查看商品</SwLink>

<!-- Bad: placeholder used as label -->
<input placeholder="请输入手机号" />

<!-- Good: label + aria-label -->
<label for="phone">手机号</label>
<input id="phone" placeholder="例:13800138000" />

13. Test Sync and Gates

Changing source means changing tests (P0 rule 9). Tests come in two tracks that guard different failure classes: unit tests guard logic branches and data mapping; e2e guards SSR output, response headers, route guards, and real-browser behavior — the failure mode of the latter is "dev / lint / unit all green, blows up in production" (the whole site's SSR switched off into an empty shell, detail pages served the wrong entity, an ISR empty-state message served to crawlers — all real incidents).

Does e2e apply? Judge by the change, not by ratio

One-line test: can the correctness of this change be asserted by a unit test under happy-dom / mocks? If not ⇒ e2e applies.

e2e applies (the same PR must include e2e cases)e2e does not apply (don't pad)
Page-level output: routes / SSR HTML and response headers / SEO tags / sitemap / robotsPure functions / utils / types / constants / config mapping
Anything affecting the SSR first paint or empty state; changing an API response shapeData transforms without UI side effects, build scripts, docs
User flows (login interception, forms, navigation, paywall); redirects / guards; middleware, plugins, server routesPure visual values (tokens / spacing / colors) — use screenshots as evidence
Real-browser-only behavior: scroll / touch / touch-action / IntersectionObserver / computed style

When it doesn't apply, write one explicit line in the issue or MR description — "e2e N/A: " — the judgment must be visible; silent skipping is forbidden, and mr-review checks it against the diff paths (rule TS-001). Guard-type e2e must come with mutation evidence (break the protected thing → red → restore → green).

Where the gates run

The MR verify job runs type, lint, unit/coverage and build checks only. It does not install browser system libraries, run full E2E or deploy. CI evidence covers only the checks it ran. Run pnpm test:e2e locally and record the final source SHA, environment/sample and results in the MR; cross-reviewers still verify independently.

The coverage baseline is a per-repo ratchet that only goes up

coverage.thresholds in vitest.config.ts is the repo's baseline; CI goes red below it. Only lines (primary) and branches (secondary) are gated. New projects default to lines 80 / branches 65 (from the starter template); existing projects set their baseline to their measured value rounded down when they adopt the gate — stop the decline first, then raise in +5 steps; lowering requires a reason in a comment at the same spot plus a linked issue (rule TS-002). coverage.exclude only excludes codegen output — the criterion is "is it generated code", not the directory name.

e2e page coverage is a completeness check, not a percentage

tests/e2e/routes.ts is the route smoke list; routes.smoke.spec.ts asserts 200 / not an empty shell / <title> / s-maxage on cacheable pages / no hydration errors for each entry; pnpm test:e2e first runs check-e2e-routes: every static page must be listed, every dynamic page needs ≥1 sample; a missing page is red — a new page not added to the list fails CI (rule TS-003). Dynamic-route samples: mock repos use fixture ids; real-backend content sites go "first list item → detail" in two hops and test.skip explicitly on an empty database; pages behind a login wall get their session via the "test login injection" convention (env-var test account + tests/e2e/helpers/login.ts obtaining a real token, writing the framework cookies, reusing storageState), and are listed with auth: true when no account exists — visible debt, not a silent skip.

14. Comment Style

13.1 When You Should Comment

  • Why comments: explain why this code is the way it is (business constraint, past pitfall, upstream limitation) — things diffs can't show.
  • Public APIs: JSDoc on exported functions / components / composables (params, return value, throw conditions).
  • Complex regex / bit manipulation / algorithms: a one-line note on intent so future maintainers don't have to reverse-engineer.
  • Temporary workarounds / known issues: mark with // TODO(@user, #issue): ... / // FIXME: ... — must be traceable (issue number or owner).

13.2 When Not to Comment

  • Translating code into prose (// increment i next to i++) — noise.
  • Commented-out old code — use git history; don't keep it in the repo.
  • JSDoc written purely for "coverage metrics" (@param data data etc.).

13.3 JSDoc Conventions

  • Public APIs (exported functions, component props / emits, composable return values) must have JSDoc.
  • Internal functions are at your discretion (write one for complex parameters / side effects / easy-to-misuse cases).
  • JSDoc text uses the project's documentation language; types come from TypeScript (don't repeat @param {string} — TS types already declare them).
/**
 * 上传单个文件并返回服务端签发的 URL。
 *
 * 实现细节:分片大小 4MB,失败自动重试 3 次。
 *
 * @throws 网络故障或服务端拒绝时抛 UploadFailedError
 */
export async function uploadFile(file: File): Promise<string> {
  // ...
}
// Good: TODO carries a traceable owner / issue
// TODO(@djatm, #142): WebSocket 心跳间隔需要随后端配置动态调整,目前先固定 30s
const HEARTBEAT_INTERVAL = 30_000

// Bad: TODO without an owner / issue
// TODO: 这里要改