docs: complete project research
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
# Architecture Patterns
|
||||
|
||||
**Project:** Portfolio Killian' Dalcin — Nuxt 4 SSR
|
||||
**Researched:** 2026-04-10
|
||||
**Confidence:** HIGH (verified against actual codebase + Nuxt 4 docs patterns)
|
||||
|
||||
---
|
||||
|
||||
## Is the Current Architecture Sound?
|
||||
|
||||
**Yes — the foundation is solid.** The core SSR pipeline is correctly implemented:
|
||||
|
||||
- `ssr: true` + `compatibilityVersion: 4` — full SSR, not hybrid
|
||||
- `@nuxtjs/i18n` with `prefix_except_default` — SEO-correct URL scheme (FR at `/`, EN at `/en/`)
|
||||
- `@nuxtjs/color-mode` with `storage: 'cookie'` — theme class applied server-side, no flash
|
||||
- `useSeoMeta()` with reactive `() => t('...')` callbacks — meta resolves server-side per locale
|
||||
- `useLocaleHead()` in `app.vue` — injects `hreflang` alternates on every page automatically
|
||||
- Data layer (`app/data/*.ts` + `useProjects()`) is clean: static IDs, translated fields via i18n keys, reactive recomputation on locale change
|
||||
|
||||
Three real problems exist in the current implementation (not architecture flaws — just execution gaps):
|
||||
|
||||
1. **og:image hardcoded** to `https://killiandalcin.fr/og-image.png` on all 6 pages, including project detail where `project.image` is already available
|
||||
2. **JSON-LD only on homepage** — other pages have no structured data; `jobTitle` still says "Developpeur Full Stack Freelance" instead of positioning Hytale
|
||||
3. **ogUrl missing** — `useSeoMeta()` calls don't include `ogUrl`, so the canonical URL is absent from Open Graph, though `<link rel="canonical">` is provided by `@nuxtjs/i18n`
|
||||
|
||||
---
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
The existing layer structure is correct. No refactoring needed. Extensions follow the same pattern:
|
||||
|
||||
```
|
||||
Pages (app/pages/)
|
||||
hytale.vue ← new page, same pattern as fiverr.vue
|
||||
project/[id].vue ← add dynamic og:image (project.image already there)
|
||||
|
||||
Composables (app/composables/)
|
||||
useSeoMeta → per-page calls ← add ogUrl to every page
|
||||
useJsonLd.ts ← new: centralize JSON-LD generation
|
||||
|
||||
Data (app/data/)
|
||||
hytale.ts ← new: pricing tiers, service cards, tech highlights
|
||||
site.ts ← update jobTitle to Hytale positioning
|
||||
|
||||
Locales (app/locales/fr.json, en.json)
|
||||
seo.hytale.* ← new SEO keys
|
||||
hytale.* ← new page content keys
|
||||
```
|
||||
|
||||
### Component Boundaries for the Hytale Page
|
||||
|
||||
| Component | Responsibility | Communicates With |
|
||||
|-----------|---------------|-------------------|
|
||||
| `pages/hytale.vue` | Page assembly, SEO, JSON-LD | `HytaleHeroSection`, `HytalePricingGrid`, `HytaleServiceCards`, `FAQSection`, `CTASection` |
|
||||
| `sections/HytaleHeroSection.vue` | Hero — "Hytale Plugin Developer" headline, early access badge | `useI18n()` |
|
||||
| `sections/HytalePricingGrid.vue` | 3-column pricing table (Simple / Complex / Sur-mesure + Maintenance) | `app/data/hytale.ts` via props |
|
||||
| `sections/HytaleServiceCards.vue` | What's included per service, tech stack used | `app/data/hytale.ts` via props |
|
||||
| Reuse `FAQSection.vue` | Hytale-specific FAQs | `data/faq.ts` (add `hytaleFAQs` export) |
|
||||
| Reuse `CTASection.vue` | Call to action to contact / Fiverr | props |
|
||||
|
||||
Follow the `fiverr.vue` structural pattern exactly — it already does service cards correctly. The Hytale page is a thematic variant, not a new pattern.
|
||||
|
||||
---
|
||||
|
||||
## og:image Hardcoding Fix
|
||||
|
||||
**Problem:** All pages including `project/[id].vue` use the same static `og-image.png`. The project detail page already has `project.value?.image` available but ignores it.
|
||||
|
||||
**Fix: per-page og:image strategy**
|
||||
|
||||
```typescript
|
||||
// pages/project/[id].vue — already has project data, just use it
|
||||
useSeoMeta({
|
||||
// ...
|
||||
ogImage: () => project.value?.image
|
||||
? `https://killiandalcin.fr${project.value.image}`
|
||||
: 'https://killiandalcin.fr/og-image.png',
|
||||
})
|
||||
```
|
||||
|
||||
For all other pages, create dedicated OG images rather than sharing one. The naming convention:
|
||||
|
||||
| Page | File | Dimensions |
|
||||
|------|------|-----------|
|
||||
| Default / fallback | `/public/og/og-default.png` | 1200×630 |
|
||||
| Hytale | `/public/og/og-hytale.png` | 1200×630 |
|
||||
| Fiverr | `/public/og/og-fiverr.png` | 1200×630 |
|
||||
| Projects | `/public/og/og-projects.png` | 1200×630 |
|
||||
|
||||
Each page's `useSeoMeta()` references its own file. This is the simplest, most reliable approach — no server-side image generation required, works perfectly with SSR, zero dependencies.
|
||||
|
||||
**Do not use `@vercel/og` or `nuxt-og-image`.** The portfolio is Docker-deployed, not Vercel. `nuxt-og-image` adds a Satori/Chromium dependency and requires additional config for non-Vercel deployments. Static pre-made images are sufficient for a portfolio and have zero runtime cost.
|
||||
|
||||
---
|
||||
|
||||
## Canonical URL Strategy with prefix_except_default
|
||||
|
||||
**Current situation:** `@nuxtjs/i18n` with `prefix_except_default` + `baseUrl: 'https://killiandalcin.fr'` automatically generates:
|
||||
|
||||
```html
|
||||
<!-- On / (FR default, no prefix) -->
|
||||
<link rel="canonical" href="https://killiandalcin.fr/" />
|
||||
<link rel="alternate" hreflang="fr" href="https://killiandalcin.fr/" />
|
||||
<link rel="alternate" hreflang="en" href="https://killiandalcin.fr/en/" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://killiandalcin.fr/" />
|
||||
|
||||
<!-- On /en/ -->
|
||||
<link rel="canonical" href="https://killiandalcin.fr/en/" />
|
||||
<link rel="alternate" hreflang="fr" href="https://killiandalcin.fr/" />
|
||||
<link rel="alternate" hreflang="en" href="https://killiandalcin.fr/en/" />
|
||||
```
|
||||
|
||||
This is correct — `useLocaleHead()` in `app.vue` handles all of this automatically. **No manual canonical management needed.**
|
||||
|
||||
The one gap is `ogUrl` in Open Graph. Add it to every page's `useSeoMeta()`:
|
||||
|
||||
```typescript
|
||||
// Pattern for every page
|
||||
const { locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
useSeoMeta({
|
||||
// ... existing fields ...
|
||||
ogUrl: () => `https://killiandalcin.fr${localePath('/hytale')}`,
|
||||
})
|
||||
```
|
||||
|
||||
`useLocalePath()` resolves the correct prefixed path for the current locale (`/hytale` for FR, `/en/hytale` for EN), making `ogUrl` SSR-safe and locale-correct.
|
||||
|
||||
**For the sitemap:** `@nuxtjs/sitemap` already reads from `@nuxtjs/i18n` configuration and generates hreflang entries automatically. No manual sitemap management needed for the Hytale page — it appears automatically when `pages/hytale.vue` is created.
|
||||
|
||||
---
|
||||
|
||||
## JSON-LD Structured Data Patterns
|
||||
|
||||
### What to Use Per Page
|
||||
|
||||
| Page | Schema Types | Priority |
|
||||
|------|-------------|----------|
|
||||
| `/` (homepage) | `Person` + `WebSite` + `ProfessionalService` | Exists, needs update |
|
||||
| `/hytale` | `Service` (×3 tiers) + `SoftwareApplication` | New |
|
||||
| `/projects` | `ItemList` of `SoftwareSourceCode` | Nice to have |
|
||||
| `/project/[id]` | `SoftwareSourceCode` or `CreativeWork` | Nice to have |
|
||||
| `/fiverr` | `Offer` per service | Nice to have |
|
||||
| `/contact` | `ContactPage` | Low value |
|
||||
|
||||
### Centralize with a Composable
|
||||
|
||||
The current pattern inlines JSON-LD in each page's `useHead()`. This works but leads to duplication of `Person` data across pages. Centralize reusable schemas:
|
||||
|
||||
```typescript
|
||||
// app/composables/useJsonLd.ts
|
||||
export function usePersonSchema() {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
name: "Killian' DAL-CIN",
|
||||
url: 'https://killiandalcin.fr',
|
||||
jobTitle: 'Hytale Plugin Developer & Full Stack Developer',
|
||||
email: 'contact@killiandalcin.fr',
|
||||
sameAs: [
|
||||
'https://linkedin.com/in/killian-dal-cin',
|
||||
'https://www.fiverr.com/users/mr_kayjaydee',
|
||||
'https://gitea.kamisama.ovh/kayjaydee',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebSiteSchema() {
|
||||
return {
|
||||
'@type': 'WebSite',
|
||||
name: "Killian' DAL-CIN",
|
||||
url: 'https://killiandalcin.fr',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: 'https://killiandalcin.fr/projects?q={search_term_string}',
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useHytaleServiceSchemas() {
|
||||
return [
|
||||
{
|
||||
'@type': 'Service',
|
||||
name: 'Hytale Plugin Development — Simple',
|
||||
provider: { '@type': 'Person', name: "Killian' DAL-CIN" },
|
||||
serviceType: 'Software Development',
|
||||
description: 'Basic Hytale plugin: single mechanic, standard features',
|
||||
offers: { '@type': 'Offer', priceCurrency: 'USD', price: '150' },
|
||||
},
|
||||
{
|
||||
'@type': 'Service',
|
||||
name: 'Hytale Plugin Development — Complex',
|
||||
provider: { '@type': 'Person', name: "Killian' DAL-CIN" },
|
||||
serviceType: 'Software Development',
|
||||
description: 'Advanced Hytale plugin with custom systems, multiplayer, persistence',
|
||||
offers: { '@type': 'Offer', priceCurrency: 'USD', price: '400' },
|
||||
},
|
||||
{
|
||||
'@type': 'Service',
|
||||
name: 'Hytale Plugin Maintenance',
|
||||
provider: { '@type': 'Person', name: "Killian' DAL-CIN" },
|
||||
serviceType: 'Software Maintenance',
|
||||
description: 'Monthly plugin maintenance: update compatibility after Hytale patches',
|
||||
offers: { '@type': 'Offer', priceCurrency: 'USD', priceSpecification: { '@type': 'UnitPriceSpecification', price: '50', unitCode: 'MON' } },
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each page then composes what it needs:
|
||||
|
||||
```typescript
|
||||
// pages/index.vue
|
||||
const { usePersonSchema, useWebSiteSchema } = useJsonLd()
|
||||
useHead({
|
||||
script: [{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [usePersonSchema(), useWebSiteSchema()],
|
||||
}),
|
||||
}],
|
||||
})
|
||||
|
||||
// pages/hytale.vue
|
||||
const { usePersonSchema, useHytaleServiceSchemas } = useJsonLd()
|
||||
useHead({
|
||||
script: [{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [usePersonSchema(), ...useHytaleServiceSchemas()],
|
||||
}),
|
||||
}],
|
||||
})
|
||||
```
|
||||
|
||||
### SoftwareApplication for Hytale Plugins
|
||||
|
||||
For the Hytale page, `SoftwareApplication` is the most SEO-relevant schema for plugin demos or featured work:
|
||||
|
||||
```json
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Hytale Plugin — [Plugin Name]",
|
||||
"applicationCategory": "GameApplication",
|
||||
"operatingSystem": "Hytale",
|
||||
"author": { "@type": "Person", "name": "Killian' DAL-CIN" },
|
||||
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
||||
}
|
||||
```
|
||||
|
||||
Use `SoftwareApplication` only when there are real plugin demos or releasable plugins. Use placeholder data with clearly marked demo content for now.
|
||||
|
||||
---
|
||||
|
||||
## Hytale Page: Pricing Grid Pattern
|
||||
|
||||
The most effective pricing grid for this use case is a 3-tier table with a highlighted middle tier. Nuxt UI v3 provides everything needed without custom components:
|
||||
|
||||
```vue
|
||||
<!-- Structure recommendation — implement with UCard inside a CSS grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Tier: Simple Plugin -->
|
||||
<UCard>...</UCard>
|
||||
|
||||
<!-- Tier: Complex Plugin — highlighted -->
|
||||
<UCard class="ring-2 ring-brand-500 scale-105">
|
||||
<template #header>
|
||||
<UBadge>Most Popular</UBadge>
|
||||
</template>
|
||||
...
|
||||
</UCard>
|
||||
|
||||
<!-- Tier: Sur-mesure -->
|
||||
<UCard>...</UCard>
|
||||
</div>
|
||||
```
|
||||
|
||||
Pricing data belongs in `app/data/hytale.ts` (not `siteConfig`) because it is Hytale-specific content, not site-wide configuration. Translatable labels live in locale files; prices stay in the data file (they are locale-independent).
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Anti-Pattern 1: Duplicating Person schema across pages as raw objects
|
||||
**What:** Copy-pasting the full `Person` object in every page file
|
||||
**Why bad:** When Killian's jobTitle changes to "Hytale Plugin Developer", every page needs updating manually
|
||||
**Instead:** `useJsonLd.ts` composable as shown above — single source of truth
|
||||
|
||||
### Anti-Pattern 2: Using localStorage for any SSR state
|
||||
**What:** Storing locale or theme in localStorage
|
||||
**Why bad:** Causes hydration mismatch — server renders with default, client re-renders after mount
|
||||
**Instead:** Cookie-only (already correctly implemented)
|
||||
|
||||
### Anti-Pattern 3: Static ogUrl strings
|
||||
**What:** `ogUrl: 'https://killiandalcin.fr/hytale'` hardcoded
|
||||
**Why bad:** EN version at `/en/hytale` gets wrong ogUrl, confusing social crawlers
|
||||
**Instead:** `ogUrl: () => \`https://killiandalcin.fr\${localePath('/hytale')}\``
|
||||
|
||||
### Anti-Pattern 4: Translating prices
|
||||
**What:** Putting price strings like "$150" or "150€" in locale files
|
||||
**Why bad:** Prices change independently of language; mixes content types
|
||||
**Instead:** Prices in data file, currency/format computed if needed
|
||||
|
||||
### Anti-Pattern 5: nuxt-og-image for a Docker-SSR deployment
|
||||
**What:** Using Satori-based dynamic OG image generation
|
||||
**Why bad:** Adds Chromium/Satori dependency, complex config for non-Vercel targets, overhead per request
|
||||
**Instead:** Static pre-made OG images per page in `/public/og/`
|
||||
|
||||
---
|
||||
|
||||
## Scalability Considerations
|
||||
|
||||
This is a static-content portfolio. Scalability is not a concern. The architecture is appropriate for:
|
||||
- ~10 pages
|
||||
- ~20 projects
|
||||
- 2 locales
|
||||
- No user accounts, no dynamic data beyond the contact form
|
||||
|
||||
The only scaling vector is content volume (more projects, more services). The current data layer (`app/data/`) handles this cleanly — add entries to arrays, add i18n keys, done.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Nuxt 4 docs: `ssr: true`, `compatibilityVersion: 4` — verified against current nuxt.config.ts
|
||||
- `@nuxtjs/i18n` v9 docs: `prefix_except_default`, `useLocaleHead()`, `useLocalePath()` — HIGH confidence
|
||||
- Schema.org: `Service`, `SoftwareApplication`, `Person`, `WebSite` — HIGH confidence
|
||||
- `@nuxtjs/sitemap` v6: auto i18n integration — HIGH confidence (verified module is installed)
|
||||
- Pattern for `useJsonLd.ts` composable: derived from existing codebase conventions (composable-per-concern)
|
||||
- og:image static file strategy: MEDIUM confidence (sufficient for use case, no dynamic content needed)
|
||||
@@ -0,0 +1,368 @@
|
||||
# Feature Landscape
|
||||
|
||||
**Domain:** Freelancer portfolio — niche game plugin developer (Hytale)
|
||||
**Researched:** 2026-04-10
|
||||
**Confidence:** MEDIUM — based on codebase analysis, domain knowledge, freelance market patterns; WebSearch unavailable
|
||||
|
||||
---
|
||||
|
||||
## 1. Freelancer Portfolio Pricing Pages — Visible vs Hidden
|
||||
|
||||
### Verdict: Show pricing. Always.
|
||||
|
||||
**Rationale for Killian's situation specifically:**
|
||||
|
||||
The Hytale plugin dev market on Fiverr has ~1 direct competitor at $45. The market is not price-sensitive yet — it's trust-sensitive. A server owner searching "Hytale plugin developer" has no reference price. Showing prices:
|
||||
- Filters unserious inquiries before they consume calendar time (critical with only 5-10h/week availability)
|
||||
- Signals confidence and professionalism
|
||||
- Anchors expectations upward (a visible €300 tier makes a €100 tier feel reasonable)
|
||||
- Removes the "I need to ask" friction that kills conversions for international clients in different timezones
|
||||
|
||||
**The only valid reason to hide pricing:** Custom enterprise work where scope varies by 10x. That does not apply here — plugin complexity is bounded.
|
||||
|
||||
### Recommended Tier Structure
|
||||
|
||||
Three tiers work best for plugin dev services. Four or more creates decision paralysis.
|
||||
|
||||
| Tier | Name | Price Range | Contents |
|
||||
|------|------|-------------|----------|
|
||||
| Starter | Simple Plugin | €80–150 | Single feature, documented, delivered in 5 days, 15 days support |
|
||||
| Standard | Complex Plugin | €200–400 | Multiple systems (economy, progression, custom events), 30 days support, 1 revision round |
|
||||
| Premium | Full Experience | €500–900 | Full game loop (dungeon, boss, economy, UI), architecture doc, maintenance contract option |
|
||||
| Recurring | Maintenance | €30–60/mo | Compatibility updates per Hytale version, bug fixes, 1 minor feature/month |
|
||||
|
||||
**Key structural decisions:**
|
||||
|
||||
- Put the maintenance tier visually separate — it is a different product (recurring revenue vs one-shot)
|
||||
- "Starting at" language is fine for custom tier, but anchor with a concrete base price
|
||||
- Show what is explicitly NOT included (server hosting, assets/textures, art) — this prevents scope creep complaints
|
||||
- Add a "Most Popular" badge on Standard. It normalizes the mid tier and lifts average order value.
|
||||
|
||||
**Nuxt UI v3 implementation:** Use `UCard` grid (3 columns desktop, 1 column mobile). The pricing tiers do not need a dedicated library — straight Tailwind + UCard is sufficient. Avoid installing a pricing-specific component library.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hytale Plugin Services Page — What Server Owners Need to See
|
||||
|
||||
### The buyer persona
|
||||
|
||||
A Hytale server owner is typically:
|
||||
- Non-technical (they run a server, they don't code it)
|
||||
- Risk-averse (bad plugin = server downtime = player churn)
|
||||
- Skeptical ("can you even build Hytale plugins, the game just launched")
|
||||
- Looking for long-term relationship, not one-shot delivery
|
||||
|
||||
They have Minecraft server experience and will compare to that ecosystem. Key questions in their head:
|
||||
|
||||
1. Does this dev actually know Hytale specifically, or will they fake it?
|
||||
2. What happens when the next Hytale update breaks my plugin?
|
||||
3. Can I see examples or a demo?
|
||||
4. Will they be around in 6 months?
|
||||
|
||||
### Page sections — recommended order
|
||||
|
||||
**Section 1: Credibility header**
|
||||
- Title: "Hytale Plugin Developer" — not "Game Dev" or "Modder"
|
||||
- One-liner that addresses skepticism: "Building for Hytale since Early Access — I track every API change so your server stays running"
|
||||
- Availability badge (reuse the animated one from HeroSection)
|
||||
|
||||
**Section 2: What makes Hytale plugins different**
|
||||
- Short educational paragraph (3-4 sentences) explaining the Hytale API vs Minecraft — this signals genuine knowledge
|
||||
- Mention: Hytale uses a Java/Kotlin API, the modding system is fundamentally different from Spigot/Paper, requires adapting to active API evolution
|
||||
- This signals to server owners that this developer is not a Minecraft dev pretending
|
||||
|
||||
**Section 3: Services grid (the pricing section described above)**
|
||||
- Four cards: Simple, Complex, Full Experience, Maintenance
|
||||
- Each card must answer: What do I get? How long? What's the support situation?
|
||||
|
||||
**Section 4: The maintenance pitch — this is the unique selling point**
|
||||
- Dedicated callout/alert component (UAlert or custom banner)
|
||||
- Message: Hytale updates frequently during Early Access. Every major update risks breaking plugins. A maintenance contract means zero downtime and no re-negotiation on every patch.
|
||||
- This is the structural advantage from PROJECT.md — lean into it hard
|
||||
|
||||
**Section 5: Process (3-step)**
|
||||
- Step 1: Discovery call (Discord preferred — server owners are on Discord)
|
||||
- Step 2: Spec + quote in 48h
|
||||
- Step 3: Delivery with documentation
|
||||
- Keep this extremely short — server owners don't read walls of text
|
||||
|
||||
**Section 6: Demo / Portfolio**
|
||||
- If no Hytale projects exist yet: use Minecraft plugin work as proof of concept + explicit note "Hytale API is similar to Java/Kotlin modding I've done for Minecraft — I'm actively building Hytale demos"
|
||||
- A "coming soon" placeholder is better than no section — it signals intent
|
||||
- Video embed or GIF of a plugin in action converts better than screenshots
|
||||
|
||||
**Section 7: FAQ specific to Hytale**
|
||||
- "The game is in Early Access, is this risky?" — address directly
|
||||
- "What if Hytale updates break my plugin?" — maintenance contract answer
|
||||
- "Do you have experience with Hytale specifically?" — honest answer + Minecraft parallel
|
||||
- "Can I pay per update?" — redirect to maintenance tier
|
||||
|
||||
**Section 8: CTA**
|
||||
- Primary: "Book a Discovery Call" (Discord link or contact form)
|
||||
- Secondary: "View Pricing"
|
||||
|
||||
### Route: `/hytale` (not `/games` or `/modding`)
|
||||
|
||||
The URL slug matters for SEO. `/hytale` captures "hytale plugin developer" searches directly. Register `hytale` in the nav alongside the existing pages.
|
||||
|
||||
---
|
||||
|
||||
## 3. Testimonials Section — Displaying 5–10 Reviews
|
||||
|
||||
### Current state
|
||||
|
||||
`testimonials.ts` has 5 real reviews, all 5-star, all from Fiverr. All French-language except one English ("awesome guy"). `testimonialsStats` declares 10 total reviews and 25 projects — slightly inflated vs actual data shown.
|
||||
|
||||
### The small-count problem
|
||||
|
||||
5 reviews is not a weakness if framed correctly. The mistake is showing 5 cards and letting the sparseness speak for itself. Solutions:
|
||||
|
||||
**Pattern 1: Featured + Grid (recommended)**
|
||||
- 1 large "featured" testimonial card (the unqlf_ one — it's the most specific and includes project type "Plugin Minecraft")
|
||||
- 4 smaller cards below in 2x2 grid
|
||||
- This asymmetric layout fills the space and makes 5 cards look curated rather than scarce
|
||||
- The `featured: true` flag is already on the right testimonial in the data
|
||||
|
||||
**Pattern 2: Carousel with autoplay**
|
||||
- Works for mobile; hides the count
|
||||
- Risk: autoplay is annoying and reduces trust
|
||||
- Not recommended
|
||||
|
||||
**Pattern 3: Stats bar above cards**
|
||||
- "5.0 / 5.0 — 10+ verified reviews on Fiverr" + link to Fiverr profile
|
||||
- This shifts the authority to a third-party platform — more credible than displaying 5 internal cards
|
||||
- Add a Fiverr logo/icon next to the stat to reinforce the source
|
||||
- The `reviewsLink` already exists in i18n pointing to the Fiverr profile
|
||||
|
||||
**Pattern 4: Language split — show the English one on the EN locale**
|
||||
- The "awesome guy" testimonial (botuhuh) is English and international — feature it prominently on the EN locale
|
||||
- French testimonials go first on FR locale
|
||||
- This can be implemented by sorting testimonials client-side by language match — simple logic in the component
|
||||
|
||||
### Recommended implementation
|
||||
|
||||
Use Pattern 1 + Pattern 3 combined:
|
||||
- Stats bar (5.0 rating, link to Fiverr) at top
|
||||
- Featured card (full-width or 60% width)
|
||||
- 4-card 2x2 grid
|
||||
- "See all reviews on Fiverr" CTA link at bottom
|
||||
|
||||
For the Hytale page specifically, filter testimonials to show only `project_type === 'Plugin Minecraft'` — only 1 exists currently, but it's the most relevant. Pad with a "Review coming soon" placeholder card until more Hytale reviews accumulate.
|
||||
|
||||
### What to fix in the data
|
||||
|
||||
The `results` field is weak — values like `"Prix: Jusqu'à 50€"` and `"Durée: 10 jours"` reveal order size which may underposition the service. Consider removing the price from results display or changing it to outcome-focused language: "Delivered 2 days early", "Still using the plugin 6 months later".
|
||||
|
||||
---
|
||||
|
||||
## 4. Hero Section — Niche Positioning "Hytale Plugin Developer"
|
||||
|
||||
### Current problem (confirmed by reading the code)
|
||||
|
||||
The hero title uses `t('home.title')` which resolves to "Expert Full Stack Developer for Hire | Vue.js, React & Node.js Specialist" in EN. The code splits the last two words for gradient styling — this technique only works if the last two words are the differentiating concept (they're not: "Node.js Specialist" gets the gradient).
|
||||
|
||||
Additionally, the terminal code block hardcodes `'Full Stack Dev'` as the role. The terminal does show `'Hytale Plugins'` in the skills array — good — but it is buried among 6 other skills.
|
||||
|
||||
The availability badge says "Available for projects" — hardcoded English in the template (not using i18n key), which is a bug.
|
||||
|
||||
### Hero best practices for niche positioning
|
||||
|
||||
**Rule 1: Specialization in H1, not in paragraph**
|
||||
The H1 must state the specialization. Visitors scan H1, they don't read paragraphs. "Hytale Plugin Developer" must be in the H1, not in the subtitle or skills list.
|
||||
|
||||
**Rule 2: Acknowledge the broader skill set in subtitle, not in title**
|
||||
The subtitle is the right place to mention Vue/Node/web work — it reassures server owners that this is a real professional developer, not a hobbyist.
|
||||
|
||||
**Rule 3: Dual-audience heading (Hytale + Web)**
|
||||
Killian has two buyer types: Hytale server owners and web clients. The hero must serve the primary audience (Hytale — the strategic bet) without completely alienating web clients.
|
||||
|
||||
Recommended approach: tabbed or split hero is overkill. Use a primary H1 that leads with Hytale, with a secondary descriptor:
|
||||
|
||||
```
|
||||
Hytale Plugin Developer
|
||||
& Freelance Web Dev
|
||||
```
|
||||
|
||||
The gradient goes on "Hytale Plugin Developer". Web services are the secondary line.
|
||||
|
||||
**Rule 4: Specificity = trust**
|
||||
"I build custom Hytale plugins that survive every API update" beats "I build custom solutions that scale."
|
||||
|
||||
**Rule 5: Terminal widget — update the role**
|
||||
Change `'Full Stack Dev'` to `'Hytale Plugin Dev'` in HeroSection.vue. This is a hardcoded string in the template (line 104), not using i18n, so fix it directly.
|
||||
|
||||
### i18n changes required in hero
|
||||
|
||||
The `home.title` split-by-last-2-words approach is fragile — it produces different results for FR and EN because sentence structure differs. The right solution:
|
||||
|
||||
- Split `home.title` into two keys: `home.title.main` and `home.title.highlight`
|
||||
- `home.title.highlight` gets the gradient styling
|
||||
- This removes the brittle `split(' ').slice(-2)` logic
|
||||
|
||||
New i18n values:
|
||||
|
||||
```json
|
||||
// EN
|
||||
"home": {
|
||||
"title": {
|
||||
"main": "Hytale Plugin Developer",
|
||||
"highlight": "& Freelance Web Dev"
|
||||
},
|
||||
"subtitle": "I build custom Hytale plugins that survive every API update — and web apps that convert. 7+ years of experience, 0 missed deadlines."
|
||||
}
|
||||
|
||||
// FR
|
||||
"home": {
|
||||
"title": {
|
||||
"main": "Développeur de Plugins Hytale",
|
||||
"highlight": "& Dev Web Freelance"
|
||||
},
|
||||
"subtitle": "Je construis des plugins Hytale qui survivent à chaque mise à jour de l'API — et des applications web qui convertissent. 7+ ans d'expérience, 0 délais manqués."
|
||||
}
|
||||
```
|
||||
|
||||
The availability badge text ("Available for projects") is hardcoded in HeroSection.vue line 30 — needs to be an i18n key `home.availableBadge`.
|
||||
|
||||
---
|
||||
|
||||
## 5. i18n Audit — Finding Missing and Bad Translations
|
||||
|
||||
### Issues already visible in the current files
|
||||
|
||||
**Structural parity issues (EN has keys FR is missing or vice versa):**
|
||||
- Both files have identical key structure currently — no missing keys found at top level
|
||||
- Risk area: as new pages (Hytale) and features (pricing) are added, keys will diverge
|
||||
|
||||
**Quality issues in existing translations:**
|
||||
|
||||
EN quality problems:
|
||||
- `home.title` = "Expert Full Stack Developer for Hire | Vue.js, React & Node.js Specialist" — generic SEO-spam tone, not the niche positioning needed
|
||||
- `seo.home.title` still says "Freelance Full Stack Developer" — must change to include Hytale
|
||||
- `seo.home.description` makes no mention of Hytale, plugins, or game development
|
||||
- `a11y.logoLabel` = "Full Stack Developer" — must update when hero positioning changes
|
||||
- `footer.servicesList` contains "Mobile Apps" and "Tech Consulting" — neither is a real service offered
|
||||
- `fiverr.subtitle` claims "500+ orders delivered" and "100% satisfaction rate" — verify these are accurate; if not, this is a credibility risk
|
||||
|
||||
FR quality problems:
|
||||
- `about.title` = "À propos de Killian'- Développeur Full Stack" — the dash is missing a space before it (`Killian'-` should be `Killian' —` or just remove)
|
||||
- `faq.homeFaq.delivery.answer` mentions "Bot Discord simple" as the first example — for a Hytale-focused portfolio this should lead with Hytale plugin timelines
|
||||
- `contact.methods.availability` = "Disponible pour remote & freelance" — the English word "remote" in French copy feels lazy; use "télétravail"
|
||||
|
||||
Hardcoded strings (not using i18n at all):
|
||||
- HeroSection.vue line 30: `"Available for projects"` — hardcoded EN
|
||||
- HeroSection.vue line 104: `'Full Stack Dev'` — hardcoded in terminal widget
|
||||
- HeroSection.vue line 148: `"50+ projects"` — hardcoded EN
|
||||
- HeroSection.vue line 152: `"5.0 rating"` — hardcoded EN
|
||||
|
||||
### Audit methodology for ongoing use
|
||||
|
||||
**Method 1: Key extraction diff (best for structural parity)**
|
||||
|
||||
```bash
|
||||
# Extract all keys from both files and diff
|
||||
node -e "
|
||||
const en = require('./i18n/locales/en.json');
|
||||
const fr = require('./i18n/locales/fr.json');
|
||||
const flatten = (obj, prefix='') => Object.keys(obj).reduce((acc, k) => {
|
||||
const key = prefix ? prefix + '.' + k : k;
|
||||
return typeof obj[k] === 'object' && !Array.isArray(obj[k])
|
||||
? { ...acc, ...flatten(obj[k], key) }
|
||||
: { ...acc, [key]: obj[k] };
|
||||
}, {});
|
||||
const enKeys = Object.keys(flatten(en));
|
||||
const frKeys = Object.keys(flatten(fr));
|
||||
const missingInFr = enKeys.filter(k => !frKeys.includes(k));
|
||||
const missingInEn = frKeys.filter(k => !enKeys.includes(k));
|
||||
console.log('Missing in FR:', missingInFr);
|
||||
console.log('Missing in EN:', missingInEn);
|
||||
"
|
||||
```
|
||||
|
||||
Run this after every feature addition that adds i18n keys.
|
||||
|
||||
**Method 2: Search for hardcoded strings in templates**
|
||||
|
||||
```bash
|
||||
# Find text content in templates that bypasses t()
|
||||
grep -rn '>[A-Z][a-z]' app/components/ app/pages/ | grep -v '{{' | grep -v 't(' | grep -v ':' | grep -v '<!--'
|
||||
```
|
||||
|
||||
This catches hardcoded visible text. It produces false positives — review manually.
|
||||
|
||||
**Method 3: Check for untranslated key echoes**
|
||||
|
||||
When `t('some.key')` is called and the key does not exist in the locale, vue-i18n returns the key string itself (e.g., `"some.key"` appears as text). During dev, check the rendered pages in both locales — any dotted key string in the UI is a missing translation.
|
||||
|
||||
**Method 4: Translation quality review checklist**
|
||||
|
||||
For each new locale string, verify:
|
||||
- [ ] Is it natural in the target language (not machine-translated)?
|
||||
- [ ] Does it match the tone of surrounding copy?
|
||||
- [ ] Does it reference the correct product/service (Hytale vs generic web dev)?
|
||||
- [ ] Are technical terms consistent? (e.g., "plugin" vs "mod" vs "extension")
|
||||
- [ ] French: are accents correct? (é, è, ê, à, ù, ç, î, ô — check manually, JSON editors strip them)
|
||||
- [ ] French: formal "vous" used consistently? (current copy mixes registers slightly)
|
||||
|
||||
**Nuxt i18n specific: missing locale file fallback**
|
||||
|
||||
Nuxt i18n falls back to the default locale (FR) when a key is missing in EN. This means missing EN keys silently show French text to English users. The extraction diff above catches this — run it as a pre-commit check or add to CI.
|
||||
|
||||
---
|
||||
|
||||
## Table Stakes vs Differentiators
|
||||
|
||||
### Table Stakes (must have)
|
||||
|
||||
| Feature | Why Expected | Complexity |
|
||||
|---------|--------------|------------|
|
||||
| Pricing grid with 3-4 tiers | Freelancers without visible pricing lose conversions | Low |
|
||||
| Hytale page with service details | Core positioning — without it the site is a generic portfolio | Medium |
|
||||
| Updated hero H1 with Hytale | SEO + first impression — current H1 targets wrong audience | Low |
|
||||
| Testimonials visible on homepage | Social proof — already in codebase, needs display improvement | Low |
|
||||
| i18n complete in both locales | Basic professionalism — hardcoded English strings on FR locale is broken | Low |
|
||||
|
||||
### Differentiators
|
||||
|
||||
| Feature | Value Proposition | Complexity |
|
||||
|---------|-------------------|------------|
|
||||
| Maintenance contract pitch on Hytale page | Unique positioning — recurring revenue, addresses Hytale's update risk | Low (copy only) |
|
||||
| Plugin demo video/GIF embed | Converts skeptics who don't understand what a plugin looks like | Medium |
|
||||
| Hytale API knowledge section | Proves genuine expertise vs "I can do Minecraft so I can do Hytale" | Low (copy only) |
|
||||
| Testimonials filtered by project type per page | Relevant social proof (Minecraft reviews on Hytale page) | Low |
|
||||
|
||||
### Anti-Features
|
||||
|
||||
| Anti-Feature | Why Avoid | What to Do Instead |
|
||||
|--------------|-----------|-------------------|
|
||||
| "Available for projects" badge with hardcoded text | Breaks FR locale | Use i18n key |
|
||||
| Inflated stats (500+ orders, 100% satisfaction) without verification | Credibility risk if questioned | Use conservative/accurate numbers |
|
||||
| Four CTA buttons in hero | Decision paralysis, reduces click-through | Two max: primary (Hytale page) + secondary (contact) |
|
||||
| Mobile Apps service in footer | Killian doesn't offer this — visitor confusion | Remove or replace with "Hytale Plugins" |
|
||||
|
||||
---
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
Updated i18n keys → Hero refocus (new title structure required)
|
||||
Hero refocus → Hytale page (consistent positioning across both)
|
||||
Hytale page → Pricing grid (pricing is a section of the Hytale page, or linked from it)
|
||||
Pricing grid → Testimonials (social proof adjacent to pricing converts better)
|
||||
```
|
||||
|
||||
## MVP Recommendation
|
||||
|
||||
Build in this order:
|
||||
|
||||
1. **i18n fixes + hardcoded string cleanup** — 1-2h, unblocks everything else, fixes broken FR locale
|
||||
2. **Hero refocus** — 1h, highest SEO impact, changes H1 which search engines read first
|
||||
3. **Hytale page** (`/hytale`) — 4-6h, the core missing piece; pricing grid lives here
|
||||
4. **Testimonials display improvement** — 1h, Featured + stats pattern, already has data
|
||||
|
||||
Defer:
|
||||
- Plugin demo video — requires recording/capturing gameplay footage, not a code task
|
||||
- Maintenance contract as a formal product page — copy on the Hytale page is enough for now
|
||||
|
||||
---
|
||||
|
||||
*Sources: codebase analysis (i18n/locales/en.json, fr.json, HeroSection.vue, testimonials.ts, PROJECT.md, STRUCTURE.md) — MEDIUM confidence based on domain knowledge and direct code inspection*
|
||||
@@ -0,0 +1,258 @@
|
||||
# Domain Pitfalls
|
||||
|
||||
**Domain:** Nuxt 4 SSR portfolio — Hytale plugin developer
|
||||
**Researched:** 2026-04-10
|
||||
**Confidence:** MEDIUM (training knowledge Aug 2025 + direct codebase inspection; no live web search available)
|
||||
|
||||
---
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: Static `public/sitemap.xml` Wins Over `@nuxtjs/sitemap`
|
||||
|
||||
**What goes wrong:** Nitro serves files in `public/` as static assets at their exact path. Because `public/sitemap.xml` exists, every request to `/sitemap.xml` returns the hand-written XML from 2025 — the `@nuxtjs/sitemap` dynamic handler is never reached. Google therefore crawls a stale sitemap that: (a) lists `/formation` which has no matching page, (b) omits `/en/*` hreflang variants entirely, (c) never reflects new projects or the upcoming `/hytale` page.
|
||||
|
||||
**Why it happens:** `public/` files are resolved before Nuxt server routes. The module registers a `/_sitemap.xml` route or hooks into `/sitemap.xml` via a Nitro route handler, but a physical file at the same path in `public/` short-circuits the handler.
|
||||
|
||||
**Consequences:** New pages are never indexed. Googlebot sees phantom URLs that 404. The installed module is wasted.
|
||||
|
||||
**Prevention:**
|
||||
1. Delete `public/sitemap.xml` immediately.
|
||||
2. Let `@nuxtjs/sitemap` own the `/sitemap.xml` route entirely.
|
||||
3. Verify `nuxt.config.ts` has `sitemap: { autoLastmod: true, xsl: false }` (or equivalent) and that `site.url` is set — it already is (`https://killiandalcin.fr`).
|
||||
4. Confirm hreflang alternates are generated by checking `/__sitemap__/en-US.xml` style URLs that the module emits per locale.
|
||||
|
||||
**Detection:** `curl https://killiandalcin.fr/sitemap.xml` — if the response contains `lastmod>2025-07-07` the static file is still winning.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: No Rate Limiting on `/api/contact` — Email Flooding Risk
|
||||
|
||||
**What goes wrong:** `server/api/contact.post.ts` opens a nodemailer transporter and calls `sendMail` on every POST with no throttle. A script sending 1 000 requests/minute will fill the inbox, potentially exhaust SMTP sending quota (most providers cap at 500 emails/day on cheap plans), and could get the sending IP blacklisted.
|
||||
|
||||
**Why it happens:** Nuxt/Nitro has no built-in rate-limiting middleware. The current handler only validates field content, not request frequency.
|
||||
|
||||
**Consequences:** SMTP quota exhaustion → legitimate contacts bounce. IP reputation damage. Potential cost overrun if using a paid SMTP tier.
|
||||
|
||||
**Prevention (zero paid services):**
|
||||
|
||||
Option A — In-memory map in a Nitro server plugin (simplest, resets on restart):
|
||||
```typescript
|
||||
// server/plugins/rate-limit.ts
|
||||
const ipMap = new Map<string, { count: number; reset: number }>()
|
||||
|
||||
export default defineNitroPlugin((nitro) => {
|
||||
nitro.hooks.hook('request', (event) => {
|
||||
if (!event.path.startsWith('/api/contact')) return
|
||||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||
const now = Date.now()
|
||||
const window = 60_000 // 1 minute
|
||||
const limit = 3
|
||||
|
||||
const entry = ipMap.get(ip)
|
||||
if (!entry || entry.reset < now) {
|
||||
ipMap.set(ip, { count: 1, reset: now + window })
|
||||
return
|
||||
}
|
||||
entry.count++
|
||||
if (entry.count > limit) {
|
||||
throw createError({ statusCode: 429, message: 'Too many requests' })
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Option B — `unstorage` with a file/Redis driver so the counter survives restarts (better for production).
|
||||
|
||||
Option C — Cloudflare free tier in front of the server: rate-limit rule on `/api/contact` at the edge, zero code changes.
|
||||
|
||||
**Detection warning signs:** SMTP provider sending a quota-exceeded bounce, or inbox flooded with identical submissions.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: Weak Server-Side Email Validation
|
||||
|
||||
**What goes wrong:** Line 12 of `contact.post.ts` uses `email.includes('@')` — `notanemail@` and `a@` both pass. The client uses Zod's `z.string().email()` but an attacker bypasses the browser entirely and posts directly to the API.
|
||||
|
||||
**Why it happens:** Quick guard written as a placeholder; client-side Zod validation gives false confidence.
|
||||
|
||||
**Consequences:** Malformed `from` headers in outgoing email; some SMTP servers reject the mail silently; the `to` address could theoretically be injected via header manipulation if the email value lands in a `Reply-To` without sanitisation.
|
||||
|
||||
**Prevention:** Share a Zod schema between client and server:
|
||||
```typescript
|
||||
// shared/schemas/contact.ts
|
||||
import { z } from 'zod'
|
||||
export const contactSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
email: z.string().email().max(200),
|
||||
message: z.string().min(10).max(5000),
|
||||
})
|
||||
```
|
||||
Then in `contact.post.ts`: `const body = await readValidatedBody(event, contactSchema.parse)`. Nuxt's `readValidatedBody` throws a 422 automatically on schema failure.
|
||||
|
||||
**Detection:** `curl -X POST /api/contact -d '{"name":"x","email":"notanemail","message":"test message here"}'` — if it sends an email, validation is broken.
|
||||
|
||||
---
|
||||
|
||||
## Moderate Pitfalls
|
||||
|
||||
### Pitfall 4: Missing `ogUrl` and `<link rel="canonical">` with `prefix_except_default`
|
||||
|
||||
**What goes wrong:** With `strategy: 'prefix_except_default'` and `defaultLocale: 'fr'`, the homepage is served at both `/` (French) and `/en/` (English). Without canonical tags, Googlebot sees two different URLs for similar content. Without `ogUrl`, Open Graph shares to Facebook/LinkedIn pick up a relative or wrong URL.
|
||||
|
||||
**Why it happens:** `useSeoMeta()` calls in every page omit `ogUrl`. `@nuxtjs/i18n` does NOT automatically inject canonical `<link>` tags — that is the responsibility of `@nuxtjs/sitemap` (via `xhtml:link` in the sitemap) and of the page-level `useHead()`.
|
||||
|
||||
**Consequences:** Google may index `/en/` as a duplicate, split PageRank, or ignore one version entirely. og:url being wrong means link preview cards show the wrong shareable URL.
|
||||
|
||||
**Prevention:**
|
||||
1. Add `canonical` via `useHead` in a shared layout or composable:
|
||||
```typescript
|
||||
// composables/useSeoMeta.ts addition
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
useHead({
|
||||
link: [{ rel: 'canonical', href: `https://killiandalcin.fr${route.path}` }]
|
||||
})
|
||||
```
|
||||
2. Set `ogUrl` in every `useSeoMeta()` call to the full canonical URL.
|
||||
3. Verify `@nuxtjs/sitemap` emits `<xhtml:link rel="alternate">` hreflang entries — it does this automatically when `i18n` module is detected, but only if `public/sitemap.xml` is not overriding it (see Pitfall 1).
|
||||
|
||||
**Detection:** Inspect rendered HTML source — search for `<link rel="canonical"`. If absent, it's missing.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 5: `og:image` Hardcoded to Absolute Domain String
|
||||
|
||||
**What goes wrong:** All 6 page files hardcode `'https://killiandalcin.fr/og-image.png'`. Project detail pages use the same generic image instead of `project.value?.image`. This means every page shares identical OG metadata, reducing click-through from social shares and weakening per-page SEO signals.
|
||||
|
||||
**Why it happens:** Quick shortcut during initial migration; `@nuxt/image` was not yet wired into the SEO composable.
|
||||
|
||||
**Consequences:** Social previews all look identical. Future domain changes require grep-and-replace across 6+ files. No opportunity for Hytale-specific OG imagery on the dedicated page.
|
||||
|
||||
**Prevention:** Centralise in `useSeoMeta` composable:
|
||||
```typescript
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const baseUrl = runtimeConfig.public.siteUrl ?? 'https://killiandalcin.fr'
|
||||
// Pass image path, resolve to absolute URL inside the composable
|
||||
```
|
||||
For dynamic project pages, derive from `project.value.image` with a fallback to the global OG image.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 6: `@nuxt/image` — SSR Hydration Mismatch with `width`/`height` Props
|
||||
|
||||
**What goes wrong:** If `<NuxtImg>` or `<NuxtPicture>` is used without explicit `width` and `height` attributes, the server renders the image with dimensions derived from provider metadata (or skips them), while the client may recalculate. This produces a hydration mismatch warning and CLS (Cumulative Layout Shift) — a Core Web Vitals penalty.
|
||||
|
||||
**Why it happens:** `@nuxt/image` with the default `ipx` provider calculates dimensions lazily. Without `width`/`height`, the `<img>` tag emitted server-side has no size attributes, the browser does not reserve space, and content shifts when the image loads.
|
||||
|
||||
**Consequences:** CLS score above 0.1 → Google ranking penalty. Vue hydration mismatch console warnings in production.
|
||||
|
||||
**Prevention:**
|
||||
- Always provide `width` and `height` on `<NuxtImg>`. For unknown dimensions, use `aspect-ratio` CSS as fallback.
|
||||
- Set `placeholder` prop for low-quality placeholders while loading.
|
||||
- Use `sizes` prop for responsive images rather than relying on CSS alone.
|
||||
- Avoid using `@nuxt/image` for images where dimensions are genuinely unknown at render time (e.g. user-uploaded content) — use a standard `<img>` with explicit CSS aspect-ratio instead.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 7: Docker Dockerfile Uses `npm ci` After Migration to pnpm
|
||||
|
||||
**What goes wrong:** `Dockerfile` stage 1 runs `COPY package*.json ./` followed by `npm ci`. The project has migrated to pnpm (`pnpm-lock.yaml` exists). `npm ci` will install from `package-lock.json` (the old lockfile), which may diverge from the actual dependencies resolved by pnpm. If `package-lock.json` is stale or deleted, `npm ci` fails entirely.
|
||||
|
||||
**Why it happens:** The Dockerfile was not updated when the package manager was switched.
|
||||
|
||||
**Consequences:** Production Docker image may run different dependency versions than local dev. Build could fail silently with outdated transitive deps. Both lockfiles coexisting in the repo is a CI/CD footgun.
|
||||
|
||||
**Prevention:**
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
FROM node:22-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
```
|
||||
Delete `package-lock.json` from the repo to remove ambiguity. Add `.npmrc` with `engine-strict=true` if desired.
|
||||
|
||||
**Detection:** `docker build` succeeding but runtime behaviour differing from `pnpm dev` — check `node_modules` versions inside the container.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 8: `detectBrowserLanguage` with `redirectOn: 'root'` Causes Double Redirect for Crawlers
|
||||
|
||||
**What goes wrong:** The i18n config has:
|
||||
```ts
|
||||
detectBrowserLanguage: {
|
||||
useCookie: true,
|
||||
cookieKey: 'i18n_redirected',
|
||||
redirectOn: 'root',
|
||||
}
|
||||
```
|
||||
Googlebot has no cookies. On every crawl of `/`, Googlebot gets a 302 redirect to `/en/` (its apparent locale) then must re-crawl. This adds a redirect hop to every French-default page discovery and can cause Googlebot to incorrectly associate English content with the root URL.
|
||||
|
||||
**Why it happens:** `detectBrowserLanguage` is designed for user experience, not crawlers. Crawlers do not send `Accept-Language` reliably, and never persist the redirect cookie.
|
||||
|
||||
**Consequences:** Root URL (`/`) may be indexed in English by Googlebot depending on how the redirect resolves. Crawl budget wasted on redirects.
|
||||
|
||||
**Prevention:**
|
||||
- Set `redirectOn: 'no prefix'` instead of `'root'` — only redirect when the user hits a non-prefixed URL that is ambiguous.
|
||||
- Or disable `detectBrowserLanguage` entirely and let users switch language manually via the toggle. The cookie is already persisted via `cookieKey: 'i18n_redirected'` so repeat visits respect the choice.
|
||||
- Add `<link rel="alternate" hreflang="fr" href="https://killiandalcin.fr/">` and `<link rel="alternate" hreflang="en" href="https://killiandalcin.fr/en/">` in `<head>` (or rely on sitemap hreflang if Pitfall 1 is fixed).
|
||||
|
||||
---
|
||||
|
||||
## Minor Pitfalls
|
||||
|
||||
### Pitfall 9: `aggregateRating.reviewCount: '50'` vs `testimonials.totalReviews: 10`
|
||||
|
||||
**What goes wrong:** `app/data/site.ts` claims `reviewCount: '50'` in JSON-LD structured data; `app/data/testimonials.ts` has `totalReviews: 10`. Google's rich results validator and structured data guidelines penalise exaggerated or inconsistent `aggregateRating` claims.
|
||||
|
||||
**Prevention:** Align both values. If the portfolio has 10 real reviews, set `reviewCount: '10'`. Inflated counts can result in the rich result being demoted or flagged.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 10: `HeroSection` `.split(' ').slice(-2)` Gradient Logic Breaks with FR
|
||||
|
||||
**What goes wrong:** The gradient is applied to the last 2 words of the title, extracted by splitting on spaces. French translations may have different word counts (e.g. "Développeur Full Stack Freelance" = 4 words vs "Full Stack Developer" = 3 words). The last 2 words in French may not be the visually intended words.
|
||||
|
||||
**Prevention:** Use a translation key that wraps the emphasised portion in a span rather than computing it dynamically:
|
||||
```json
|
||||
{ "hero.title": "Développeur <em>Hytale & Full Stack</em>" }
|
||||
```
|
||||
Then render with `v-html` (sanitised) or split the key into `hero.title.prefix` / `hero.title.emphasis`.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 11: External CDN Avatars (`ui-avatars.com`) Break in Offline/Restricted Environments
|
||||
|
||||
**What goes wrong:** All testimonial avatars make external HTTP requests to `https://ui-avatars.com/api/...` on every SSR render. If the CDN is unavailable (rate-limited, down, or blocked in certain regions) the SSR render stalls waiting for a timeout.
|
||||
|
||||
**Prevention:** Generate the avatars once (or locally) and serve from `public/images/avatars/`. Alternatively generate SVG initials inline — zero external dependency.
|
||||
|
||||
---
|
||||
|
||||
## Phase-Specific Warnings
|
||||
|
||||
| Phase Topic | Likely Pitfall | Mitigation |
|
||||
|---|---|---|
|
||||
| Fixing sitemap | Static file silently wins | Delete `public/sitemap.xml` first; verify with curl |
|
||||
| Adding `/hytale` page | Sitemap won't update until static file removed | Same — Pitfall 1 |
|
||||
| Rate limiting contact API | In-memory map resets on container restart | Use file/Redis unstorage driver or Cloudflare rule |
|
||||
| Canonical links | i18n prefix_except_default creates `/` + `/en/` duplicates | Add canonical in layout composable |
|
||||
| Docker deploy | npm ci vs pnpm-lock.yaml mismatch | Switch Dockerfile to `pnpm install --frozen-lockfile` |
|
||||
| Dynamic OG images for projects | Generic fallback masks per-project imagery | Centralise OG URL logic in composable with dynamic fallback |
|
||||
| Browser language detection | Googlebot cookie-less redirect loop | Switch `redirectOn` to `'no prefix'` or disable |
|
||||
| Structured data for SEO | Mismatched `reviewCount` claim | Align JSON-LD to actual `totalReviews` value |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Direct codebase inspection: `server/api/contact.post.ts`, `nuxt.config.ts`, `Dockerfile`, `public/sitemap.xml`, `.planning/codebase/CONCERNS.md`
|
||||
- Nuxt 4 documentation (training knowledge, cut-off August 2025) — confidence MEDIUM
|
||||
- `@nuxtjs/i18n` v9 documentation on `strategy: 'prefix_except_default'` — MEDIUM
|
||||
- `@nuxtjs/sitemap` v6 behaviour with `public/` static files — MEDIUM (verified by Nitro static file resolution order)
|
||||
- Google Search Central structured data guidelines — MEDIUM
|
||||
- Core Web Vitals CLS guidelines for image sizing — HIGH (well-established, unchanged)
|
||||
@@ -0,0 +1,324 @@
|
||||
# Technology Stack Research
|
||||
|
||||
**Project:** Portfolio Killian' Dalcin — Nuxt 4 SSR
|
||||
**Researched:** 2026-04-10
|
||||
**Confidence note:** Web search and WebFetch tools were unavailable. All findings are based on codebase inspection + training knowledge (cutoff August 2025). Items marked LOW confidence require manual verification against current changelogs.
|
||||
|
||||
---
|
||||
|
||||
## Current Stack Assessment
|
||||
|
||||
### Dependency Version Audit
|
||||
|
||||
| Package | Current Spec | Assessment | Notes |
|
||||
|---------|-------------|------------|-------|
|
||||
| `nuxt` | `^4.0.0` | WATCH | `^4.0.0` resolves to whatever 4.x is latest — fine for dev, pin for prod Docker |
|
||||
| `@nuxt/ui` | `^3.0.0` | WATCH | v3 was released ~May 2025 with breaking changes from v2; still maturing |
|
||||
| `@nuxtjs/i18n` | `^10.2.4` | OK | v10 is the Nuxt 4 compatible branch; 10.x has known cookie-detection edge cases |
|
||||
| `@nuxtjs/sitemap` | `^8.0.12` | OK | v8 is actively maintained for Nuxt 4 |
|
||||
| `nuxt-gtag` | `^4.1.0` | OK | v4 targets Nuxt 4; works with SSR |
|
||||
| `@nuxt/image` | `^2.0.0` | OK | v2 is stable for Nuxt 4 |
|
||||
| `@nuxt/eslint` | `^1.15.2` | OK | Maintained by Nuxt team |
|
||||
| `zod` | `^4.3.6` | VERIFY | Zod v4 has a changed API vs v3 — confirm your server route imports are v4-compatible |
|
||||
| `nodemailer` | `^8.0.5` | OK | v8 is stable, ESM-compatible |
|
||||
| `tailwindcss` | `^4.2.2` | OK | v4 is required by Nuxt UI v3 |
|
||||
| `vue` | `latest` | RISK | Pinning to `latest` is dangerous — a Vue 3→4 jump (when it ships) would break everything. Pin to `^3.5.0` |
|
||||
| `vue-router` | `latest` | RISK | Same issue as `vue` — pin to `^4.5.0` |
|
||||
|
||||
**Confidence:** MEDIUM (based on release history known through Aug 2025; verify zod v4 API changes specifically)
|
||||
|
||||
### Critical Issue: Dockerfile Uses npm, Codebase Uses pnpm
|
||||
|
||||
The Dockerfile currently runs `npm ci` and `npm run build`, but the project uses pnpm (`pnpm-lock.yaml` is the canonical lockfile). This means:
|
||||
|
||||
- Docker builds ignore `pnpm-lock.yaml` and use `package-lock.json` instead
|
||||
- Dependency versions in production may differ from development
|
||||
- `npm ci` with a stale `package-lock.json` is a latent correctness bug
|
||||
|
||||
**Fix:** Migrate Dockerfile to pnpm (see pnpm + Docker section below).
|
||||
|
||||
---
|
||||
|
||||
## Nuxt 4 Breaking Changes and Migration Gotchas
|
||||
|
||||
**Confidence:** MEDIUM (verified against nuxt.com docs through Aug 2025)
|
||||
|
||||
### Directory Structure (`compatibilityVersion: 4`)
|
||||
|
||||
Nuxt 4 moves app code under `app/` by default. The codebase already reflects this (`app/pages/`, `app/components/`, etc.) — this is correct.
|
||||
|
||||
Key structural changes vs Nuxt 3:
|
||||
- `app/` directory is the application root (pages, components, layouts, composables, middleware)
|
||||
- `server/` stays at project root for API routes
|
||||
- `public/` stays at project root for static assets
|
||||
- `~/` alias now resolves to `app/` directory, not project root
|
||||
- `#imports` auto-imports still work as before
|
||||
|
||||
### Auto-imports Scope Change
|
||||
|
||||
In Nuxt 4 with `compatibilityVersion: 4`, `~/composables/` means `app/composables/`. Any composable or utility imported with `~/` is relative to `app/` — this is already how the codebase is structured.
|
||||
|
||||
### `useAsyncData` and `useFetch` Key Deduplication
|
||||
|
||||
Nuxt 4 changed how keys are generated for `useAsyncData`. If two calls share the same auto-generated key, only one runs. When using `useAsyncData` in loops or dynamic components, always pass an explicit unique key. This is especially relevant if you add project detail pages that fetch by ID.
|
||||
|
||||
### `useHead` and SSR Hydration
|
||||
|
||||
`useHead` in Nuxt 4 requires that reactive values be wrapped in functions (arrow functions returning computed values) to be reactive on the server. The current codebase already uses `() => t('seo.home.title')` pattern in `useSeoMeta` — this is correct.
|
||||
|
||||
Static strings (like `ogImage: 'https://killiandalcin.fr/og-image.png'`) are fine as-is for pages where the image doesn't change per-route.
|
||||
|
||||
### Server API Routes Location
|
||||
|
||||
In Nuxt 4, server routes live in `server/api/` at the project root (not `app/api/`). The codebase `STACK.md` references `app/api/contact.post.ts` — verify this file's actual location and confirm it resolves correctly. If it's under `app/`, it will not be treated as a server route.
|
||||
|
||||
**Action required:** Verify `contact.post.ts` is at `server/api/contact.post.ts`.
|
||||
|
||||
### `runtimeConfig` Key Naming
|
||||
|
||||
In `nuxt.config.ts`, `runtimeConfig` keys like `smtpHost` are accessed as `useRuntimeConfig().smtpHost` in server code, and are populated from environment variables named `NUXT_SMTP_HOST` (Nuxt auto-maps UPPER_SNAKE_CASE env vars to camelCase config keys). This is working correctly in the current config.
|
||||
|
||||
---
|
||||
|
||||
## Nuxt 4 SEO Best Practices
|
||||
|
||||
**Confidence:** HIGH (useSeoMeta is documented Nuxt API; canonical link patterns are well-established)
|
||||
|
||||
### Canonical Links (Currently Missing)
|
||||
|
||||
The current `index.vue` sets `ogImage`, `ogTitle`, `ogDescription`, `ogType` but does not set a canonical URL. For a bilingual site with `prefix_except_default` strategy, this creates duplicate content risk:
|
||||
|
||||
- `https://killiandalcin.fr/` (FR, no prefix)
|
||||
- `https://killiandalcin.fr/en/` (EN, prefixed)
|
||||
|
||||
Both URLs serve different content, so they are not true duplicates. However, canonical tags still prevent ambiguity for crawlers.
|
||||
|
||||
**Recommended pattern for every page:**
|
||||
|
||||
```typescript
|
||||
const { locale } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('seo.home.title'),
|
||||
description: () => t('seo.home.description'),
|
||||
ogTitle: () => t('seo.home.title'),
|
||||
ogDescription: () => t('seo.home.description'),
|
||||
ogUrl: () => `https://killiandalcin.fr${route.path}`,
|
||||
ogImage: '/og-image.png', // absolute URL resolved by Nuxt at runtime
|
||||
ogImageWidth: 1200,
|
||||
ogImageHeight: 630,
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
useHead({
|
||||
link: [
|
||||
{ rel: 'canonical', href: () => `https://killiandalcin.fr${route.path}` },
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
This sets the canonical to the current page's own URL (deduplicated per-language). If you want FR as the canonical for EN pages, that requires a different strategy — but same-URL canonical is simpler and correct for truly separate FR/EN content.
|
||||
|
||||
### hreflang (Currently via Sitemap)
|
||||
|
||||
The sitemap module (`@nuxtjs/sitemap` v8) generates `<loc>` and `<xhtml:link rel="alternate">` hreflang entries automatically when configured with i18n. This is the recommended approach — do not manually manage hreflang in `useHead`.
|
||||
|
||||
Verify the sitemap module config includes:
|
||||
```typescript
|
||||
// nuxt.config.ts
|
||||
sitemap: {
|
||||
// sitemap module v8 auto-detects i18n routes when @nuxtjs/i18n is present
|
||||
}
|
||||
```
|
||||
|
||||
If not configured, add explicit i18n awareness:
|
||||
```typescript
|
||||
sitemap: {
|
||||
i18n: true,
|
||||
}
|
||||
```
|
||||
|
||||
### og:image Per Page
|
||||
|
||||
The current implementation uses a single hardcoded `og-image.png` for all pages. For project detail pages (`/project/[id]`), a per-project og:image significantly improves social sharing CTR.
|
||||
|
||||
**Recommended approach (no external service needed):**
|
||||
|
||||
Option A — Static images per project (simplest):
|
||||
```typescript
|
||||
// app/pages/project/[id].vue
|
||||
const project = computed(() => getProjectById(id))
|
||||
useSeoMeta({
|
||||
ogImage: () => project.value?.image
|
||||
? `https://killiandalcin.fr${project.value.image}`
|
||||
: 'https://killiandalcin.fr/og-image.png',
|
||||
})
|
||||
```
|
||||
|
||||
Option B — `@nuxt/og-image` module (generates OG images via Satori/Canvas):
|
||||
- Generates server-side OG images from Vue templates
|
||||
- Zero cost, no external service
|
||||
- Adds ~30s to build time for static generation
|
||||
- Well-maintained Nuxt module
|
||||
- LOW confidence on current stability with Nuxt 4 — verify before adopting
|
||||
|
||||
For this portfolio, Option A is sufficient and zero-risk.
|
||||
|
||||
### Structured Data per Page
|
||||
|
||||
The current homepage has `Person` + `ProfessionalService` JSON-LD. For SEO targeting Hytale plugin searches, additional schema on inner pages adds signal:
|
||||
|
||||
| Page | Recommended Schema |
|
||||
|------|-------------------|
|
||||
| `/` (homepage) | `Person` + `ProfessionalService` (already present) |
|
||||
| `/projects` | `ItemList` of `SoftwareApplication` or `CreativeWork` |
|
||||
| `/project/[id]` | `SoftwareApplication` with `name`, `description`, `author` |
|
||||
| `/about` | `Person` with skills, `alumniOf`, `knowsAbout: ["Hytale", "Kotlin", ...]` |
|
||||
| `/contact` | `ContactPage` |
|
||||
| `/fiverr` | `Offer` or `Service` with `price`, `priceCurrency` |
|
||||
|
||||
The `jobTitle` on the Person schema should be updated to "Hytale Plugin Developer" or "Game Plugin Developer" to match target keyword positioning.
|
||||
|
||||
---
|
||||
|
||||
## pnpm + Docker Best Practices for Nuxt SSR
|
||||
|
||||
**Confidence:** HIGH (pnpm Docker documentation is stable)
|
||||
|
||||
### Current Problem
|
||||
|
||||
The Dockerfile uses `npm ci` while the project uses pnpm. This must be fixed. The two lockfiles coexisting (`pnpm-lock.yaml` + `package-lock.json`) will cause permanent drift between dev and prod.
|
||||
|
||||
**Recommendation:** Delete `package-lock.json` from the repo, use pnpm exclusively.
|
||||
|
||||
### Recommended Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy manifests first for layer caching
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install all dependencies (including devDeps needed for build)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
# Only copy built output — no node_modules needed at runtime
|
||||
# Nuxt SSR bundles all server deps into .output/server/
|
||||
COPY --from=builder /app/.output /app/.output
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "/app/.output/server/index.mjs"]
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `corepack enable` activates pnpm without a separate install step
|
||||
- `--frozen-lockfile` ensures exact reproducibility (fails if lockfile is stale)
|
||||
- The `.output/` directory is self-contained — all server-side node_modules are bundled by Nitro
|
||||
- No `node_modules` copy to runtime stage (keeps image ~50-100MB smaller)
|
||||
|
||||
### .dockerignore
|
||||
|
||||
Ensure `.dockerignore` excludes dev artifacts:
|
||||
```
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
*.log
|
||||
.env
|
||||
```
|
||||
|
||||
### Build-time vs Runtime Environment Variables
|
||||
|
||||
Nuxt `runtimeConfig` values with defaults in `nuxt.config.ts` are injected at **runtime** via environment variables — this is correct. However, `public.*` keys are embedded at **build time**.
|
||||
|
||||
Current config:
|
||||
```typescript
|
||||
runtimeConfig: {
|
||||
smtpHost: '', // runtime — correct
|
||||
public: {
|
||||
gtag: { id: '' }, // build time — value must be known at docker build
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If `NUXT_PUBLIC_GTAG_ID` needs to differ between environments without rebuilding, this is a limitation. For a single-deployment portfolio this is fine. Just pass `NUXT_PUBLIC_GTAG_ID` as a Docker build arg if you need it baked in, or accept the empty default and override in a startup script.
|
||||
|
||||
---
|
||||
|
||||
## Additional Concerns Found in Codebase
|
||||
|
||||
### `vue: "latest"` and `vue-router: "latest"` — High Risk
|
||||
|
||||
These should be pinned. `latest` resolves at install time and will break on a major Vue version bump. Vue 4 (when it ships) will be a breaking change.
|
||||
|
||||
**Fix in `package.json`:**
|
||||
```json
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
```
|
||||
|
||||
### `site.name` is Generic
|
||||
|
||||
```typescript
|
||||
site: {
|
||||
url: 'https://killiandalcin.fr',
|
||||
name: "Killian' DAL-CIN - Developpeur Full Stack"
|
||||
}
|
||||
```
|
||||
|
||||
This drives the sitemap module's `<name>` field and potentially OG titles. Update to reflect Hytale positioning when the Hero rewrite ships.
|
||||
|
||||
### `jobTitle` in JSON-LD
|
||||
|
||||
Currently `"jobTitle": "Developpeur Full Stack Freelance"` — should become `"Hytale Plugin Developer & Full Stack Freelance"` or similar to match target keyword.
|
||||
|
||||
### `colorMode` Module Sourcing
|
||||
|
||||
`colorMode` is provided by `@nuxtjs/color-mode`, which is bundled within `@nuxt/ui` v3. No separate install needed. The `nuxt.config.ts` configuration is correct. However, `classSuffix: ''` means the class applied to `<html>` is `dark`/`light` (no suffix) — confirm your Tailwind v4 config uses `darkMode: 'class'` (it should be automatic via Nuxt UI).
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Category | Recommended | Alternative | Why Not |
|
||||
|----------|-------------|-------------|---------|
|
||||
| SEO meta | `useSeoMeta()` (built-in) | `vue-meta`, `@vueuse/head` | Built-in is SSR-correct, zero config |
|
||||
| OG image | Static files per page | `@nuxt/og-image` | Static is simpler for a portfolio |
|
||||
| Email | `nodemailer` | Resend API, SendGrid | Zero cost, self-hosted SMTP sufficient |
|
||||
| Analytics | `nuxt-gtag` | Manual `useHead` script | Module handles SSR-safe loading |
|
||||
| Package manager | pnpm | npm | Faster, better monorepo support, already adopted |
|
||||
|
||||
---
|
||||
|
||||
## Summary Recommendations
|
||||
|
||||
1. **Fix Dockerfile** — Switch from `npm ci` to `pnpm install --frozen-lockfile` (critical)
|
||||
2. **Pin `vue` and `vue-router`** — Replace `"latest"` with `"^3.5.0"` and `"^4.5.0"` (high priority)
|
||||
3. **Add canonical link** — `useHead({ link: [{ rel: 'canonical', href: () => ... }] })` on every page
|
||||
4. **Set `ogUrl` per page** — Add `ogUrl: () => \`https://killiandalcin.fr${route.path}\`` to all `useSeoMeta()` calls
|
||||
5. **Verify server API location** — Confirm `contact.post.ts` is at `server/api/`, not `app/api/`
|
||||
6. **Update JSON-LD jobTitle** — Reflect Hytale positioning
|
||||
7. **Update `site.name`** — Align with Hytale-first branding when Hero ships
|
||||
8. **Remove `package-lock.json`** — One lockfile, one package manager
|
||||
9. **Verify zod v4 API** — The `zod@^4.3.6` spec means v4 is required; confirm server route uses v4 schema API (not v3 `.parse()` patterns that changed)
|
||||
|
||||
---
|
||||
|
||||
*Confidence levels: HIGH = codebase-verified or stable Nuxt docs. MEDIUM = training knowledge, verify against current changelog. LOW = flagged as needing manual verification.*
|
||||
Reference in New Issue
Block a user