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.mdvia 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.mdin the standards repo. This page covers a subset of the source of truth — for the complete set of rules, the standards reporules/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:
| Order | Source | Scope |
|---|---|---|
| 1 | P0 rules / engineering gates at the top of each repo's CLAUDE.md | P0 guardrails / ownership / release / doc sync (highest) |
| 2 | This document (rules/ in standards repo) | Shared baseline for all appliers + standard dev workflow SOP |
| 3 | Project-specific CLAUDE.md rules | Per-repo directory layout, naming, test conventions, internal APIs |
| 4 | Other 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
| Location | Naming Style | Examples |
|---|---|---|
app/components/ | PascalCase | LoginForm.vue / AdSwiper.vue / PaginationBar.vue |
app/pages/ | kebab-case | about-us.vue / product-detail.vue / [id].vue |
app/layouts/ | all lowercase | default.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
| Location | Naming Style | Examples |
|---|---|---|
app/composables/ | use + PascalCase | useModal.ts / useToast.ts / useLoginCheck.ts |
app/store/ | kebab-case | user-info.ts / system-info.ts |
app/plugins/ | kebab-case | analytics.client.ts / error-handler.ts |
app/middleware/ | kebab-case + .global suffix | login.global.ts / guest.global.ts |
app/utils/ | kebab-case | aes-dynamic.ts / format-date.ts |
server/api/ | kebab-case + HTTP method suffix | health.get.ts / user-info.post.ts |
app/api/ | generated by generate-api, never hand-edit | index.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 Type | Style | Example |
|---|---|---|
| Business component | PascalCase | LoginForm.vue |
| Core component | Sw + PascalCase (framework) | SwAsyncText.vue |
| Page file | kebab-case | product-detail.vue |
| Layout file | all lowercase | default.vue |
| Composable file | use + PascalCase | useToast.ts |
| Utils file | kebab-case | aes-dynamic.ts |
| Store file | kebab-case | user-info.ts |
| Middleware file | kebab-case + .global | login.global.ts |
| Plugin file | kebab-case + .client / .server | analytics.client.ts |
| Server API file | kebab-case + HTTP method | health.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:
- 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> - 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> - 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-classfile-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> - Conditional-render multi-branch (
v-ifswitches 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'smulti-word-component-namesrule. 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/eslintships theroot-class-sw-prefixrule (v2: only enforced when aclassis written; roots withoutclassare auto-exempt). Default severity iswarn; CI can hard-fail witheslint --max-warnings=0if you want.@swift-web/standards/prettier+@swift-web/standards/stylelintcentralize base configs (each repo'sprettier.config.ts/stylelint.config.tsconsumes them viaimport 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:
| Category | Form | Meaning | Examples |
|---|---|---|---|
| Child element | noun, plain kebab | "what something is" | .header / .error-msg / .submit |
| State modifier | adjective, 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:
:rootdefaults in framework'stokens.cssalready 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):
| Category | Variables |
|---|---|
| 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 category | Unit |
|---|---|
font-size / letter-spacing / text-indent | rem (a11y-friendly) |
line-height | unitless number (e.g. 1.5); px / rem forbidden |
padding / margin / gap / width / height / top/right/bottom/left | rem |
border-radius ≥ 0.5rem (8px) | rem |
border-radius < 8px (small radius) | px exception (sub-pixel rendering stability) |
border-width / box-shadow / text-shadow offsets | px |
@media query breakpoints | px |
| Physically fixed elements (small icons / dividers, etc.) | px |
em | forbidden (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:
| Breakpoint | Range |
|---|---|
| mobile | < 768px |
| tablet | 768px – 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
anyallowed — switch tounknown+ 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.tsor in the target module'sindex.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)
| Scenario | Key example | Rationale |
|---|---|---|
| Routes / pathnames | routes.home | Tightly bound to URLs; Chinese isn't appropriate. |
| Status enum mapping | status.pending / status.paid | Backend returns English enums; need bidirectional mapping. |
| Very long paragraphs (> 200 chars) | legal.terms.section1 | A Chinese key would hurt code readability. |
| Rich text with HTML tags | tooltip.html.welcome | Source 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/**/*.vuemust calldefineRouteRules()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/prerenderare forbidden; onlyssr: trueorssr: falseis 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 usessr: 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 declaringisr/prerender/swr/cachemust not readroute.queryon 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=hothits 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 writeswr/cache/staticinsidedefineRouteRules, and never write an empty object{}. Caching is always declared asisr; framework translates it at build time into thecacherule that node-server actually consumes.
6.2 Strategy Quick Reference + Decision Tree
| Strategy | Code | When 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 trait | Recommended strategy | Key reason |
|---|---|---|
| 0–2 stable params (category / pagination / tag) | RESTful URL paths + ISR | Normalized 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 revalidate | High hit rate; SEO is mandatory |
| List / content homepage | ISR with short maxAge | Balance freshness vs. hit rate |
| User-state pages (profile / admin / orders) | CSR (ssr: false) | Sensitive data — ISR is forbidden |
| Error pages / 404 | SSR + 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: truewas removed, fw#414)isr: 600/isr: 1800: recommended for home / list pages (new content shows within 10–30 minutes;isronly 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:
defineRouteRulesis a compile-time macro — its argument must be a literal object; variables are not allowed. For batch rules across many routes, declare them centrally innuxt.config.ts'srouteRules; 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):
useFetchforwards cookies to the backend by default → backend reads the cookie and returns personalized data.- Calls to a private API (marked
x-sw-visibility: privateby the backend; OpenAPI sources exposeapp/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):
- Mark user-state pages with
ssr: false(simplest). - Physically split Public / Private APIs and force-split mixed pages (see §8).
- Constrain with Nitro
cache.varies. - 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
useFetchfails, you mustthrow createError(...). set-cookiefrom 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/documentin runtime → SSR 500. - Cross-component shared state with
ref→ cross-request bleed; useuseStateinstead.
F. Nitro defaults:
swr: trueis on by default.maxAge: 1second by default.- cache key =
path + searchParams; cookies / headers only enter the key via explicitvaries.
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/localStorageat the top level. - Cross-component shared state uses
useState, notref.
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 touseSeo(). 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
| Field | Recommended length | Notes |
|---|---|---|
title | 10–70 characters | Too short: invisible in SERPs; too long: truncated by engines. |
description | 50–200 characters | Too short: wastes optimization headroom; too long: truncated. |
keywords | ≤ 10 items / 200 chars | Keyword 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 source | Annotation | Frontend artifact |
|---|---|---|
| OpenAPI (self-built server) | endpoints carry x-sw-visibility: public | private | generate-api emits app/api/visibility.json (<module>.<fn> → public | private) and adds @swVisibility to the generated JSDoc |
| YAPI (legacy company backend) | no annotation slot | no 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 afteronMounted, 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 viasegments) forbidisr/swr/prerender.swift-web/no-isr-on-user-state-paths: user-state path segments (mine/orders/wallet/vip, etc.; overridable viasegments) must setssr: false.
Two more routeRules rules ship in the same recommended preset, also at error:
swift-web/no-ineffective-route-rules:swr/cache/staticand empty objects are rejected insidedefineRouteRules. The first three are valid nitro keys, but every rule, doc and decision tree in this ecosystem is written aroundisr— 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 readroute.queryon 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 acomputed(() => 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.⚠️
computedis the main battleground: the shape appliers actually write isconst 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 readsapp/api/visibility.json(path overridable via themanifestoption); it reports when the same file declaresisr/prerenderand 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 build | Framework alternative to use |
|---|---|
Native <img> / hand-wrapped image | SwImage (built-in ratio + lazy loading + avif/webp srcset) |
Native <button> / hand-wrapped button | SwButton (standard button, pair with |
Native <a> / manual route push | SwLink (default for all links; <NuxtLink> only for the named custom v-slot exception) |
| Custom modal / dialog | SwModal |
| Custom toast / message | useToast() |
| Custom confirm dialog | useModal() |
| Theme switching | useTheme() |
| AES encryption / decryption | decryptBinary / decryptBase64 / encryptDynamic |
| MD5 / file hashing | md5 / md5File |
| IndexedDB ops | dbPut / dbGet / dbClear |
| Local cache (with encryption) | useLocal / useSession |
| HTTP requests | request / createApiFetch (unified AES + error handling) |
| Chunked file upload | useUploader / createUploader |
| QR code generation / scanning | SwQrcodeGenerator / SwQrcodeScanner |
| Video / audio players | SwVideoPlayer / SwAudioPlayer / SwVideoPreview |
| Rich-text editor | SwEditor |
| Form field wrappers | SwFormField |
| File uploaders | SwUploadImage / SwUploadVideo |
| Global state | useUserStore / useSystemStore (don't roll a new user/system store) |
| SEO meta | useSeo() (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 toSwImage. 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
windowaccess blows up during SSR. Move it insideonMountedor guard it withimport.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.
| Prefix | Purpose | Example |
|---|---|---|
feat/ | New feature | feat/sw-image-avif-srcset |
fix/ | Bug fix | fix/login-redirect-loop |
perf/ | Performance (no behaviour change) | perf/image-srcset |
style/ | Formatting / style only, no logic change | style/prettier-reflow |
build/ | Build / bundling config | build/vite-7 |
refactor/ | Refactor (no new feature, no bug fix) | refactor/use-fetch-error-handling |
chore/ | Tooling / dependencies / non-CI config / rules sync | chore/fw-0.140.0 |
docs/ | Docs only | docs/deploy-runbook |
test/ | Tests only | test/routes-smoke-samples |
ci/ | CI only | ci/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, notfeat/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-pattern | Problem | Fix |
|---|---|---|
feature_dark_theme | Underscore + wrong prefix | feat/dark-theme |
fix/LoginBug | camelCase + vague description | fix/login-redirect |
feat/添加暗色主题切换功能 | Non-ASCII + too long | feat/dark-theme |
Committing on dev/main | Rejected by pre-commit; the server rejects the push too | git switch -c feat/xxx + MR |
dev/v2 long-lived branch | The long-lived-branch workflow is retired (the name itself is not banned) | Cut a short-lived branch from origin/dev/main |
release/0.6.0 | Releases no longer get a branch | dev/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.jsfrom@swift-web/standards; empty messages or too-short subjects likewip/fix/.are rejected outright with exit 1). - Description must be substantive: placeholder text like
update/修改/fix bugis 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
| Type | Use case |
|---|---|
feat | New feature or page |
fix | Bug fix |
perf | Performance optimization (no behavior change) |
refactor | Refactor (no new feature, no bug fix) |
style | Formatting (indent / spaces / semicolons); no logic change |
test | Add / modify tests |
docs | Documentation (CLAUDE.md / wiki / README) |
chore | Build / dependency / config changes |
ci | CI/CD changes |
revert | Revert |
11.4 Scope Suggestions
Use kebab-case scopes that point to the submodule / topic of the change — never a file path.
| Example scope | Module |
|---|---|
sw-image | framework SwImage component |
use-modal | framework useModal composable |
request | framework HTTP request util |
nuxt-config | project nuxt.config.ts |
eslint | ESLint config |
auth | business module: login / auth |
order | business module: orders |
seo | business 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:
- Subject prefix
[AI]— visible directly in the GitLab UI. - 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-createdlabel (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:
| Layer | Constraint | Enforced by |
|---|---|---|
| GitLab project settings | dev/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 only | Server side, cannot be bypassed |
| Local pre-commit | scripts/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-guard | Warn 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-scrolleror 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/+ navigateTomakes the destination invisible to crawlers — they only follow<a href>. Default to<SwLink>(<NuxtLink>only for the namedcustomv-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 viaclass. - Semantic elements: use
<header>/<nav>/<main>/<article>/<section>/<footer>/<aside>instead of<div>everywhere. - Image alt:
<SwImage>requiresalt(describing content/semantics); usealt=""for decorative images — never omit the attribute. - Form labels: every
<input>needs an associated<label for=>oraria-label.placeholderdoes not replace a label. - Anchor text: link text should be descriptive (
Learn SEO optimization tipsis better thanclick here). - External link rel: outbound links should add
rel="nofollow";target="_blank"must be paired withrel="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 / robots | Pure functions / utils / types / constants / config mapping |
| Anything affecting the SSR first paint or empty state; changing an API response shape | Data transforms without UI side effects, build scripts, docs |
| User flows (login interception, forms, navigation, paywall); redirects / guards; middleware, plugins, server routes | Pure 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:
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 inext toi++) — noise. - Commented-out old code — use git history; don't keep it in the repo.
- JSDoc written purely for "coverage metrics" (
@param data dataetc.).
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: 这里要改
15. Related Links
- @swift-web/standards — single source of truth for these rules (AI-compact version)
- framework / CLAUDE.md
- Styling & Tokens (full reference)
- Swift Web SEO Best Practices
- Nuxt Rendering Modes Guide
- framework changelog