Skip to content

Commit 5f391d1

Browse files
committed
docs: add skills
1 parent 9867a47 commit 5f391d1

12 files changed

Lines changed: 3125 additions & 0 deletions

File tree

.cursor/skills/nuxt-architecture-review/SKILL.md

Lines changed: 423 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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, '&amp;')
215+
.replace(/</g, '&lt;')
216+
.replace(/>/g, '&gt;')
217+
.replace(/"/g, '&quot;')
218+
.replace(/'/g, '&#039;')
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, '&quot;')
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

Comments
 (0)