SEO
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 30s

This commit is contained in:
2026-06-09 18:36:09 +02:00
parent 3da7424418
commit 09b44952df
23 changed files with 3652 additions and 8 deletions

View File

@@ -0,0 +1,440 @@
---
name: accessibility
description: Audit and improve web accessibility following WCAG 2.2 guidelines. Use when asked to "improve accessibility", "a11y audit", "WCAG compliance", "screen reader support", "keyboard navigation", or "make accessible".
license: MIT
metadata:
author: web-quality-skills
version: "1.1"
---
# Accessibility (a11y)
Comprehensive accessibility guidelines based on WCAG 2.2 and Lighthouse accessibility audits. Goal: make content usable by everyone, including people with disabilities.
## WCAG Principles: POUR
| Principle | Description |
|-----------|-------------|
| **P**erceivable | Content can be perceived through different senses |
| **O**perable | Interface can be operated by all users |
| **U**nderstandable | Content and interface are understandable |
| **R**obust | Content works with assistive technologies |
## Conformance levels
| Level | Requirement | Target |
|-------|-------------|--------|
| **A** | Minimum accessibility | Must pass |
| **AA** | Standard compliance | Should pass (legal requirement in many jurisdictions) |
| **AAA** | Enhanced accessibility | Nice to have |
---
## Perceivable
### Text alternatives (1.1)
**Images require alt text:**
```html
<!-- ❌ Missing alt -->
<img src="chart.png">
<!-- ✅ Descriptive alt -->
<img src="chart.png" alt="Bar chart showing 40% increase in Q3 sales">
<!-- ✅ Decorative image (empty alt) -->
<img src="decorative-border.png" alt="" role="presentation">
<!-- ✅ Complex image with longer description -->
<figure>
<img src="infographic.png" alt="2024 market trends infographic"
aria-describedby="infographic-desc">
<figcaption id="infographic-desc">
<!-- Detailed description -->
</figcaption>
</figure>
```
**Icon buttons need accessible names:**
```html
<!-- ❌ No accessible name -->
<button><svg><!-- menu icon --></svg></button>
<!-- ✅ Using aria-label -->
<button aria-label="Open menu">
<svg aria-hidden="true"><!-- menu icon --></svg>
</button>
<!-- ✅ Using visually hidden text -->
<button>
<svg aria-hidden="true"><!-- menu icon --></svg>
<span class="visually-hidden">Open menu</span>
</button>
```
**Visually hidden class:**
```css
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
```
### Color contrast (1.4.3, 1.4.6)
| Text Size | AA minimum | AAA enhanced |
|-----------|------------|--------------|
| Normal text (< 18px / < 14px bold) | 4.5:1 | 7:1 |
| Large text (≥ 18px / ≥ 14px bold) | 3:1 | 4.5:1 |
| UI components & graphics | 3:1 | 3:1 |
```css
/* ❌ Low contrast (2.5:1) */
.low-contrast {
color: #999;
background: #fff;
}
/* ✅ Sufficient contrast (7:1) */
.high-contrast {
color: #333;
background: #fff;
}
/* ✅ Focus states need contrast too */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
```
**Don't rely on color alone:**
```html
<!-- ❌ Only color indicates error -->
<input class="error-border">
<style>.error-border { border-color: red; }</style>
<!-- ✅ Color + icon + text -->
<div class="field-error">
<input aria-invalid="true" aria-describedby="email-error">
<span id="email-error" class="error-message">
<svg aria-hidden="true"><!-- error icon --></svg>
Please enter a valid email address
</span>
</div>
```
### Media alternatives (1.2)
```html
<!-- Video with captions -->
<video controls>
<source src="video.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English" default>
<track kind="descriptions" src="descriptions.vtt" srclang="en" label="Descriptions">
</video>
<!-- Audio with transcript -->
<audio controls>
<source src="podcast.mp3" type="audio/mp3">
</audio>
<details>
<summary>Transcript</summary>
<p>Full transcript text...</p>
</details>
```
---
## Operable
### Keyboard accessible (2.1)
**All functionality must be keyboard accessible:**
```javascript
// ❌ Only handles click
element.addEventListener('click', handleAction);
// ✅ Handles both click and keyboard
element.addEventListener('click', handleAction);
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAction();
}
});
```
**No keyboard traps.** Users must be able to Tab into and out of every component. Use the [modal focus trap pattern](references/A11Y-PATTERNS.md#modal-focus-trap) for dialogs—the native `<dialog>` element handles this automatically.
### Focus visible (2.4.7)
```css
/* ❌ Never remove focus outlines */
*:focus { outline: none; }
/* ✅ Use :focus-visible for keyboard-only focus */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* ✅ Or custom focus styles */
button:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
}
```
### Focus not obscured (2.4.11) — new in 2.2
When an element receives keyboard focus, it must not be entirely hidden by other author-created content such as sticky headers, footers, or overlapping panels. At Level AAA (2.4.12), no part of the focused element may be hidden.
```css
/* ✅ Account for sticky headers when scrolling to focused elements */
:target {
scroll-margin-top: 80px;
}
/* ✅ Ensure focused items clear fixed/sticky bars */
:focus {
scroll-margin-top: 80px;
scroll-margin-bottom: 60px;
}
```
### Skip links (2.4.1)
Provide a skip link so keyboard users can bypass repetitive navigation. See the [skip link pattern](references/A11Y-PATTERNS.md#skip-link) for full markup and styles.
### Target size (2.5.8) — new in 2.2
Interactive targets must be at least **24 × 24 CSS pixels** (AA). Exceptions: inline text links, elements where the browser controls the size, and targets where a 24px circle centered on the bounding box does not overlap another target.
```css
/* ✅ Minimum target size */
button,
[role="button"],
input[type="checkbox"] + label,
input[type="radio"] + label {
min-width: 24px;
min-height: 24px;
}
/* ✅ Comfortable target size (recommended 44×44) */
.touch-target {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
}
```
### Dragging movements (2.5.7) — new in 2.2
Any action that requires dragging must have a single-pointer alternative (e.g., buttons, inputs). See the [dragging movements pattern](references/A11Y-PATTERNS.md#dragging-movements) for a sortable-list example.
### Timing (2.2)
```javascript
// Allow users to extend time limits
function showSessionWarning() {
const modal = createModal({
title: 'Session Expiring',
content: 'Your session will expire in 2 minutes.',
actions: [
{ label: 'Extend session', action: extendSession },
{ label: 'Log out', action: logout }
],
timeout: 120000
});
}
```
### Motion (2.3)
```css
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
---
## Understandable
### Page language (3.1.1)
```html
<!-- ❌ No language specified -->
<html>
<!-- ✅ Language specified -->
<html lang="en">
<!-- ✅ Language changes within page -->
<p>The French word for hello is <span lang="fr">bonjour</span>.</p>
```
### Consistent navigation (3.2.3)
```html
<!-- Navigation should be consistent across pages -->
<nav aria-label="Main">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
```
### Consistent help (3.2.6) — new in 2.2
If a help mechanism (contact info, chat widget, FAQ link, self-help option) is repeated across multiple pages, it must appear in the **same relative order** each time. Users who rely on consistent placement shouldn't have to hunt for help on every page.
### Form labels (3.3.2)
Every input needs a programmatically associated label. See the [form labels pattern](references/A11Y-PATTERNS.md#form-labels) for explicit, implicit, and instructional examples.
### Error handling (3.3.1, 3.3.3)
Announce errors to screen readers with `role="alert"` or `aria-live`, set `aria-invalid="true"` on invalid fields, and focus the first error on submit. See the [error handling pattern](references/A11Y-PATTERNS.md#error-handling) for full markup and JS.
### Redundant entry (3.3.7) — new in 2.2
Don't force users to re-enter information they already provided in the same session. Auto-populate from earlier steps, or let users select from previously entered values. Exceptions: security re-confirmation and content that has expired.
```html
<!-- ✅ Auto-fill shipping address from billing -->
<fieldset>
<legend>Shipping address</legend>
<label>
<input type="checkbox" id="same-as-billing" checked>
Same as billing address
</label>
<!-- Fields auto-populated when checked -->
</fieldset>
```
### Accessible authentication (3.3.8) — new in 2.2
Login flows must not rely on cognitive function tests (e.g., remembering a password, solving a puzzle) unless at least one of:
- A copy-paste or autofill mechanism is available
- An alternative method exists (e.g., passkey, SSO, email link)
- The test uses object recognition or personal content (AA only; AAA removes this exception)
```html
<!-- ✅ Allow paste in password fields -->
<input type="password" id="password" autocomplete="current-password">
<!-- ✅ Offer passwordless alternatives -->
<button type="button">Sign in with passkey</button>
<button type="button">Email me a login link</button>
```
---
## Robust
### ARIA usage (4.1.2)
**Prefer native elements:**
```html
<!-- ❌ ARIA role on div -->
<div role="button" tabindex="0">Click me</div>
<!-- ✅ Native button -->
<button>Click me</button>
<!-- ❌ ARIA checkbox -->
<div role="checkbox" aria-checked="false">Option</div>
<!-- ✅ Native checkbox -->
<label><input type="checkbox"> Option</label>
```
**When ARIA is needed,** use the correct roles and states. See the [ARIA tabs pattern](references/A11Y-PATTERNS.md#aria-tabs) for a complete tablist example.
### Live regions (4.1.3)
Use `aria-live` regions to announce dynamic content changes without moving focus. See the [live regions pattern](references/A11Y-PATTERNS.md#live-regions-and-notifications) for markup and a `showNotification()` helper.
---
## Testing checklist
### Automated testing
```bash
# Lighthouse accessibility audit
npx lighthouse https://example.com --only-categories=accessibility
# axe-core
npm install @axe-core/cli -g
axe https://example.com
```
### Manual testing
- [ ] **Keyboard navigation:** Tab through entire page, use Enter/Space to activate
- [ ] **Screen reader:** Test with VoiceOver (Mac), NVDA (Windows), or TalkBack (Android)
- [ ] **Zoom:** Content usable at 200% zoom
- [ ] **High contrast:** Test with Windows High Contrast Mode
- [ ] **Reduced motion:** Test with `prefers-reduced-motion: reduce`
- [ ] **Focus order:** Logical and follows visual order
- [ ] **Target size:** Interactive elements meet 24×24px minimum
See the [screen reader commands reference](references/A11Y-PATTERNS.md#screen-reader-commands) for VoiceOver and NVDA shortcuts.
---
## Common issues by impact
### Critical (fix immediately)
1. Missing form labels
2. Missing image alt text
3. Insufficient color contrast
4. Keyboard traps
5. No focus indicators
### Serious (fix before launch)
1. Missing page language
2. Missing heading structure
3. Non-descriptive link text
4. Auto-playing media
5. Missing skip links
### Moderate (fix soon)
1. Missing ARIA labels on icons
2. Inconsistent navigation
3. Missing error identification
4. Timing without controls
5. Missing landmark regions
## References
- [WCAG 2.2 Quick Reference](https://www.w3.org/WAI/WCAG22/quickref/)
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
- [Deque axe Rules](https://dequeuniversity.com/rules/axe/)
- [Web Quality Audit](../web-quality-audit/SKILL.md)
- [WCAG criteria reference](references/WCAG.md)
- [Accessibility code patterns](references/A11Y-PATTERNS.md)

View File

@@ -0,0 +1,233 @@
# Accessibility Code Patterns
Practical, copy-paste-ready patterns for common accessibility requirements. Each pattern is self-contained and linked from the main [SKILL.md](../SKILL.md).
---
## Modal focus trap
Trap keyboard focus inside a modal dialog so Tab/Shift+Tab cycle through its focusable elements and Escape closes it.
```javascript
function openModal(modal) {
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
if (e.key === 'Escape') {
closeModal();
}
});
firstElement.focus();
}
```
The native `<dialog>` element handles focus trapping automatically—prefer it when browser support allows.
---
## Skip link
Allows keyboard users to bypass repetitive navigation and jump straight to main content.
```html
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header><!-- navigation --></header>
<main id="main-content" tabindex="-1">
<!-- main content -->
</main>
</body>
```
```css
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
```
---
## Error handling
Announce errors to screen readers and focus the first invalid field on submit.
```html
<form novalidate>
<div class="field" aria-live="polite">
<label for="email">Email</label>
<input type="email" id="email"
aria-invalid="true"
aria-describedby="email-error">
<p id="email-error" class="error" role="alert">
Please enter a valid email address (e.g., name@example.com)
</p>
</div>
</form>
```
```javascript
form.addEventListener('submit', (e) => {
const firstError = form.querySelector('[aria-invalid="true"]');
if (firstError) {
e.preventDefault();
firstError.focus();
const errorSummary = document.getElementById('error-summary');
errorSummary.textContent =
`${errors.length} errors found. Please fix them and try again.`;
errorSummary.focus();
}
});
```
---
## Form labels
Every input needs an associated label—either explicit (`for`/`id`) or implicit (wrapping `<label>`).
```html
<!-- ❌ No label association -->
<input type="email" placeholder="Email">
<!-- ✅ Explicit label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email"
autocomplete="email" required>
<!-- ✅ Implicit label -->
<label>
Email address
<input type="email" name="email" autocomplete="email" required>
</label>
<!-- ✅ With instructions -->
<label for="password">Password</label>
<input type="password" id="password"
aria-describedby="password-requirements">
<p id="password-requirements">
Must be at least 8 characters with one number.
</p>
```
---
## Dragging movements
Any action triggered by dragging must offer a single-pointer alternative (WCAG 2.5.7).
```html
<!-- ❌ Drag-only reorder -->
<ul class="sortable-list" draggable="true">
<li>Item 1</li>
<li>Item 2</li>
</ul>
<!-- ✅ Drag + button alternatives -->
<ul class="sortable-list">
<li>
<span>Item 1</span>
<button aria-label="Move Item 1 up"></button>
<button aria-label="Move Item 1 down"></button>
</li>
<li>
<span>Item 2</span>
<button aria-label="Move Item 2 up"></button>
<button aria-label="Move Item 2 down"></button>
</li>
</ul>
```
Also applies to sliders, map panning, colour pickers, and similar drag-based widgets—always provide an equivalent click/tap or keyboard path.
---
## ARIA tabs
Tabs require `role="tablist"`, `role="tab"`, and `role="tabpanel"` with proper `aria-selected`, `aria-controls`, and keyboard support.
```html
<div role="tablist" aria-label="Product information">
<button role="tab" id="tab-1" aria-selected="true"
aria-controls="panel-1">Description</button>
<button role="tab" id="tab-2" aria-selected="false"
aria-controls="panel-2" tabindex="-1">Reviews</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<!-- Panel content -->
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<!-- Panel content -->
</div>
```
Arrow keys should move focus between tabs; the active tab receives `tabindex="0"` while inactive tabs use `tabindex="-1"`.
---
## Live regions and notifications
Use `aria-live` to announce dynamic content changes to screen readers without moving focus.
```html
<!-- Status updates (polite — waits for pause in speech) -->
<div aria-live="polite" aria-atomic="true" class="status">
<!-- Content updates announced to screen readers -->
</div>
<!-- Urgent alerts (assertive — interrupts) -->
<div role="alert" aria-live="assertive">
<!-- Interrupts current announcement -->
</div>
```
```javascript
function showNotification(message, type = 'polite') {
const container = document.getElementById(`${type}-announcer`);
container.textContent = '';
requestAnimationFrame(() => {
container.textContent = message;
});
}
```
Clear the container before writing to ensure the same message triggers a new announcement.
---
## Screen reader commands
Quick reference for the most common screen reader shortcuts.
| Action | VoiceOver (Mac) | NVDA (Windows) |
|--------|-----------------|----------------|
| Start/Stop | ⌘ + F5 | Ctrl + Alt + N |
| Next item | VO + → | ↓ |
| Previous item | VO + ← | ↑ |
| Activate | VO + Space | Enter |
| Headings list | VO + U, then arrows | H / Shift + H |
| Links list | VO + U | K / Shift + K |

View File

@@ -0,0 +1,191 @@
# WCAG 2.2 Quick Reference
## Success criteria by level
### Level A (minimum)
| Criterion | Description |
|-----------|-------------|
| **1.1.1** Non-text Content | All images, icons have text alternatives |
| **1.2.1** Audio-only/Video-only | Provide transcript or audio description |
| **1.2.2** Captions | Video with audio has captions |
| **1.2.3** Audio Description | Video has audio description |
| **1.3.1** Info and Relationships | Information conveyed through presentation is available programmatically |
| **1.3.2** Meaningful Sequence | Reading order is logical |
| **1.3.3** Sensory Characteristics | Instructions don't rely solely on shape, color, size, location, orientation, or sound |
| **1.4.1** Use of Color | Color is not the only visual means of conveying information |
| **1.4.2** Audio Control | Audio playing automatically can be paused/stopped |
| **2.1.1** Keyboard | All functionality available via keyboard |
| **2.1.2** No Keyboard Trap | Keyboard focus can be moved away from any component |
| **2.1.4** Character Key Shortcuts | Single-key shortcuts can be turned off or remapped |
| **2.2.1** Timing Adjustable | Time limits can be extended |
| **2.2.2** Pause, Stop, Hide | Moving/blinking content can be paused |
| **2.3.1** Three Flashes | Nothing flashes more than 3 times per second |
| **2.4.1** Bypass Blocks | Skip link or landmark navigation available |
| **2.4.2** Page Titled | Pages have descriptive titles |
| **2.4.3** Focus Order | Focus order preserves meaning |
| **2.4.4** Link Purpose | Link purpose clear from link text or context |
| **2.5.1** Pointer Gestures | Multi-point gestures have single-pointer alternatives |
| **2.5.2** Pointer Cancellation | Down-event doesn't trigger action (use up-event or click) |
| **2.5.3** Label in Name | Accessible name contains visible label text |
| **2.5.4** Motion Actuation | Motion-triggered functions have alternatives |
| **3.1.1** Language of Page | Default language specified in HTML |
| **3.2.1** On Focus | Focus doesn't trigger unexpected changes |
| **3.2.2** On Input | Input doesn't trigger unexpected changes |
| **3.2.6** Consistent Help | Help mechanisms appear in the same relative order across pages |
| **3.3.1** Error Identification | Input errors clearly described |
| **3.3.2** Labels or Instructions | Form inputs have labels or instructions |
| **3.3.7** Redundant Entry | Information previously entered is auto-populated or available to select |
| **4.1.2** Name, Role, Value | UI components have accessible names and correct roles |
### Level AA (standard)
| Criterion | Description |
|-----------|-------------|
| **1.2.4** Captions (Live) | Live audio has captions |
| **1.2.5** Audio Description | Pre-recorded video has audio description |
| **1.3.4** Orientation | Content doesn't restrict orientation |
| **1.3.5** Identify Input Purpose | Input purpose can be programmatically determined |
| **1.4.3** Contrast (Minimum) | 4.5:1 for normal text, 3:1 for large text |
| **1.4.4** Resize Text | Text can be resized to 200% without loss of functionality |
| **1.4.5** Images of Text | Text used instead of images of text |
| **1.4.10** Reflow | Content reflows at 320px width without horizontal scroll |
| **1.4.11** Non-text Contrast | UI components have 3:1 contrast |
| **1.4.12** Text Spacing | Content adapts to text spacing changes |
| **1.4.13** Content on Hover/Focus | Additional content is dismissible, hoverable, persistent |
| **2.4.5** Multiple Ways | Multiple ways to find pages |
| **2.4.6** Headings and Labels | Headings and labels are descriptive |
| **2.4.7** Focus Visible | Focus indicator is visible |
| **2.4.11** Focus Not Obscured (Minimum) | Focused element is not entirely hidden by author-created content |
| **2.5.7** Dragging Movements | Dragging actions have single-pointer alternatives |
| **2.5.8** Target Size (Minimum) | Interactive targets are at least 24×24 CSS pixels (with exceptions) |
| **3.1.2** Language of Parts | Language changes are marked |
| **3.2.3** Consistent Navigation | Navigation is consistent across pages |
| **3.2.4** Consistent Identification | Same functionality uses same labels |
| **3.3.3** Error Suggestion | Error corrections suggested when known |
| **3.3.4** Error Prevention (Legal) | Actions can be reversed or confirmed |
| **3.3.8** Accessible Authentication (Minimum) | No cognitive function test for login unless an alternative or assistance is provided |
| **4.1.3** Status Messages | Status messages announced to screen readers |
### Level AAA (enhanced)
| Criterion | Description |
|-----------|-------------|
| **1.4.6** Contrast (Enhanced) | 7:1 for normal text, 4.5:1 for large text |
| **1.4.8** Visual Presentation | Foreground/background colors can be selected |
| **1.4.9** Images of Text (No Exception) | No images of text |
| **2.1.3** Keyboard (No Exception) | All functionality keyboard accessible |
| **2.2.3** No Timing | No time limits |
| **2.2.4** Interruptions | Interruptions can be postponed |
| **2.2.5** Re-authenticating | Data preserved on re-authentication |
| **2.2.6** Timeouts | Users warned about data loss from inactivity |
| **2.3.2** Three Flashes | No content flashes more than 3 times |
| **2.3.3** Animation from Interactions | Motion animation can be disabled |
| **2.4.8** Location | User location within site is available |
| **2.4.9** Link Purpose (Link Only) | Link purpose clear from link text alone |
| **2.4.10** Section Headings | Sections have headings |
| **2.4.12** Focus Not Obscured (Enhanced) | No part of the focused element is hidden by author-created content |
| **2.4.13** Focus Appearance | Focus indicator has sufficient area, contrast, and is not obscured |
| **3.1.3** Unusual Words | Definitions available for unusual words |
| **3.1.4** Abbreviations | Abbreviations expanded |
| **3.1.5** Reading Level | Alternative content for complex text |
| **3.1.6** Pronunciation | Pronunciation available where needed |
| **3.2.5** Change on Request | Changes initiated only by user |
| **3.3.5** Help | Context-sensitive help available |
| **3.3.6** Error Prevention (All) | All form submissions can be reviewed |
| **3.3.9** Accessible Authentication (Enhanced) | No cognitive function test for login (no object or personal content recognition exceptions) |
## Common ARIA patterns
### Buttons
```html
<button>Label</button>
<!-- or -->
<button aria-label="Close dialog">×</button>
```
### Links
```html
<a href="/page">Descriptive link text</a>
<!-- External links -->
<a href="https://external.com" target="_blank" rel="noopener">
External site
<span class="visually-hidden">(opens in new tab)</span>
</a>
```
### Form fields
```html
<label for="email">Email address</label>
<input type="email" id="email" aria-describedby="email-hint">
<p id="email-hint">We'll never share your email.</p>
```
### Error states
```html
<label for="email">Email</label>
<input type="email" id="email" aria-invalid="true" aria-describedby="email-error">
<p id="email-error" role="alert">Please enter a valid email address.</p>
```
### Navigation
```html
<nav aria-label="Main">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
```
### Modals
```html
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Action</h2>
<!-- content -->
</div>
```
### Live regions
```html
<!-- Polite (waits for pause in speech) -->
<div aria-live="polite">Status update here</div>
<!-- Assertive (interrupts immediately) -->
<div aria-live="assertive" role="alert">Error message here</div>
<!-- Status (polite, implicit) -->
<div role="status">Loading complete</div>
```
## What changed from 2.1 to 2.2
| Change | Criterion | Level |
|--------|-----------|-------|
| **Removed** | 4.1.1 Parsing | A |
| **Added** | 2.4.11 Focus Not Obscured (Minimum) | AA |
| **Added** | 2.4.12 Focus Not Obscured (Enhanced) | AAA |
| **Added** | 2.4.13 Focus Appearance | AAA |
| **Added** | 2.5.7 Dragging Movements | AA |
| **Added** | 2.5.8 Target Size (Minimum) | AA |
| **Added** | 3.2.6 Consistent Help | A |
| **Added** | 3.3.7 Redundant Entry | A |
| **Added** | 3.3.8 Accessible Authentication (Minimum) | AA |
| **Added** | 3.3.9 Accessible Authentication (Enhanced) | AAA |
## Testing tools
| Tool | Type | URL |
|------|------|-----|
| axe DevTools | Browser extension | [deque.com/axe](https://www.deque.com/axe/) |
| WAVE | Browser extension | [wave.webaim.org](https://wave.webaim.org/) |
| Lighthouse | Built into Chrome | DevTools → Lighthouse |
| NVDA | Screen reader (Windows) | [nvaccess.org](https://www.nvaccess.org/) |
| VoiceOver | Screen reader (Mac) | Built into macOS |
| Colour Contrast Analyser | Desktop app | [tpgi.com](https://www.tpgi.com/color-contrast-checker/) |
## Sources
- [WCAG 2.2 W3C Recommendation](https://www.w3.org/TR/WCAG22/)
- [WCAG 2.2 Quick Reference](https://www.w3.org/WAI/WCAG22/quickref/)
- [What's New in WCAG 2.2](https://www.w3.org/WAI/standards-guidelines/wcag/new-in-22/)

View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View File

@@ -0,0 +1,639 @@
---
name: nodejs-backend-patterns
description: Build production-ready Node.js backend services with Express/Fastify, implementing middleware patterns, error handling, authentication, database integration, and API design best practices. Use when creating Node.js servers, REST APIs, GraphQL backends, or microservices architectures.
---
# Node.js Backend Patterns
Comprehensive guidance for building scalable, maintainable, and production-ready Node.js backend applications with modern frameworks, architectural patterns, and best practices.
## When to Use This Skill
- Building REST APIs or GraphQL servers
- Creating microservices with Node.js
- Implementing authentication and authorization
- Designing scalable backend architectures
- Setting up middleware and error handling
- Integrating databases (SQL and NoSQL)
- Building real-time applications with WebSockets
- Implementing background job processing
## Core Frameworks
### Express.js - Minimalist Framework
**Basic Setup:**
```typescript
import express, { Request, Response, NextFunction } from "express";
import helmet from "helmet";
import cors from "cors";
import compression from "compression";
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") }));
app.use(compression());
// Body parsing
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Request logging
app.use((req: Request, res: Response, next: NextFunction) => {
console.log(`${req.method} ${req.path}`);
next();
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
```
### Fastify - High Performance Framework
**Basic Setup:**
```typescript
import Fastify from "fastify";
import helmet from "@fastify/helmet";
import cors from "@fastify/cors";
import compress from "@fastify/compress";
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
transport: {
target: "pino-pretty",
options: { colorize: true },
},
},
});
// Plugins
await fastify.register(helmet);
await fastify.register(cors, { origin: true });
await fastify.register(compress);
// Type-safe routes with schema validation
fastify.post<{
Body: { name: string; email: string };
Reply: { id: string; name: string };
}>(
"/users",
{
schema: {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 1 },
email: { type: "string", format: "email" },
},
},
},
},
async (request, reply) => {
const { name, email } = request.body;
return { id: "123", name };
},
);
await fastify.listen({ port: 3000, host: "0.0.0.0" });
```
## Architectural Patterns
### Pattern 1: Layered Architecture
**Structure:**
```
src/
├── controllers/ # Handle HTTP requests/responses
├── services/ # Business logic
├── repositories/ # Data access layer
├── models/ # Data models
├── middleware/ # Express/Fastify middleware
├── routes/ # Route definitions
├── utils/ # Helper functions
├── config/ # Configuration
└── types/ # TypeScript types
```
**Controller Layer:**
```typescript
// controllers/user.controller.ts
import { Request, Response, NextFunction } from "express";
import { UserService } from "../services/user.service";
import { CreateUserDTO, UpdateUserDTO } from "../types/user.types";
export class UserController {
constructor(private userService: UserService) {}
async createUser(req: Request, res: Response, next: NextFunction) {
try {
const userData: CreateUserDTO = req.body;
const user = await this.userService.createUser(userData);
res.status(201).json(user);
} catch (error) {
next(error);
}
}
async getUser(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const user = await this.userService.getUserById(id);
res.json(user);
} catch (error) {
next(error);
}
}
async updateUser(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const updates: UpdateUserDTO = req.body;
const user = await this.userService.updateUser(id, updates);
res.json(user);
} catch (error) {
next(error);
}
}
async deleteUser(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
await this.userService.deleteUser(id);
res.status(204).send();
} catch (error) {
next(error);
}
}
}
```
**Service Layer:**
```typescript
// services/user.service.ts
import { UserRepository } from "../repositories/user.repository";
import { CreateUserDTO, UpdateUserDTO, User } from "../types/user.types";
import { NotFoundError, ValidationError } from "../utils/errors";
import bcrypt from "bcrypt";
export class UserService {
constructor(private userRepository: UserRepository) {}
async createUser(userData: CreateUserDTO): Promise<User> {
// Validation
const existingUser = await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new ValidationError("Email already exists");
}
// Hash password
const hashedPassword = await bcrypt.hash(userData.password, 10);
// Create user
const user = await this.userRepository.create({
...userData,
password: hashedPassword,
});
// Remove password from response
const { password, ...userWithoutPassword } = user;
return userWithoutPassword as User;
}
async getUserById(id: string): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundError("User not found");
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword as User;
}
async updateUser(id: string, updates: UpdateUserDTO): Promise<User> {
const user = await this.userRepository.update(id, updates);
if (!user) {
throw new NotFoundError("User not found");
}
const { password, ...userWithoutPassword } = user;
return userWithoutPassword as User;
}
async deleteUser(id: string): Promise<void> {
const deleted = await this.userRepository.delete(id);
if (!deleted) {
throw new NotFoundError("User not found");
}
}
}
```
**Repository Layer:**
```typescript
// repositories/user.repository.ts
import { Pool } from "pg";
import { CreateUserDTO, UpdateUserDTO, UserEntity } from "../types/user.types";
export class UserRepository {
constructor(private db: Pool) {}
async create(
userData: CreateUserDTO & { password: string },
): Promise<UserEntity> {
const query = `
INSERT INTO users (name, email, password)
VALUES ($1, $2, $3)
RETURNING id, name, email, password, created_at, updated_at
`;
const { rows } = await this.db.query(query, [
userData.name,
userData.email,
userData.password,
]);
return rows[0];
}
async findById(id: string): Promise<UserEntity | null> {
const query = "SELECT * FROM users WHERE id = $1";
const { rows } = await this.db.query(query, [id]);
return rows[0] || null;
}
async findByEmail(email: string): Promise<UserEntity | null> {
const query = "SELECT * FROM users WHERE email = $1";
const { rows } = await this.db.query(query, [email]);
return rows[0] || null;
}
async update(id: string, updates: UpdateUserDTO): Promise<UserEntity | null> {
const fields = Object.keys(updates);
const values = Object.values(updates);
const setClause = fields
.map((field, idx) => `${field} = $${idx + 2}`)
.join(", ");
const query = `
UPDATE users
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`;
const { rows } = await this.db.query(query, [id, ...values]);
return rows[0] || null;
}
async delete(id: string): Promise<boolean> {
const query = "DELETE FROM users WHERE id = $1";
const { rowCount } = await this.db.query(query, [id]);
return rowCount > 0;
}
}
```
### Pattern 2: Dependency Injection
Use a DI container to wire up repositories, services, and controllers. For a full container implementation, see [references/advanced-patterns.md](references/advanced-patterns.md).
## Middleware Patterns
### Authentication Middleware
```typescript
// middleware/auth.middleware.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { UnauthorizedError } from "../utils/errors";
interface JWTPayload {
userId: string;
email: string;
}
declare global {
namespace Express {
interface Request {
user?: JWTPayload;
}
}
}
export const authenticate = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new UnauthorizedError("No token provided");
}
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
req.user = payload;
next();
} catch (error) {
next(new UnauthorizedError("Invalid token"));
}
};
export const authorize = (...roles: string[]) => {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return next(new UnauthorizedError("Not authenticated"));
}
// Check if user has required role
const hasRole = roles.some((role) => req.user?.roles?.includes(role));
if (!hasRole) {
return next(new UnauthorizedError("Insufficient permissions"));
}
next();
};
};
```
### Validation Middleware
```typescript
// middleware/validation.middleware.ts
import { Request, Response, NextFunction } from "express";
import { AnyZodObject, ZodError } from "zod";
import { ValidationError } from "../utils/errors";
export const validate = (schema: AnyZodObject) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (error) {
if (error instanceof ZodError) {
const errors = error.errors.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
next(new ValidationError("Validation failed", errors));
} else {
next(error);
}
}
};
};
// Usage with Zod
import { z } from "zod";
const createUserSchema = z.object({
body: z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
}),
});
router.post("/users", validate(createUserSchema), userController.createUser);
```
### Rate Limiting Middleware
```typescript
// middleware/rate-limit.middleware.ts
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || "6379"),
});
export const apiLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: "rl:",
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: "Too many requests from this IP, please try again later",
standardHeaders: true,
legacyHeaders: false,
});
export const authLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: "rl:auth:",
}),
windowMs: 15 * 60 * 1000,
max: 5, // Stricter limit for auth endpoints
skipSuccessfulRequests: true,
});
```
### Request Logging Middleware
```typescript
// middleware/logger.middleware.ts
import { Request, Response, NextFunction } from "express";
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport: {
target: "pino-pretty",
options: { colorize: true },
},
});
export const requestLogger = (
req: Request,
res: Response,
next: NextFunction,
) => {
const start = Date.now();
// Log response when finished
res.on("finish", () => {
const duration = Date.now() - start;
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
userAgent: req.headers["user-agent"],
ip: req.ip,
});
});
next();
};
export { logger };
```
## Error Handling
### Custom Error Classes
```typescript
// utils/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public isOperational: boolean = true,
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public errors?: any[],
) {
super(message, 400);
}
}
export class NotFoundError extends AppError {
constructor(message: string = "Resource not found") {
super(message, 404);
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = "Unauthorized") {
super(message, 401);
}
}
export class ForbiddenError extends AppError {
constructor(message: string = "Forbidden") {
super(message, 403);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409);
}
}
```
### Global Error Handler
```typescript
// middleware/error-handler.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/errors";
import { logger } from "./logger.middleware";
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction,
) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: "error",
message: err.message,
...(err instanceof ValidationError && { errors: err.errors }),
});
}
// Log unexpected errors
logger.error({
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
});
// Don't leak error details in production
const message =
process.env.NODE_ENV === "production"
? "Internal server error"
: err.message;
res.status(500).json({
status: "error",
message,
});
};
// Async error wrapper
export const asyncHandler = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>,
) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
```
## Database Patterns
Node.js supports both SQL and NoSQL databases. Use connection pooling for all production databases.
Key patterns covered in [references/advanced-patterns.md](references/advanced-patterns.md):
- **PostgreSQL with connection pool** — `pg` Pool configuration and graceful shutdown
- **MongoDB with Mongoose** — connection management and schema definition
- **Transaction pattern** — `BEGIN`/`COMMIT`/`ROLLBACK` with `pg` client
## Authentication & Authorization
JWT-based auth with access tokens (short-lived, 15m) and refresh tokens (7d). Full `AuthService` implementation with `bcrypt` password comparison in [references/advanced-patterns.md](references/advanced-patterns.md).
## Caching Strategies
Redis-backed `CacheService` with get/set/delete/invalidatePattern, plus a `@Cacheable` decorator for method-level caching. See [references/advanced-patterns.md](references/advanced-patterns.md).
## API Response Format
Standardized `ApiResponse` helper with `success`, `error`, and `paginated` static methods. See [references/advanced-patterns.md](references/advanced-patterns.md).
## Best Practices
1. **Use TypeScript**: Type safety prevents runtime errors
2. **Implement proper error handling**: Use custom error classes
3. **Validate input**: Use libraries like Zod or Joi
4. **Use environment variables**: Never hardcode secrets
5. **Implement logging**: Use structured logging (Pino, Winston)
6. **Add rate limiting**: Prevent abuse
7. **Use HTTPS**: Always in production
8. **Implement CORS properly**: Don't use `*` in production
9. **Use dependency injection**: Easier testing and maintenance
10. **Write tests**: Unit, integration, and E2E tests
11. **Handle graceful shutdown**: Clean up resources
12. **Use connection pooling**: For databases
13. **Implement health checks**: For monitoring
14. **Use compression**: Reduce response size
15. **Monitor performance**: Use APM tools
## Testing Patterns
See `javascript-testing-patterns` skill for comprehensive testing guidance.

View File

@@ -0,0 +1,430 @@
# Node.js Advanced Patterns
Advanced patterns for dependency injection, database integration, authentication, caching, and API response formatting.
## Dependency Injection
### DI Container
```typescript
// di-container.ts
import { Pool } from "pg";
import { UserRepository } from "./repositories/user.repository";
import { UserService } from "./services/user.service";
import { UserController } from "./controllers/user.controller";
import { AuthService } from "./services/auth.service";
class Container {
private instances = new Map<string, any>();
register<T>(key: string, factory: () => T): void {
this.instances.set(key, factory);
}
resolve<T>(key: string): T {
const factory = this.instances.get(key);
if (!factory) {
throw new Error(`No factory registered for ${key}`);
}
return factory();
}
singleton<T>(key: string, factory: () => T): void {
let instance: T;
this.instances.set(key, () => {
if (!instance) {
instance = factory();
}
return instance;
});
}
}
export const container = new Container();
// Register dependencies
container.singleton(
"db",
() =>
new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}),
);
container.singleton(
"userRepository",
() => new UserRepository(container.resolve("db")),
);
container.singleton(
"userService",
() => new UserService(container.resolve("userRepository")),
);
container.register(
"userController",
() => new UserController(container.resolve("userService")),
);
container.singleton(
"authService",
() => new AuthService(container.resolve("userRepository")),
);
```
## Database Patterns
### PostgreSQL with Connection Pool
```typescript
// config/database.ts
import { Pool, PoolConfig } from "pg";
const poolConfig: PoolConfig = {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
};
export const pool = new Pool(poolConfig);
// Test connection
pool.on("connect", () => {
console.log("Database connected");
});
pool.on("error", (err) => {
console.error("Unexpected database error", err);
process.exit(-1);
});
// Graceful shutdown
export const closeDatabase = async () => {
await pool.end();
console.log("Database connection closed");
};
```
### MongoDB with Mongoose
```typescript
// config/mongoose.ts
import mongoose from "mongoose";
const connectDB = async () => {
try {
await mongoose.connect(process.env.MONGODB_URI!, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
});
console.log("MongoDB connected");
} catch (error) {
console.error("MongoDB connection error:", error);
process.exit(1);
}
};
mongoose.connection.on("disconnected", () => {
console.log("MongoDB disconnected");
});
mongoose.connection.on("error", (err) => {
console.error("MongoDB error:", err);
});
export { connectDB };
// Model example
import { Schema, model, Document } from "mongoose";
interface IUser extends Document {
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
const userSchema = new Schema<IUser>(
{
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
},
{
timestamps: true,
},
);
// Indexes
userSchema.index({ email: 1 });
export const User = model<IUser>("User", userSchema);
```
### Transaction Pattern
```typescript
// services/order.service.ts
import { Pool } from "pg";
export class OrderService {
constructor(private db: Pool) {}
async createOrder(userId: string, items: any[]) {
const client = await this.db.connect();
try {
await client.query("BEGIN");
// Create order
const orderResult = await client.query(
"INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id",
[userId, calculateTotal(items)],
);
const orderId = orderResult.rows[0].id;
// Create order items
for (const item of items) {
await client.query(
"INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ($1, $2, $3, $4)",
[orderId, item.productId, item.quantity, item.price],
);
// Update inventory
await client.query(
"UPDATE products SET stock = stock - $1 WHERE id = $2",
[item.quantity, item.productId],
);
}
await client.query("COMMIT");
return orderId;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}
```
## Authentication & Authorization
### JWT Authentication
```typescript
// services/auth.service.ts
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { UserRepository } from "../repositories/user.repository";
import { UnauthorizedError } from "../utils/errors";
export class AuthService {
constructor(private userRepository: UserRepository) {}
async login(email: string, password: string) {
const user = await this.userRepository.findByEmail(email);
if (!user) {
throw new UnauthorizedError("Invalid credentials");
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new UnauthorizedError("Invalid credentials");
}
const token = this.generateToken({
userId: user.id,
email: user.email,
});
const refreshToken = this.generateRefreshToken({
userId: user.id,
});
return {
token,
refreshToken,
user: {
id: user.id,
name: user.name,
email: user.email,
},
};
}
async refreshToken(refreshToken: string) {
try {
const payload = jwt.verify(
refreshToken,
process.env.REFRESH_TOKEN_SECRET!,
) as { userId: string };
const user = await this.userRepository.findById(payload.userId);
if (!user) {
throw new UnauthorizedError("User not found");
}
const token = this.generateToken({
userId: user.id,
email: user.email,
});
return { token };
} catch (error) {
throw new UnauthorizedError("Invalid refresh token");
}
}
private generateToken(payload: any): string {
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: "15m",
});
}
private generateRefreshToken(payload: any): string {
return jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET!, {
expiresIn: "7d",
});
}
}
```
## Caching Strategies
```typescript
// utils/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || "6379"),
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
export class CacheService {
async get<T>(key: string): Promise<T | null> {
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
}
async set(key: string, value: any, ttl?: number): Promise<void> {
const serialized = JSON.stringify(value);
if (ttl) {
await redis.setex(key, ttl, serialized);
} else {
await redis.set(key, serialized);
}
}
async delete(key: string): Promise<void> {
await redis.del(key);
}
async invalidatePattern(pattern: string): Promise<void> {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
}
}
// Cache decorator
export function Cacheable(ttl: number = 300) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const cache = new CacheService();
const cacheKey = `${propertyKey}:${JSON.stringify(args)}`;
const cached = await cache.get(cacheKey);
if (cached) {
return cached;
}
const result = await originalMethod.apply(this, args);
await cache.set(cacheKey, result, ttl);
return result;
};
return descriptor;
};
}
```
## API Response Format
```typescript
// utils/response.ts
import { Response } from "express";
export class ApiResponse {
static success<T>(
res: Response,
data: T,
message?: string,
statusCode = 200,
) {
return res.status(statusCode).json({
status: "success",
message,
data,
});
}
static error(res: Response, message: string, statusCode = 500, errors?: any) {
return res.status(statusCode).json({
status: "error",
message,
...(errors && { errors }),
});
}
static paginated<T>(
res: Response,
data: T[],
page: number,
limit: number,
total: number,
) {
return res.json({
status: "success",
data,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
});
}
}
```

View File

@@ -0,0 +1,343 @@
---
name: nodejs-best-practices
description: "Node.js development principles and decision-making. Framework selection, async patterns, security, and architecture. Teaches thinking, not copying."
risk: unknown
source: community
date_added: "2026-02-27"
---
# Node.js Best Practices
> Principles and decision-making for Node.js development in 2025.
> **Learn to THINK, not memorize code patterns.**
## When to Use
Use this skill when making Node.js architecture decisions, choosing frameworks, designing async patterns, or applying security and deployment best practices.
---
## ⚠️ How to Use This Skill
This skill teaches **decision-making principles**, not fixed code to copy.
- ASK user for preferences when unclear
- Choose framework/pattern based on CONTEXT
- Don't default to same solution every time
---
## 1. Framework Selection (2025)
### Decision Tree
```
What are you building?
├── Edge/Serverless (Cloudflare, Vercel)
│ └── Hono (zero-dependency, ultra-fast cold starts)
├── High Performance API
│ └── Fastify (2-3x faster than Express)
├── Enterprise/Team familiarity
│ └── NestJS (structured, DI, decorators)
├── Legacy/Stable/Maximum ecosystem
│ └── Express (mature, most middleware)
└── Full-stack with frontend
└── Next.js API Routes or tRPC
```
### Comparison Principles
| Factor | Hono | Fastify | Express |
|--------|------|---------|---------|
| **Best for** | Edge, serverless | Performance | Legacy, learning |
| **Cold start** | Fastest | Fast | Moderate |
| **Ecosystem** | Growing | Good | Largest |
| **TypeScript** | Native | Excellent | Good |
| **Learning curve** | Low | Medium | Low |
### Selection Questions to Ask:
1. What's the deployment target?
2. Is cold start time critical?
3. Does team have existing experience?
4. Is there legacy code to maintain?
---
## 2. Runtime Considerations (2025)
### Native TypeScript
```
Node.js 22+: --experimental-strip-types
├── Run .ts files directly
├── No build step needed for simple projects
└── Consider for: scripts, simple APIs
```
### Module System Decision
```
ESM (import/export)
├── Modern standard
├── Better tree-shaking
├── Async module loading
└── Use for: new projects
CommonJS (require)
├── Legacy compatibility
├── More npm packages support
└── Use for: existing codebases, some edge cases
```
### Runtime Selection
| Runtime | Best For |
|---------|----------|
| **Node.js** | General purpose, largest ecosystem |
| **Bun** | Performance, built-in bundler |
| **Deno** | Security-first, built-in TypeScript |
---
## 3. Architecture Principles
### Layered Structure Concept
```
Request Flow:
├── Controller/Route Layer
│ ├── Handles HTTP specifics
│ ├── Input validation at boundary
│ └── Calls service layer
├── Service Layer
│ ├── Business logic
│ ├── Framework-agnostic
│ └── Calls repository layer
└── Repository Layer
├── Data access only
├── Database queries
└── ORM interactions
```
### Why This Matters:
- **Testability**: Mock layers independently
- **Flexibility**: Swap database without touching business logic
- **Clarity**: Each layer has single responsibility
### When to Simplify:
- Small scripts → Single file OK
- Prototypes → Less structure acceptable
- Always ask: "Will this grow?"
---
## 4. Error Handling Principles
### Centralized Error Handling
```
Pattern:
├── Create custom error classes
├── Throw from any layer
├── Catch at top level (middleware)
└── Format consistent response
```
### Error Response Philosophy
```
Client gets:
├── Appropriate HTTP status
├── Error code for programmatic handling
├── User-friendly message
└── NO internal details (security!)
Logs get:
├── Full stack trace
├── Request context
├── User ID (if applicable)
└── Timestamp
```
### Status Code Selection
| Situation | Status | When |
|-----------|--------|------|
| Bad input | 400 | Client sent invalid data |
| No auth | 401 | Missing or invalid credentials |
| No permission | 403 | Valid auth, but not allowed |
| Not found | 404 | Resource doesn't exist |
| Conflict | 409 | Duplicate or state conflict |
| Validation | 422 | Schema valid but business rules fail |
| Server error | 500 | Our fault, log everything |
---
## 5. Async Patterns Principles
### When to Use Each
| Pattern | Use When |
|---------|----------|
| `async/await` | Sequential async operations |
| `Promise.all` | Parallel independent operations |
| `Promise.allSettled` | Parallel where some can fail |
| `Promise.race` | Timeout or first response wins |
### Event Loop Awareness
```
I/O-bound (async helps):
├── Database queries
├── HTTP requests
├── File system
└── Network operations
CPU-bound (async doesn't help):
├── Crypto operations
├── Image processing
├── Complex calculations
└── → Use worker threads or offload
```
### Avoiding Event Loop Blocking
- Never use sync methods in production (fs.readFileSync, etc.)
- Offload CPU-intensive work
- Use streaming for large data
---
## 6. Validation Principles
### Validate at Boundaries
```
Where to validate:
├── API entry point (request body/params)
├── Before database operations
├── External data (API responses, file uploads)
└── Environment variables (startup)
```
### Validation Library Selection
| Library | Best For |
|---------|----------|
| **Zod** | TypeScript first, inference |
| **Valibot** | Smaller bundle (tree-shakeable) |
| **ArkType** | Performance critical |
| **Yup** | Existing React Form usage |
### Validation Philosophy
- Fail fast: Validate early
- Be specific: Clear error messages
- Don't trust: Even "internal" data
---
## 7. Security Principles
### Security Checklist (Not Code)
- [ ] **Input validation**: All inputs validated
- [ ] **Parameterized queries**: No string concatenation for SQL
- [ ] **Password hashing**: bcrypt or argon2
- [ ] **JWT verification**: Always verify signature and expiry
- [ ] **Rate limiting**: Protect from abuse
- [ ] **Security headers**: Helmet.js or equivalent
- [ ] **HTTPS**: Everywhere in production
- [ ] **CORS**: Properly configured
- [ ] **Secrets**: Environment variables only
- [ ] **Dependencies**: Regularly audited
### Security Mindset
```
Trust nothing:
├── Query params → validate
├── Request body → validate
├── Headers → verify
├── Cookies → validate
├── File uploads → scan
└── External APIs → validate response
```
---
## 8. Testing Principles
### Test Strategy Selection
| Type | Purpose | Tools |
|------|---------|-------|
| **Unit** | Business logic | node:test, Vitest |
| **Integration** | API endpoints | Supertest |
| **E2E** | Full flows | Playwright |
### What to Test (Priorities)
1. **Critical paths**: Auth, payments, core business
2. **Edge cases**: Empty inputs, boundaries
3. **Error handling**: What happens when things fail?
4. **Not worth testing**: Framework code, trivial getters
### Built-in Test Runner (Node.js 22+)
```
node --test src/**/*.test.ts
├── No external dependency
├── Good coverage reporting
└── Watch mode available
```
---
## 9. Anti-Patterns to Avoid
### ❌ DON'T:
- Use Express for new edge projects (use Hono)
- Use sync methods in production code
- Put business logic in controllers
- Skip input validation
- Hardcode secrets
- Trust external data without validation
- Block event loop with CPU work
### ✅ DO:
- Choose framework based on context
- Ask user for preferences when unclear
- Use layered architecture for growing projects
- Validate all inputs
- Use environment variables for secrets
- Profile before optimizing
---
## 10. Decision Checklist
Before implementing:
- [ ] **Asked user about stack preference?**
- [ ] **Chosen framework for THIS context?** (not just default)
- [ ] **Considered deployment target?**
- [ ] **Planned error handling strategy?**
- [ ] **Identified validation points?**
- [ ] **Considered security requirements?**
---
> **Remember**: Node.js best practices are about decision-making, not memorizing patterns. Every project deserves fresh consideration based on its requirements.
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.

513
.agents/skills/seo/SKILL.md Normal file
View File

@@ -0,0 +1,513 @@
---
name: seo
description: Optimize for search engine visibility and ranking. Use when asked to "improve SEO", "optimize for search", "fix meta tags", "add structured data", "sitemap optimization", or "search engine optimization".
license: MIT
metadata:
author: web-quality-skills
version: "1.0"
---
# SEO optimization
Search engine optimization based on Lighthouse SEO audits and Google Search guidelines. Focus on technical SEO, on-page optimization, and structured data.
## SEO fundamentals
Search ranking factors (approximate influence):
| Factor | Influence | This Skill |
|--------|-----------|------------|
| Content quality & relevance | ~40% | Partial (structure) |
| Backlinks & authority | ~25% | ✗ |
| Technical SEO | ~15% | ✓ |
| Page experience (Core Web Vitals) | ~10% | See [Core Web Vitals](../core-web-vitals/SKILL.md) |
| On-page SEO | ~10% | ✓ |
---
## Technical SEO
### Crawlability
**robots.txt:**
```text
# /robots.txt
User-agent: *
Allow: /
# Block admin/private areas
Disallow: /admin/
Disallow: /api/
Disallow: /private/
# Don't block resources needed for rendering
# ❌ Disallow: /static/
Sitemap: https://example.com/sitemap.xml
```
**Meta robots:**
```html
<!-- Default: indexable, followable -->
<meta name="robots" content="index, follow">
<!-- Noindex specific pages -->
<meta name="robots" content="noindex, nofollow">
<!-- Indexable but don't follow links -->
<meta name="robots" content="index, nofollow">
<!-- Control snippets -->
<meta name="robots" content="max-snippet:150, max-image-preview:large">
```
**Canonical URLs:**
```html
<!-- Prevent duplicate content issues -->
<link rel="canonical" href="https://example.com/page">
<!-- Self-referencing canonical (recommended) -->
<link rel="canonical" href="https://example.com/current-page">
<!-- For paginated content -->
<link rel="canonical" href="https://example.com/products">
<!-- Or use rel="prev" / rel="next" for explicit pagination -->
```
### XML sitemap
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2024-01-15</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://example.com/products</loc>
<lastmod>2024-01-14</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
```
**Sitemap best practices:**
- Maximum 50,000 URLs or 50MB per sitemap
- Use sitemap index for larger sites
- Include only canonical, indexable URLs
- Update `lastmod` when content changes
- Submit to Google Search Console
### URL structure
```
✅ Good URLs:
https://example.com/products/blue-widget
https://example.com/blog/how-to-use-widgets
❌ Poor URLs:
https://example.com/p?id=12345
https://example.com/products/item/category/subcategory/blue-widget-2024-sale-discount
```
**URL guidelines:**
- Use hyphens, not underscores
- Lowercase only
- Keep short (< 75 characters)
- Include target keywords naturally
- Avoid parameters when possible
- Use HTTPS always
### HTTPS & security
```html
<!-- Ensure all resources use HTTPS -->
<img src="https://example.com/image.jpg">
<!-- Not: -->
<img src="http://example.com/image.jpg">
```
**Security headers for SEO trust signals:**
```
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
```
---
## On-page SEO
### Title tags
```html
<!-- ❌ Missing or generic -->
<title>Page</title>
<title>Home</title>
<!-- ✅ Descriptive with primary keyword -->
<title>Blue Widgets for Sale | Premium Quality | Example Store</title>
```
**Title tag guidelines:**
- 50-60 characters (Google truncates ~60)
- Primary keyword near the beginning
- Unique for every page
- Brand name at end (unless homepage)
- Action-oriented when appropriate
### Meta descriptions
```html
<!-- ❌ Missing or duplicate -->
<meta name="description" content="">
<!-- ✅ Compelling and unique -->
<meta name="description" content="Shop premium blue widgets with free shipping. 30-day returns. Rated 4.9/5 by 10,000+ customers. Order today and save 20%.">
```
**Meta description guidelines:**
- 150-160 characters
- Include primary keyword naturally
- Compelling call-to-action
- Unique for every page
- Matches page content
### Heading structure
```html
<!-- ❌ Poor structure -->
<h2>Welcome to Our Store</h2>
<h4>Products</h4>
<h1>Contact Us</h1>
<!-- ✅ Proper hierarchy -->
<h1>Blue Widgets - Premium Quality</h1>
<h2>Product Features</h2>
<h3>Durability</h3>
<h3>Design</h3>
<h2>Customer Reviews</h2>
<h2>Pricing</h2>
```
**Heading guidelines:**
- Single `<h1>` per page (the main topic)
- Logical hierarchy (don't skip levels)
- Include keywords naturally
- Descriptive, not generic
### Image SEO
```html
<!-- ❌ Poor image SEO -->
<img src="IMG_12345.jpg">
<!-- ✅ Optimized image -->
<img src="blue-widget-product-photo.webp"
alt="Blue widget with chrome finish, side view showing control panel"
width="800"
height="600"
loading="lazy">
```
**Image guidelines:**
- Descriptive filenames with keywords
- Alt text describes the image content
- Compressed and properly sized
- WebP/AVIF with fallbacks
- Lazy load below-fold images
### Internal linking
```html
<!-- ❌ Non-descriptive -->
<a href="/products">Click here</a>
<a href="/widgets">Read more</a>
<!-- ✅ Descriptive anchor text -->
<a href="/products/blue-widgets">Browse our blue widget collection</a>
<a href="/guides/widget-maintenance">Learn how to maintain your widgets</a>
```
**Linking guidelines:**
- Descriptive anchor text with keywords
- Link to relevant internal pages
- Reasonable number of links per page
- Fix broken links promptly
- Use breadcrumbs for hierarchy
---
## Structured data (JSON-LD)
### Organization
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Example Company",
"url": "https://example.com",
"logo": "https://example.com/logo.png",
"sameAs": [
"https://twitter.com/example",
"https://linkedin.com/company/example"
],
"contactPoint": {
"@type": "ContactPoint",
"telephone": "+1-555-123-4567",
"contactType": "customer service"
}
}
</script>
```
### Article
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "How to Choose the Right Widget",
"description": "Complete guide to selecting widgets for your needs.",
"image": "https://example.com/article-image.jpg",
"author": {
"@type": "Person",
"name": "Jane Smith",
"url": "https://example.com/authors/jane-smith"
},
"publisher": {
"@type": "Organization",
"name": "Example Blog",
"logo": {
"@type": "ImageObject",
"url": "https://example.com/logo.png"
}
},
"datePublished": "2024-01-15",
"dateModified": "2024-01-20"
}
</script>
```
### Product
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Blue Widget Pro",
"image": "https://example.com/blue-widget.jpg",
"description": "Premium blue widget with advanced features.",
"brand": {
"@type": "Brand",
"name": "WidgetCo"
},
"offers": {
"@type": "Offer",
"price": "49.99",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"url": "https://example.com/products/blue-widget"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"reviewCount": "1250"
}
}
</script>
```
### FAQ
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What colors are available?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Our widgets come in blue, red, and green."
}
},
{
"@type": "Question",
"name": "What is the warranty?",
"acceptedAnswer": {
"@type": "Answer",
"text": "All widgets include a 2-year warranty."
}
}
]
}
</script>
```
### Breadcrumbs
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://example.com"
},
{
"@type": "ListItem",
"position": 2,
"name": "Products",
"item": "https://example.com/products"
},
{
"@type": "ListItem",
"position": 3,
"name": "Blue Widgets",
"item": "https://example.com/products/blue-widgets"
}
]
}
</script>
```
### Validation
Test structured data at:
- [Google Rich Results Test](https://search.google.com/test/rich-results)
- [Schema.org Validator](https://validator.schema.org/)
---
## Mobile SEO
### Responsive design
```html
<!-- ❌ Not mobile-friendly -->
<meta name="viewport" content="width=1024">
<!-- ✅ Responsive viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1">
```
### Tap targets
```css
/* ❌ Too small for mobile */
.small-link {
padding: 4px;
font-size: 12px;
}
/* ✅ Adequate tap target */
.mobile-friendly-link {
padding: 12px;
font-size: 16px;
min-height: 48px;
min-width: 48px;
}
```
### Font sizes
```css
/* ❌ Too small on mobile */
body {
font-size: 10px;
}
/* ✅ Readable without zooming */
body {
font-size: 16px;
line-height: 1.5;
}
```
---
## International SEO
### Hreflang tags
```html
<!-- For multi-language sites -->
<link rel="alternate" hreflang="en" href="https://example.com/page">
<link rel="alternate" hreflang="es" href="https://example.com/es/page">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/page">
<link rel="alternate" hreflang="x-default" href="https://example.com/page">
```
### Language declaration
```html
<html lang="en">
<!-- or -->
<html lang="es-MX">
```
---
## SEO audit checklist
### Critical
- [ ] HTTPS enabled
- [ ] robots.txt allows crawling
- [ ] No `noindex` on important pages
- [ ] Title tags present and unique
- [ ] Single `<h1>` per page
### High priority
- [ ] Meta descriptions present
- [ ] Sitemap submitted
- [ ] Canonical URLs set
- [ ] Mobile-responsive
- [ ] Core Web Vitals passing
### Medium priority
- [ ] Structured data implemented
- [ ] Internal linking strategy
- [ ] Image alt text
- [ ] Descriptive URLs
- [ ] Breadcrumb navigation
### Ongoing
- [ ] Fix crawl errors in Search Console
- [ ] Update sitemap when content changes
- [ ] Monitor ranking changes
- [ ] Check for broken links
- [ ] Review Search Console insights
---
## Tools
| Tool | Use |
|------|-----|
| Google Search Console | Monitor indexing, fix issues |
| Google PageSpeed Insights | Performance + Core Web Vitals |
| Rich Results Test | Validate structured data |
| Lighthouse | Full SEO audit |
| Screaming Frog | Crawl analysis |
## References
- [Google Search Central](https://developers.google.com/search)
- [Schema.org](https://schema.org/)
- [Core Web Vitals](../core-web-vitals/SKILL.md)
- [Web Quality Audit](../web-quality-audit/SKILL.md)

View File

@@ -2,7 +2,7 @@
<template>
<head>
<title>Aran Roig</title>
<title>Aran Roig Developer, Artist & Designer</title>
</head>
<div>
<NuxtRouteAnnouncer />
@@ -13,6 +13,21 @@
</template>
<script setup lang="ts">
const { locale } = useI18n()
// Set global html lang attribute based on current locale
useHead({
htmlAttrs: {
lang: computed(() => {
const map: Record<string, string> = { en: 'en-US', es: 'es-ES', ca: 'ca-ES' }
return map[locale.value] || 'en-US'
})
},
meta: [
{ name: 'author', content: 'Aran Roig' },
{ name: 'robots', content: 'index, follow' }
]
})
onMounted(() => {
setupTheme()

View File

@@ -0,0 +1,32 @@
// Shared TUI transition mixin
@mixin tui-transition($steps: 2) {
transition: all 0.1s steps($steps, end);
}
// Box-drawing content string for borders (top/bottom frame lines)
$box-line-top: "═══════════════════";
/* Global rules - no-sprite pages hide the undertable sprite area */
.no-sprite .undertable-wrapper {
display: none;
}
// Dropdown transition keyframes (used by LanguageSelector and ThemeSelector)
.dropdown-enter-active, .dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-enter-from, .dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
// Dropdown button shared styles
.tui-dropdown-button {
background: var(--color-background-fore);
color: var(--color-text);
font-size: 1em;
border: 1px solid var(--color-border-color);
padding: 8px 12px;
border-radius: 0;
cursor: pointer;
}

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
interface ArtItem {
slug: string;
title: string;
thumb: string;
}
defineProps<{
items: ArtItem[];
}>();
</script>
<template>
<div class="grid">
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(250px, 1fr));
gap: 16px;
padding: 24px 0;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(2, minmax(200px, 1fr));
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(1, minmax(200px, 1fr));
padding: 16px 0;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
defineProps<{
item: { title: string; thumb?: string };
nuxtImg?: boolean;
imgWidth?: number;
imgHeight?: number;
}>();
</script>
<template>
<div class="selector">
<span class="selector-border-top" aria-hidden="true"></span>
<component :is="nuxtImg ? 'NuxtImg' : 'img'"
v-if="item.thumb"
:src="item.thumb"
:alt="item.title"
class="selector-img"
width="600" height="250"
fit="cover"
/>
<span class="selector-border-bottom" aria-hidden="true"></span>
<div class="overlay-label">{{ item.title }}</div>
</div>
</template>
<style lang="scss" scoped>
.selector {
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
cursor: pointer;
transition: all 0.1s steps(3, end);
display: block;
border: 2px solid var(--color-border-color);
background-color: var(--color-background-fore);
&:hover {
border-color: var(--color-link);
transform: translateY(-2px);
box-shadow: 4px -4px 0px 0px var(--color-link);
}
.selector-border-top,
.selector-border-bottom {
position: absolute;
left: 30px;
right: 30px;
height: 0;
color: var(--color-border-color);
font-size: 0;
line-height: 0;
white-space: nowrap;
z-index: 10;
transition: color 0.1s steps(2, end);
}
.selector-border-top {
top: -2px;
}
.selector-border-bottom {
bottom: -2px;
}
}
.selector-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.overlay-label {
position: absolute;
bottom: -2px;
left: 30px;
background-color: var(--color-link);
color: var(--color-background-fore);
padding: 2px 8px;
font-size: 0.7rem;
letter-spacing: 1px;
text-transform: uppercase;
box-shadow: inset 0 -2px 0px 0px rgba(0,0,0,0.3);
pointer-events: none;
}
/* Ensure hovered state works when selector is wrapped in a link */
.selector-wrap {
display: block;
text-decoration: none;
}
@media (max-width: 900px) {
.selector {
height: 200px;
}
}
@media (max-width: 640px) {
.selector {
height: 180px;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
interface Post {
slug: string;
title: string;
date: string;
description: string;
}
defineProps<{
posts: Post[];
linkTo?: (slug: string) => string;
}>();
</script>
<template>
<section class="blog-section">
<ul class="tui-list">
<li v-for="post in posts" :key="post.slug" class="blog-entry">
<NuxtLink v-if="linkTo" class="entry-link" :to="linkTo(post.slug)">
<span class="entry-title">{{ post.title }}</span>
</NuxtLink>
<NuxtLink v-else class="entry-link" :to="`#${post.slug}`">
<span class="entry-title">{{ post.title }}</span>
</NuxtLink>
<span class="entry-meta">[{{ post.date }}] {{ post.description }}</span>
</li>
</ul>
</section>
</template>
<style lang="scss" scoped>
.tui-list {
list-style: none;
margin: 0;
padding: 0;
}
.blog-entry {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 16px;
border-left: 2px solid var(--color-border-color);
margin-bottom: 4px;
transition: all 0.1s steps(2, end);
&:hover {
border-left-color: var(--color-link);
background-color: var(--color-hover);
}
&::before {
display: none;
}
}
.entry-link {
text-decoration: none;
color: var(--color-text);
&:hover {
color: var(--color-link);
text-shadow: 0 0 6px var(--color-link);
}
}
.entry-title {
font-size: 1rem;
transition: all 0.1s steps(2, end);
}
.entry-meta {
color: var(--color-text);
font-size: 0.8rem;
opacity: 0.6;
}
@media screen and (max-width: 600px) {
.blog-entry {
padding: 6px 12px;
}
.entry-title {
font-size: 0.9rem;
}
}
</style>

