refactor(config): update nuxt.config.ts to enhance module configuration, remove deprecated files, and improve contact form validation with zod schema

This commit is contained in:
2026-04-21 23:15:04 +02:00
parent 20a5b5d85f
commit 839c584b0a
19 changed files with 340 additions and 19750 deletions
+1 -1
View File
@@ -46,8 +46,8 @@ defineExpose({ openGallery })
icon="i-lucide-x" icon="i-lucide-x"
variant="ghost" variant="ghost"
size="lg" size="lg"
@click="isOpen = false"
:aria-label="'Close gallery'" :aria-label="'Close gallery'"
@click="isOpen = false"
/> />
</div> </div>
+3 -2
View File
@@ -35,8 +35,9 @@ const techData = computed((): Technology => {
let found = allTechs.find((t) => t.name.toLowerCase() === techName.toLowerCase()) let found = allTechs.find((t) => t.name.toLowerCase() === techName.toLowerCase())
if (!found && techMapping[techName]) { const mapped = techMapping[techName]
found = allTechs.find((t) => t.name.toLowerCase() === techMapping[techName].toLowerCase()) if (!found && mapped) {
found = allTechs.find((t) => t.name.toLowerCase() === mapped.toLowerCase())
} }
return found ?? { name: techName, image: '', level: 'Intermediate' as const } return found ?? { name: techName, image: '', level: 'Intermediate' as const }
+2 -1
View File
@@ -33,7 +33,8 @@ const props = withDefaults(defineProps<Props>(), {
/> />
</svg> </svg>
</summary> </summary>
<div class="px-4 py-3 prose prose-neutral dark:prose-invert max-w-none <div
class="px-4 py-3 prose prose-neutral dark:prose-invert max-w-none
prose-code:before:content-none prose-code:after:content-none prose-code:before:content-none prose-code:after:content-none
prose-pre:p-0 prose-pre:bg-transparent text-sm"> prose-pre:p-0 prose-pre:bg-transparent text-sm">
<slot /> <slot />
+1 -1
View File
@@ -50,7 +50,7 @@ const wrapperStyle = computed(() => {
:title="props.title || props.caption" :title="props.title || props.caption"
loading="lazy" loading="lazy"
:class="props.align === 'full' && !attrs.class ? 'w-full rounded-lg' : 'rounded-lg'" :class="props.align === 'full' && !attrs.class ? 'w-full rounded-lg' : 'rounded-lg'"
/> >
<span <span
v-if="props.caption" v-if="props.caption"
class="mt-2 block text-center text-xs text-neutral-500 dark:text-neutral-400 italic" class="mt-2 block text-center text-xs text-neutral-500 dark:text-neutral-400 italic"
+8 -4
View File
@@ -24,7 +24,8 @@ const quickLinks = computed(() => [
<!-- Brand column --> <!-- Brand column -->
<div class="sm:col-span-2 lg:col-span-1 space-y-5"> <div class="sm:col-span-2 lg:col-span-1 space-y-5">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2.5 group"> <NuxtLink :to="localePath('/')" class="flex items-center gap-2.5 group">
<NuxtImg src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="lazy" <NuxtImg
src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="lazy"
class="rounded-lg transition-transform duration-300 group-hover:scale-110" /> class="rounded-lg transition-transform duration-300 group-hover:scale-110" />
<span class="text-lg font-bold text-gray-900 dark:text-white">Killian' DAL-CIN</span> <span class="text-lg font-bold text-gray-900 dark:text-white">Killian' DAL-CIN</span>
</NuxtLink> </NuxtLink>
@@ -39,7 +40,8 @@ const quickLinks = computed(() => [
Navigation Navigation
</h3> </h3>
<nav class="flex flex-col gap-3"> <nav class="flex flex-col gap-3">
<NuxtLink v-for="link in quickLinks" :key="link.key" :to="localePath(link.path)" <NuxtLink
v-for="link in quickLinks" :key="link.key" :to="localePath(link.path)"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors duration-200"> class="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors duration-200">
{{ t(`nav.${link.key}`) }} {{ t(`nav.${link.key}`) }}
</NuxtLink> </NuxtLink>
@@ -65,10 +67,12 @@ const quickLinks = computed(() => [
Connect Connect
</h3> </h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a v-for="link in socialLinks" :key="link.name" :href="link.url" target="_blank" rel="noopener noreferrer" <a
v-for="link in socialLinks" :key="link.name" :href="link.url" target="_blank" rel="noopener noreferrer"
:aria-label="t(link.ariaKey)" :aria-label="t(link.ariaKey)"
class="w-10 h-10 inline-flex items-center justify-center rounded-xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 hover:border-brand-500/40 hover:bg-brand-500/10 dark:hover:bg-brand-500/10 transition-all duration-300 group"> class="w-10 h-10 inline-flex items-center justify-center rounded-xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 hover:border-brand-500/40 hover:bg-brand-500/10 dark:hover:bg-brand-500/10 transition-all duration-300 group">
<UIcon :name="link.icon" <UIcon
:name="link.icon"
class="w-4.5 h-4.5 text-gray-500 dark:text-gray-400 group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors" /> class="w-4.5 h-4.5 text-gray-500 dark:text-gray-400 group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors" />
</a> </a>
</div> </div>
+12 -6
View File
@@ -34,14 +34,16 @@ function isActive(path: string): boolean {
<div class="flex items-center justify-between h-16"> <div class="flex items-center justify-between h-16">
<!-- Logo --> <!-- Logo -->
<NuxtLink :to="localePath('/')" :aria-label="t('a11y.logoLabel')" class="flex items-center gap-2.5 shrink-0"> <NuxtLink :to="localePath('/')" :aria-label="t('a11y.logoLabel')" class="flex items-center gap-2.5 shrink-0">
<NuxtImg src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="eager" <NuxtImg
src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="eager"
class="rounded-lg" /> class="rounded-lg" />
<span class="text-base font-semibold tracking-tight text-gray-900 dark:text-white">Killian'</span> <span class="text-base font-semibold tracking-tight text-gray-900 dark:text-white">Killian'</span>
</NuxtLink> </NuxtLink>
<!-- Desktop nav --> <!-- Desktop nav -->
<nav class="hidden md:flex items-center gap-1" aria-label="Main navigation"> <nav class="hidden md:flex items-center gap-1" aria-label="Main navigation">
<NuxtLink v-for="link in navLinks" :key="link.key" :to="localePath(link.path)" <NuxtLink
v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
:aria-current="isActive(link.path) ? 'page' : undefined" :aria-current="isActive(link.path) ? 'page' : undefined"
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors" :class="[ class="px-3 py-2 text-sm font-medium rounded-lg transition-colors" :class="[
isActive(link.path) isActive(link.path)
@@ -60,13 +62,15 @@ function isActive(path: string): boolean {
</UButton> </UButton>
<!-- Theme toggle --> <!-- Theme toggle -->
<UButton variant="ghost" color="neutral" size="sm" <UButton
variant="ghost" color="neutral" size="sm"
:icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'" :icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')" :aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
@click="toggleTheme" /> @click="toggleTheme" />
<!-- Mobile hamburger --> <!-- Mobile hamburger -->
<UButton variant="ghost" color="neutral" size="sm" icon="i-lucide-menu" class="md:hidden" <UButton
variant="ghost" color="neutral" size="sm" icon="i-lucide-menu" class="md:hidden"
:aria-label="t('a11y.openMenu')" @click="mobileOpen = true" /> :aria-label="t('a11y.openMenu')" @click="mobileOpen = true" />
</div> </div>
</div> </div>
@@ -83,7 +87,8 @@ function isActive(path: string): boolean {
<template #body> <template #body>
<nav class="flex flex-col gap-1" aria-label="Mobile navigation"> <nav class="flex flex-col gap-1" aria-label="Mobile navigation">
<NuxtLink v-for="link in navLinks" :key="link.key" :to="localePath(link.path)" <NuxtLink
v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
:aria-current="isActive(link.path) ? 'page' : undefined" :aria-current="isActive(link.path) ? 'page' : undefined"
class="px-4 py-3 text-base font-medium rounded-lg transition-colors" :class="[ class="px-4 py-3 text-base font-medium rounded-lg transition-colors" :class="[
isActive(link.path) isActive(link.path)
@@ -100,7 +105,8 @@ function isActive(path: string): boolean {
<UButton variant="ghost" color="neutral" :aria-label="t('a11y.langToggle')" @click="toggleLocale"> <UButton variant="ghost" color="neutral" :aria-label="t('a11y.langToggle')" @click="toggleLocale">
{{ locale === 'fr' ? 'EN' : 'FR' }} {{ locale === 'fr' ? 'EN' : 'FR' }}
</UButton> </UButton>
<UButton variant="ghost" color="neutral" :icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'" <UButton
variant="ghost" color="neutral" :icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')" :aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
@click="toggleTheme" /> @click="toggleTheme" />
</div> </div>
+2 -1
View File
@@ -33,7 +33,8 @@ const resolvedSecondaryText = computed(() => props.secondaryText || t('home.cta2
<div class="max-w-5xl mx-auto"> <div class="max-w-5xl mx-auto">
<div class="relative overflow-hidden rounded-3xl px-8 py-20 sm:px-16 sm:py-24 text-center border border-gray-200/60 dark:border-gray-800/40 bg-gray-50 dark:bg-gray-900"> <div class="relative overflow-hidden rounded-3xl px-8 py-20 sm:px-16 sm:py-24 text-center border border-gray-200/60 dark:border-gray-800/40 bg-gray-50 dark:bg-gray-900">
<!-- Subtle dot pattern --> <!-- Subtle dot pattern -->
<div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true" <div
class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true"
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 24px 24px;" /> style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 24px 24px;" />
<!-- Brand glow --> <!-- Brand glow -->
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-brand-500/8 dark:bg-brand-500/15 rounded-full blur-3xl pointer-events-none" aria-hidden="true" /> <div class="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-brand-500/8 dark:bg-brand-500/15 rounded-full blur-3xl pointer-events-none" aria-hidden="true" />
+2 -1
View File
@@ -11,7 +11,8 @@ const discordUrl = siteConfig.social.find(s => s.name === 'Discord')?.url ?? '#'
<section class="relative min-h-[80vh] flex items-center overflow-hidden bg-white dark:bg-gray-950"> <section class="relative min-h-[80vh] flex items-center overflow-hidden bg-white dark:bg-gray-950">
<!-- Dot grid background pattern --> <!-- Dot grid background pattern -->
<div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true"> <div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true">
<div class="absolute inset-0" <div
class="absolute inset-0"
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 32px 32px;" /> style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 32px 32px;" />
</div> </div>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NuxtError } from '#app' import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>() defineProps<{ error: NuxtError }>()
const { t } = useI18n() const { t } = useI18n()
function handleError() { function handleError() {
-1
View File
@@ -2,7 +2,6 @@
import { techStack } from '~/data/techstack' import { techStack } from '~/data/techstack'
const { t } = useI18n() const { t } = useI18n()
const localePath = useLocalePath()
useSeoMeta({ useSeoMeta({
title: () => t('seo.about.title'), title: () => t('seo.about.title'),
+1 -1
View File
@@ -122,7 +122,7 @@ function resetFilters() {
</div> </div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{{ t('projects.noResults.title') }}</h3> <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{{ t('projects.noResults.title') }}</h3>
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">{{ t('projects.noResults.description') }}</p> <p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">{{ t('projects.noResults.description') }}</p>
<UButton @click="resetFilters" variant="soft" size="md" icon="i-lucide-rotate-ccw"> <UButton variant="soft" size="md" icon="i-lucide-rotate-ccw" @click="resetFilters">
{{ t('common.reset') }} {{ t('common.reset') }}
</UButton> </UButton>
</div> </div>
+2 -1
View File
@@ -19,7 +19,8 @@ const { data: page } = await useAsyncData('test', () =>
</p> </p>
</header> </header>
<article class="prose prose-neutral dark:prose-invert max-w-none <article
class="prose prose-neutral dark:prose-invert max-w-none
prose-headings:font-semibold prose-headings:font-semibold
prose-code:before:content-none prose-code:after:content-none prose-code:before:content-none prose-code:after:content-none
prose-pre:p-0 prose-pre:bg-transparent"> prose-pre:p-0 prose-pre:bg-transparent">
+23 -27
View File
@@ -1,18 +1,15 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2026-04-21', compatibilityDate: '2026-04-21',
future: {
compatibilityVersion: 4
},
ssr: true, ssr: true,
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
modules: [ modules: [
'@nuxt/ui', '@nuxt/ui',
'@nuxtjs/i18n', '@nuxt/image',
'@nuxt/content',
'@nuxt/eslint', '@nuxt/eslint',
'@nuxtjs/i18n',
'@nuxtjs/sitemap', '@nuxtjs/sitemap',
'nuxt-gtag', 'nuxt-gtag',
'@nuxt/image',
'@nuxt/content'
], ],
components: [ components: [
{ {
@@ -21,23 +18,22 @@ export default defineNuxtConfig({
}, },
], ],
typescript: { typescript: {
strict: true strict: true,
}, },
colorMode: { colorMode: {
preference: 'dark', preference: 'dark',
fallback: 'dark', fallback: 'dark',
storage: 'cookie', storage: 'cookie',
storageKey: 'nuxt-color-mode', storageKey: 'nuxt-color-mode',
classSuffix: '' classSuffix: '',
}, },
site: { site: {
url: 'https://killiandalcin.fr', url: 'https://killiandalcin.fr',
name: "Killian' DAL-CIN - Developpeur Full Stack" name: "Killian' DAL-CIN - Developpeur Full Stack",
}, },
i18n: { i18n: {
strategy: 'prefix', strategy: 'prefix',
defaultLocale: 'fr', defaultLocale: 'fr',
baseUrl: 'https://killiandalcin.fr',
locales: [ locales: [
{ code: 'fr', language: 'fr-FR', file: 'fr.json' }, { code: 'fr', language: 'fr-FR', file: 'fr.json' },
{ code: 'en', language: 'en-US', file: 'en.json' }, { code: 'en', language: 'en-US', file: 'en.json' },
@@ -48,7 +44,7 @@ export default defineNuxtConfig({
cookieKey: 'i18n_redirected', cookieKey: 'i18n_redirected',
redirectOn: 'root', redirectOn: 'root',
}, },
}, },
runtimeConfig: { runtimeConfig: {
smtpHost: '', smtpHost: '',
smtpUser: '', smtpUser: '',
@@ -59,30 +55,30 @@ export default defineNuxtConfig({
id: '', id: '',
}, },
}, },
}, },
gtag: { gtag: {
id: '', enabled: !import.meta.dev,
enabled: import.meta.env.NODE_ENV === 'production', },
},
routeRules: { routeRules: {
'/blog/**': { redirect: { to: '/fr/blog/**', statusCode: 301 } }, '/blog': { redirect: { to: '/fr/blog', statusCode: 301 } },
}, '/blog/**': { redirect: { to: '/fr/blog', statusCode: 301 } },
vite: {
optimizeDeps: {
include: ['zod'],
},
}, },
content: { content: {
build: { build: {
markdown: { markdown: {
highlight: { highlight: {
theme: 'github-dark', theme: 'github-dark',
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css'] langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css'],
} },
} },
}, },
experimental: { experimental: {
sqliteConnector: 'native' sqliteConnector: 'native',
} },
} },
vite: {
optimizeDeps: {
include: ['zod'],
},
},
}) })
-19402
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -14,24 +14,24 @@
}, },
"dependencies": { "dependencies": {
"@nuxt/content": "^3.13.0", "@nuxt/content": "^3.13.0",
"@nuxt/eslint": "^1.15.2",
"@nuxt/image": "^2.0.0", "@nuxt/image": "^2.0.0",
"@nuxt/ui": "^3.0.0", "@nuxt/ui": "^3.3.2",
"@nuxtjs/i18n": "^10.2.4", "@nuxtjs/i18n": "^10.2.4",
"@nuxtjs/sitemap": "^8.0.12", "@nuxtjs/sitemap": "^8.0.12",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
"nuxt": "^4.0.0", "nuxt": "^4.4.2",
"nuxt-gtag": "^4.1.0", "nuxt-gtag": "^4.1.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.5.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.102", "@iconify-json/lucide": "^1.2.102",
"@nuxt/eslint": "^1.15.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.3",
"typescript": "~5.8.0" "typescript": "~5.9.0",
"vue-tsc": "^3.2.6"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
+231 -219
View File
File diff suppressed because it is too large Load Diff
+27 -26
View File
@@ -1,21 +1,33 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email().max(200),
message: z.string().min(10).max(5000),
})
const htmlEscapes: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (c) => htmlEscapes[c] ?? c)
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody(event) const body = await readBody(event)
const parsed = contactSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 400, message: 'Invalid payload' })
}
const { name, email, message } = parsed.data
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
// Server-side validation (T-03-01)
const { name, email, message } = body
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 100) {
throw createError({ statusCode: 400, message: 'Invalid name' })
}
if (!email || typeof email !== 'string' || !email.includes('@') || email.length > 200) {
throw createError({ statusCode: 400, message: 'Invalid email' })
}
if (!message || typeof message !== 'string' || message.length < 10 || message.length > 5000) {
throw createError({ statusCode: 400, message: 'Invalid message' })
}
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: config.smtpHost, host: config.smtpHost,
port: 465, port: 465,
@@ -26,20 +38,9 @@ export default defineEventHandler(async (event) => {
}, },
}) })
// Escape HTML to prevent XSS in email body (T-03-02) const escapedName = escapeHtml(name)
const escapedName = name.replace(/[&<>"']/g, (c: string) => { const escapedEmail = escapeHtml(email)
const map: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' } const escapedMessage = escapeHtml(message)
return map[c] ?? c
})
const escapedMessage = message.replace(/[&<>"']/g, (c: string) => {
const map: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
return map[c] ?? c
})
const escapedEmail = email.replace(/[&<>"']/g, (c: string) => {
const map: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
return map[c] ?? c
})
const dateStr = new Date().toLocaleString('fr-FR', { const dateStr = new Date().toLocaleString('fr-FR', {
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}