|
| 1 | +# Component Patterns Reference |
| 2 | + |
| 3 | +## Table of Contents |
| 4 | + |
| 5 | +- [Server/Client Component Split](#serverclient-component-split) |
| 6 | +- [defineModel Patterns](#definemodel-patterns) |
| 7 | +- [Route Aliases](#route-aliases) |
| 8 | +- [Canonical Redirects Middleware](#canonical-redirects-middleware) |
| 9 | +- [XSS-Safe Markdown Rendering](#xss-safe-markdown-rendering) |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Server/Client Component Split |
| 14 | + |
| 15 | +### When to split |
| 16 | + |
| 17 | +| Scenario | Approach | |
| 18 | +| --------------------------------------------------- | ------------------------------------ | |
| 19 | +| Component needs browser APIs (localStorage, window) | `.client.vue` | |
| 20 | +| Component is purely presentational, no JS needed | `.server.vue` | |
| 21 | +| Component has different SSR vs client behavior | Both `.server.vue` and `.client.vue` | |
| 22 | +| Component needs interactivity + SSR | Single `.vue` (default) | |
| 23 | + |
| 24 | +### Example: auth-aware menu |
| 25 | + |
| 26 | +```vue |
| 27 | +<!-- app/components/Header/AccountMenu.server.vue --> |
| 28 | +<template> |
| 29 | + <!-- SSR placeholder: simple login link, no JS --> |
| 30 | + <a href="/login" class="text-sm text-fg-muted">Sign in</a> |
| 31 | +</template> |
| 32 | +``` |
| 33 | + |
| 34 | +```vue |
| 35 | +<!-- app/components/Header/AccountMenu.client.vue --> |
| 36 | +<script setup lang="ts"> |
| 37 | +const { user, logout } = useAuth() |
| 38 | +</script> |
| 39 | +<template> |
| 40 | + <div v-if="user"> |
| 41 | + <img :src="user.avatar" :alt="user.name" /> |
| 42 | + <button @click="logout">Sign out</button> |
| 43 | + </div> |
| 44 | + <a v-else href="/login">Sign in</a> |
| 45 | +</template> |
| 46 | +``` |
| 47 | + |
| 48 | +### How it works |
| 49 | + |
| 50 | +1. During SSR, Nuxt renders `AccountMenu.server.vue` (no JS shipped) |
| 51 | +2. After hydration, Nuxt replaces it with `AccountMenu.client.vue` |
| 52 | +3. The client version has full interactivity and browser API access |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +## defineModel Patterns |
| 57 | + |
| 58 | +### Basic v-model |
| 59 | + |
| 60 | +```vue |
| 61 | +<!-- Child: ToggleSwitch.vue --> |
| 62 | +<script setup lang="ts"> |
| 63 | +const modelValue = defineModel<boolean>({ required: true }) |
| 64 | +</script> |
| 65 | +<template> |
| 66 | + <button @click="modelValue = !modelValue"> |
| 67 | + {{ modelValue ? 'On' : 'Off' }} |
| 68 | + </button> |
| 69 | +</template> |
| 70 | +
|
| 71 | +<!-- Parent --> |
| 72 | +<ToggleSwitch v-model="isDarkMode" /> |
| 73 | +``` |
| 74 | + |
| 75 | +### Named v-model (multiple models) |
| 76 | + |
| 77 | +```vue |
| 78 | +<!-- Child: DataTable.vue --> |
| 79 | +<script setup lang="ts"> |
| 80 | +const sortOption = defineModel<string>('sortOption', { required: true }) |
| 81 | +const pageSize = defineModel<number>('pageSize', { default: 25 }) |
| 82 | +const currentPage = defineModel<number>('page', { default: 1 }) |
| 83 | +</script> |
| 84 | +
|
| 85 | +<!-- Parent --> |
| 86 | +<DataTable v-model:sort-option="currentSort" v-model:page-size="rowsPerPage" v-model:page="page" /> |
| 87 | +``` |
| 88 | + |
| 89 | +### Replacing the old pattern |
| 90 | + |
| 91 | +Before (Vue 3.3 and earlier): |
| 92 | + |
| 93 | +```ts |
| 94 | +const props = defineProps<{ sortOption: string }>() |
| 95 | +const emit = defineEmits<{ 'update:sortOption': [value: string] }>() |
| 96 | +const localSort = computed({ |
| 97 | + get: () => props.sortOption, |
| 98 | + set: v => emit('update:sortOption', v), |
| 99 | +}) |
| 100 | +``` |
| 101 | + |
| 102 | +After (Vue 3.4+): |
| 103 | + |
| 104 | +```ts |
| 105 | +const sortOption = defineModel<string>('sortOption', { required: true }) |
| 106 | +``` |
| 107 | + |
| 108 | +--- |
| 109 | + |
| 110 | +## Route Aliases |
| 111 | + |
| 112 | +### Pattern: multiple URL shapes for one page |
| 113 | + |
| 114 | +```ts |
| 115 | +// app/pages/package-code/[...path].vue |
| 116 | +definePageMeta({ |
| 117 | + name: 'code', |
| 118 | + path: '/package-code/:path+', |
| 119 | + alias: [ |
| 120 | + '/package/code/:path+', // Legacy URL pattern |
| 121 | + '/code/:path+', // Shorthand |
| 122 | + ], |
| 123 | +}) |
| 124 | +``` |
| 125 | + |
| 126 | +All three URLs render the same page component: |
| 127 | + |
| 128 | +- `/package-code/vue/v/3.5.0/src/index.ts` |
| 129 | +- `/package/code/vue/v/3.5.0/src/index.ts` |
| 130 | +- `/code/vue/v/3.5.0/src/index.ts` |
| 131 | + |
| 132 | +### When to use aliases vs redirects |
| 133 | + |
| 134 | +| Scenario | Use | |
| 135 | +| -------------------------------------------- | ---------------------------- | |
| 136 | +| Both URLs are valid and should be accessible | Alias | |
| 137 | +| One URL is canonical, others should redirect | Redirect (middleware) | |
| 138 | +| Backwards compatibility with old URL scheme | Alias (or redirect with 301) | |
| 139 | + |
| 140 | +--- |
| 141 | + |
| 142 | +## Canonical Redirects Middleware |
| 143 | + |
| 144 | +### Full implementation |
| 145 | + |
| 146 | +```ts |
| 147 | +// server/middleware/canonical-redirects.global.ts |
| 148 | + |
| 149 | +// Pages that should NOT be redirected (they have their own routes) |
| 150 | +const reservedPaths = ['/about', '/search', '/settings', '/api', '/package'] |
| 151 | + |
| 152 | +const cacheControl = 's-maxage=3600, stale-while-revalidate=36000' |
| 153 | + |
| 154 | +export default defineEventHandler(async event => { |
| 155 | + const [path = '/', query] = event.path.split('?') |
| 156 | + |
| 157 | + // Skip internal paths |
| 158 | + if (path.startsWith('/~') || path.startsWith('/_')) return |
| 159 | + |
| 160 | + // Skip known page routes |
| 161 | + if (reservedPaths.some(p => path === p || path.startsWith(p + '/'))) return |
| 162 | + |
| 163 | + // /vue -> /package/vue (shorthand package URL) |
| 164 | + const pkgMatch = path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)$/) |
| 165 | + if (pkgMatch?.groups) { |
| 166 | + const parts = [pkgMatch.groups.org, pkgMatch.groups.name].filter(Boolean).join('/') |
| 167 | + setHeader(event, 'cache-control', cacheControl) |
| 168 | + return sendRedirect(event, `/package/${parts}${query ? '?' + query : ''}`, 301) |
| 169 | + } |
| 170 | + |
| 171 | + // /vue@3.5.0 -> /package/vue/v/3.5.0 |
| 172 | + const versionMatch = path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)@(?<version>[^/]+)$/) |
| 173 | + if (versionMatch?.groups) { |
| 174 | + const parts = [versionMatch.groups.org, versionMatch.groups.name].filter(Boolean).join('/') |
| 175 | + setHeader(event, 'cache-control', cacheControl) |
| 176 | + return sendRedirect( |
| 177 | + event, |
| 178 | + `/package/${parts}/v/${versionMatch.groups.version}${query ? '?' + query : ''}`, |
| 179 | + 301, |
| 180 | + ) |
| 181 | + } |
| 182 | +}) |
| 183 | +``` |
| 184 | + |
| 185 | +### Key design decisions |
| 186 | + |
| 187 | +1. **Early returns** -- Skip known paths first to avoid regex evaluation |
| 188 | +2. **Cache-Control on redirects** -- CDN caches the redirect itself, avoiding a server roundtrip |
| 189 | +3. **301 Permanent** -- Tells search engines to index only the canonical URL |
| 190 | +4. **Query preservation** -- Redirects preserve query parameters |
| 191 | + |
| 192 | +--- |
| 193 | + |
| 194 | +## XSS-Safe Markdown Rendering |
| 195 | + |
| 196 | +### Complete inline markdown parser |
| 197 | + |
| 198 | +```ts |
| 199 | +// app/composables/useMarkdown.ts |
| 200 | + |
| 201 | +function stripAndEscapeHtml(text: string): string { |
| 202 | + // Decode HTML entities first |
| 203 | + let stripped = decodeHtmlEntities(text) |
| 204 | + |
| 205 | + // Strip markdown image badges (bounded quantifiers for ReDoS safety) |
| 206 | + stripped = stripped.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '') |
| 207 | + stripped = stripped.replace(/!\[[^\]]{0,500}\]\([^)]{0,2000}\)/g, '') |
| 208 | + |
| 209 | + // Strip HTML tags (keep text content) |
| 210 | + stripped = stripped.replace(/<\/?[a-z][^>]*>/gi, '') |
| 211 | + |
| 212 | + // Escape remaining HTML entities |
| 213 | + return stripped |
| 214 | + .replace(/&/g, '&') |
| 215 | + .replace(/</g, '<') |
| 216 | + .replace(/>/g, '>') |
| 217 | + .replace(/"/g, '"') |
| 218 | + .replace(/'/g, ''') |
| 219 | +} |
| 220 | + |
| 221 | +function parseMarkdown(text: string): string { |
| 222 | + if (!text) return '' |
| 223 | + |
| 224 | + let html = stripAndEscapeHtml(text) |
| 225 | + |
| 226 | + // Bold |
| 227 | + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') |
| 228 | + |
| 229 | + // Italic |
| 230 | + html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>') |
| 231 | + |
| 232 | + // Inline code |
| 233 | + html = html.replace(/`([^`]+)`/g, '<code>$1</code>') |
| 234 | + |
| 235 | + // Strikethrough |
| 236 | + html = html.replace(/~~(.+?)~~/g, '<del>$1</del>') |
| 237 | + |
| 238 | + // Links with protocol validation |
| 239 | + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => { |
| 240 | + try { |
| 241 | + const { protocol, href } = new URL(url) |
| 242 | + if (['https:', 'mailto:'].includes(protocol)) { |
| 243 | + const safeUrl = href.replace(/"/g, '"') |
| 244 | + return `<a href="${safeUrl}" rel="nofollow noreferrer noopener" target="_blank">${text}</a>` |
| 245 | + } |
| 246 | + } catch {} |
| 247 | + return `${text} (${url})` |
| 248 | + }) |
| 249 | + |
| 250 | + return html |
| 251 | +} |
| 252 | +``` |
| 253 | + |
| 254 | +### Security checklist for v-html |
| 255 | + |
| 256 | +1. **Escape HTML entities** before any markdown processing |
| 257 | +2. **Validate URL protocols** -- only allow `https:` and `mailto:` |
| 258 | +3. **Add `rel="nofollow noreferrer noopener"`** to all external links |
| 259 | +4. **Use bounded quantifiers** in regex (e.g., `{0,500}` instead of `*`) |
| 260 | +5. **Strip image badges** that could contain tracking pixels |
| 261 | +6. **Never render raw user input** without sanitization |
0 commit comments