View File

@@ -0,0 +1,76 @@
import { useHead, useSeoMeta, useRoute } from '#imports'
interface SeoOptions {
title?: string
description?: string
ogImage?: string
canonicalUrl?: string
articleDate?: string
structuredData?: Record<string, unknown>
}
export function useSeo(options: SeoOptions = {}) {
const { locale } = useI18n()
const route = useRoute()
const baseURL = 'https://aranroig.com'
// Map locale codes to BCP 47 tags
const localeToIso: Record<string, string> = {
en: 'en-US',
es: 'es-ES',
ca: 'ca-ES'
}
const canonicalPath = computed(() => {
if (!options.canonicalUrl) return ''
return options.canonicalUrl.startsWith('http') ? options.canonicalUrl : `${baseURL}${options.canonicalUrl}`
})
// Hreflang alternate links for all locales
const hreflangs = computed(() => {
const currentPath = typeof route.path === 'string' ? route.path : ''
const base = currentPath.replace(/^(\/en|\/es|\/ca)?\//, '/')
return [
{ rel: 'alternate', hreflang: 'en', href: `${baseURL}/en${base}` },
{ rel: 'alternate', hreflang: 'es', href: `${baseURL}/es${base}` },
{ rel: 'alternate', hreflang: 'ca', href: `${baseURL}/ca${base}` }
] as Array<{rel: string; hreflang: string; href: string}>
})
const seoTitle = computed(() => {
if (options.title) return `${options.title} | Aran Roig`
return 'Aran Roig — Developer, Artist & Designer'
})
const seoDescription = options.description || 'Personal website of Aran Roig — developer, artist, and designer. Explore projects, blog posts, art gallery, and more.'
useSeoMeta({
title: seoTitle.value,
description: seoDescription,
ogTitle: seoTitle.value,
ogDescription: seoDescription,
ogImage: options.ogImage || `${baseURL}/og-image.png`,
ogUrl: canonicalPath.value,
ogType: 'website',
twitterCard: 'summary_large_image',
twitterTitle: seoTitle.value,
twitterDescription: seoDescription,
twitterImage: options.ogImage || `${baseURL}/og-image.png`,
canonical: canonicalPath.value
})
useHead({
htmlAttrs: { lang: computed(() => localeToIso[locale.value] || 'en-US') },
link: [
...hreflangs.value,
...(canonicalPath.value ? [{ rel: 'canonical', href: canonicalPath.value }] : [])
],
script: options.structuredData ? [
{
type: 'application/ld+json',
innerHTML: JSON.stringify(options.structuredData)
}
] : []
})
}

View File

@@ -1,5 +1,6 @@
<script setup>
<script setup lang="ts">
import TableHeader from '~/components/parts/TableHeader.vue';
import { useSeo } from '~/composables/seo';
const slug = useRoute().params.slug;
const { locale } = useI18n();
@@ -7,14 +8,37 @@ const { locale } = useI18n();
const { data: post } = await useAsyncData(`art-${slug}`, () =>
queryCollection(`art`).path(`/art/${locale.value}/${slug}`).first()
, {watch: [locale]})
useSeo({
title: post.value?.title || '',
description: `Art piece: ${post.value?.title || ''} by Aran Roig`,
canonicalUrl: `/art/${locale.value}/${slug}`,
structuredData: post.value ? {
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: post.value.title,
description: `Art piece by Aran Roig titled "${post.value.title}"`,
author: {
'@type': 'Person',
name: 'Aran Roig'
},
datePublished: post.value.date,
image: post.value.thumb ? `${post.value.thumb.startsWith('http') ? '' : 'https://aranroig.com'}${post.value.thumb}` : undefined,
creativeWorkTheme: {
'@type': 'Thing',
name: 'Digital Art'
}
} : undefined,
ogImage: post.value?.thumb
});
</script>
<template>
<!-- Render the blog post as Prose & Vue components -->
<div class="no-sprite">
<TableHeader></TableHeader>
</div>
<div class="extended-container">
<h1 v-if="post" class="art-title">{{ post.title }}</h1>
<ContentRenderer v-if="post" :value="post" class="art" />
</div>
</template>
@@ -26,6 +50,44 @@ const { data: post } = await useAsyncData(`art-${slug}`, () =>
margin-top: 32px;
}
.art {
h2 {
a {
text-decoration: none;
color: var(--color-text);
}
}
img {
margin: auto;
display: flex;
max-height: 77vh;
max-width: 100%;
}
}
.art-title {
font-family: 'Hurmit', monospace;
font-size: 1.8rem;
color: var(--color-text);
margin-bottom: 16px;
line-height: 1.3;
}
</style>
<style lang="scss">
.no-sprite .undertable-wrapper {
display: none;
}
</style>
<style lang="scss">
.extended-container {
width: 100%;
margin: auto;
margin-top: 32px;
}
.art {
h2 {
a {

View File

@@ -2,10 +2,36 @@
import { ref, computed } from 'vue';
import FixedLayout from '~/components/layouts/FixedLayout.vue';
import TableHeader from '~/components/parts/TableHeader.vue';
import { useSeo } from '~/composables/seo';
const { locale, t } = useI18n();
const localePath = useLocalePath();
useSeo({
title: 'Art Gallery',
description: 'Browse the digital art gallery of Aran Roig. Explore original artwork, generative art, pixel art, and creative visual experiments.',
canonicalUrl: '/art'
});
// WebPage structured data for art gallery listing
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'CollectionPage',
headline: 'Art Gallery by Aran Roig',
description: 'Browse the digital art gallery of Aran Roig. Explore original artwork, generative art, pixel art, and creative visual experiments.',
url: 'https://aranroig.com/art',
author: {
'@type': 'Person',
name: 'Aran Roig'
},
inLanguage: ['en', 'es', 'ca']
})
}]
});
const { data: posts } = useAsyncData('art-posts', async () => {
const currentLocale = locale.value;
@@ -58,7 +84,7 @@ const displayedArt = computed(() => {
</div>
<FixedLayout>
<Container>
<h2 class="section-title">ART GALLERY</h2>
<h1 class="section-title">{{ t('pages.art_heading') }}</h1>
<div class="grid">
<NuxtLink v-for="art in displayedArt"
:key="art.slug"

View File

@@ -1,6 +1,8 @@
<script setup>
<script setup lang="ts">
import FixedLayout from '~/components/layouts/FixedLayout.vue';
import TableHeader from '~/components/parts/TableHeader.vue';
import { useSeo } from '~/composables/seo';
import { computed, onBeforeMount } from 'vue';
const slug = useRoute().params.slug;
const { locale } = useI18n();
@@ -8,21 +10,81 @@ const { locale } = useI18n();
const { data: post } = await useAsyncData(`blog-${slug}`, () =>
queryCollection(`blog`).path(`/blog/${locale.value}/${slug}`).first()
, {watch: [locale]})
useSeo({
title: post.value?.title || '',
description: post.value?.description || '',
canonicalUrl: `/blog/${locale.value}/${slug}`,
articleDate: post.value?.date,
structuredData: post.value ? {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.value.title,
description: post.value.description,
datePublished: post.value.date,
dateModified: post.value.date,
author: {
'@type': 'Person',
name: 'Aran Roig'
},
publisher: {
'@type': 'Organization',
name: 'Aran Roig',
url: 'https://aranroig.com'
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://aranroig.com/blog/${locale.value}/${slug}`
}
} : undefined,
ogImage: post.value ? `https://aranroig.com/blog/${locale.value}/${slug}/cover.png` : undefined
});
</script>
<template>
<!-- Render the blog post as Prose & Vue components -->
<div class="no-sprite">
<TableHeader></TableHeader>
</div>
<FixedLayout>
<Container>
<h1 v-if="post" class="blog-post-title">{{ post.title }}</h1>
<ContentRenderer v-if="post" :value="post" class="blog" />
</Container>
</FixedLayout>
<Footer></Footer>
</template>
<style lang="scss">
.blog {
h2 {
a {
text-decoration: none;
color: var(--color-text);
}
}
img {
margin: auto;
display: flex;
max-height: 400px;
}
}
.blog-post-title {
font-family: 'Hurmit', monospace;
font-size: 1.8rem;
color: var(--color-text);
margin-bottom: 16px;
line-height: 1.3;
}
</style>
<style lang="scss">
.no-sprite .undertable-wrapper {
display: none;
}
</style>
<style lang="scss">
.blog {
h2 {

View File

@@ -2,10 +2,36 @@
import TableHeader from '~/components/parts/TableHeader.vue';
import FixedLayout from '~/components/layouts/FixedLayout.vue';
import { ref, computed } from 'vue';
import { useSeo } from '~/composables/seo'
const { locale } = useI18n();
const { t } = useI18n();
const localePath = useLocalePath()
useSeo({
title: 'Blog',
description: 'Read blog posts by Aran Roig on software engineering, web development, digital art, and creative coding.',
canonicalUrl: '/blog'
});
// WebPage structured data for blog listing
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'CollectionPage',
headline: 'Blog Posts by Aran Roig',
description: 'Read blog posts by Aran Roig on software engineering, web development, digital art, and creative coding.',
url: 'https://aranroig.com/blog',
author: {
'@type': 'Person',
name: 'Aran Roig'
},
inLanguage: ['en', 'es', 'ca']
})
}]
});
const {data: posts, refresh} = useAsyncData('blog-posts', async () =>
await queryCollection(`blog`).where('path', 'LIKE', `/blog/${locale.value}/%`).order('date', 'DESC').all()
, {watch: [locale, () => useRoute().path]});
@@ -30,8 +56,9 @@ const displayedPosts = computed(() => {
</div>
<FixedLayout>
<Container>
<h1 style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;">Blog Aran Roig</h1>
<section class="blog-section">
<h2 class="section-title">BLOG ENTRIES</h2>
<h2 class="section-title">{{ t('pages.blog_heading') }}</h2>
<ul class="tui-list">
<li v-for="post in displayedPosts" :key="post.slug" class="blog-entry">
<NuxtLink class="entry-link" :to="localePath({ name: 'blog-slug', params: { slug: post.slug } })">

View File

@@ -1,10 +1,32 @@
<script setup lang="ts">
import TableHeader from '~/components/parts/TableHeader.vue';
import FixedLayout from '~/components/layouts/FixedLayout.vue';
import { useSeo } from '~/composables/seo';
const { get, post } = api();
const { locale } = useI18n();
useSeo({
title: 'Contact',
description: 'Get in touch with Aran Roig. Reach out for collaborations, freelance projects, or just to say hello.',
canonicalUrl: '/contact'
});
// WebPage structured data for contact page
useHead({
script: [{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Contact Aran Roig',
description: 'Get in touch with Aran Roig for collaborations, freelance projects, or inquiries.',
url: 'https://aranroig.com/contact',
inLanguage: ['en', 'es', 'ca']
})
}]
});
// Move useAsyncData to top level — NOT inside onMounted
const { data: markdown } = await useAsyncData(`fixed`, async () =>
@@ -19,6 +41,7 @@ const { data: markdown } = await useAsyncData(`fixed`, async () =>
<FixedLayout>
<Container>
<h1>Contact Aran Roig</h1>
<ContentRenderer v-if="markdown" :value="markdown"></ContentRenderer>
</Container>
</FixedLayout>

View File

@@ -2,11 +2,47 @@
import FixedLayout from '~/components/layouts/FixedLayout.vue';
import TableHeader from '~/components/parts/TableHeader.vue';
import api from '~/composables/api'
import { useSeo } from '~/composables/seo'
const { get, post } = api();
const { locale } = useI18n();
const { t } = useI18n();
useSeo({
title: 'Home',
description: 'Aran Roig — Developer, Artist & Designer. Exploring projects in software engineering, digital art, and interactive design.',
canonicalUrl: '/',
structuredData: {
'@context': 'https://schema.org',
'@type': 'WebPage',
'@id': 'https://aranroig.com/#webpage',
url: 'https://aranroig.com',
name: 'Aran Roig — Developer, Artist & Designer',
description: 'Personal website of Aran Roig — developer, artist, and designer.',
inLanguage: ['en', 'es', 'ca'],
publisher: {
'@type': 'Person',
name: 'Aran Roig'
}
}
});
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Aran Roig',
url: 'https://aranroig.com',
sameAs: [],
jobTitle: 'Developer, Artist & Designer'
})
}
]
});
const currentPath = computed(() => route.path);
const route = useRoute();
@@ -126,6 +162,7 @@ const sectionTargets = {
<template>
<TableHeader></TableHeader>
<h1 style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;">Aran Roig Developer, Artist & Designer</h1>
<div id="top" style="scroll-margin-top: 60px;">&nbsp;</div>
<FixedLayout>

View File

@@ -27,5 +27,20 @@ export default defineNuxtConfig({
vueI18n: './i18n.config.ts',
langDir: 'locales/'
},
app: {
head: {
htmlAttrs: { lang: 'en' },
bodyAttrs: { class: '' },
title: 'Aran Roig — Developer, Artist & Designer',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'Personal website of Aran Roig — developer, artist, and designer. Explore projects, blog posts, art gallery, and more.' },
{ name: 'author', content: 'Aran Roig' },
{ name: 'robots', content: 'index, follow' }
]
}
},
modules: ['@nuxtjs/i18n', '@nuxt/content', '@nuxt/image']
})

30
skills-lock.json Normal file
View File

@@ -0,0 +1,30 @@
{
"version": 1,
"skills": {
"accessibility": {
"source": "addyosmani/web-quality-skills",
"sourceType": "autoskills-registry",
"computedHash": "bffe3d08cfe92ebad63699f74ce29e35c19850ebfbf474c1463183cfe34d6a09"
},
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "autoskills-registry",
"computedHash": "82fb11a63fb1e35ee2469516ed02d54695f783115b1540c0e783197af4240a3a"
},
"nodejs-backend-patterns": {
"source": "wshobson/agents",
"sourceType": "autoskills-registry",
"computedHash": "710a5e6f83c46e8f6c43356df55c143a7375c4414559a581654ce51709138c55"
},
"nodejs-best-practices": {
"source": "sickn33/antigravity-awesome-skills",
"sourceType": "autoskills-registry",
"computedHash": "7361ab02fb6b09913e3bdd9cf61c629ed6c17de9485e6a781054e5d437ccfc29"
},
"seo": {
"source": "addyosmani/web-quality-skills",
"sourceType": "autoskills-registry",
"computedHash": "c184da724d1c61ad077f27418ea8e7e88fd54bcdf98165e18be7e4681cbd5e20"
}
}
}