chore: implement SEO composables, structured data, and sitemap with robots config
- Added `usePageSeo` composable for dynamic page-level SEO. - Configured default head manager using `@vueuse/head`. - Introduced sitemap.xml and robots.txt for search engine indexing. - Updated `head` plugin and integrated SEO composable into pages. - Added localized SEO data for English and German translations. - Updated `yarn.lock` and dependencies to include `@vueuse/head`.
This commit is contained in:
30
index.html
30
index.html
@@ -12,37 +12,9 @@
|
||||
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<meta
|
||||
content="Portfolio of Christian Nachtigall, a front-end engineer crafting accessible Vue & Vuetify experiences with performance-first thinking."
|
||||
content="Christian Nachtigall is a front-end focused full-stack engineer building accessible Vue and Vuetify interfaces, Rust-backed APIs, and automation that keeps teams shipping fast."
|
||||
name="description"
|
||||
/>
|
||||
<meta content="#7c5cff" name="theme-color" />
|
||||
<meta
|
||||
content="Christian Nachtigall · Front-end Engineer"
|
||||
property="og:title"
|
||||
/>
|
||||
<meta
|
||||
content="Explore projects, experience, and accessibility-driven front-end work by Christian Nachtigall."
|
||||
property="og:description"
|
||||
/>
|
||||
<meta content="website" property="og:type" />
|
||||
<meta content="https://nachtigall.dev" property="og:url" />
|
||||
<meta
|
||||
content="https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1200&q=80"
|
||||
property="og:image"
|
||||
/>
|
||||
<meta content="summary_large_image" name="twitter:card" />
|
||||
<meta
|
||||
content="Christian Nachtigall · Front-end Engineer"
|
||||
name="twitter:title"
|
||||
/>
|
||||
<meta
|
||||
content="Portfolio for accessibility-focused Vue developer Christian Nachtigall."
|
||||
name="twitter:description"
|
||||
/>
|
||||
<meta
|
||||
content="https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1200&q=80"
|
||||
name="twitter:image"
|
||||
/>
|
||||
<title>Christian Nachtigall · Front-end Engineer</title>
|
||||
|
||||
<link
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@vueuse/head": "^2.0.0",
|
||||
"core-js": "^3.45.1",
|
||||
"prettier": "^3.6.2",
|
||||
"roboto-fontface": "*",
|
||||
|
||||
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://nachtigall.dev/sitemap.xml
|
||||
53
public/sitemap.xml
Normal file
53
public/sitemap.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/projects</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/experience</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/contact</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/projects/owlen</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/projects/itsh</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/projects/owly-news</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/projects/polyscribe</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/projects/abtruennige</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://nachtigall.dev/projects/kiropraktikk</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
113
src/composables/usePageSeo.ts
Normal file
113
src/composables/usePageSeo.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useRoute } from 'vue-router/auto'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useHead, useSeoMeta } from '@vueuse/head'
|
||||
|
||||
import {
|
||||
DEFAULT_DESCRIPTION,
|
||||
DEFAULT_SOCIAL_IMAGE,
|
||||
DEFAULT_SOCIAL_IMAGE_ALT,
|
||||
DEFAULT_TITLE,
|
||||
OG_LOCALES,
|
||||
SITE_TITLE_SUFFIX,
|
||||
SITE_URL,
|
||||
} from '@/config/site'
|
||||
|
||||
interface UsePageSeoOptions {
|
||||
readonly title?: MaybeRefOrGetter<string | null | undefined>
|
||||
readonly description?: MaybeRefOrGetter<string | null | undefined>
|
||||
readonly image?: MaybeRefOrGetter<string | null | undefined>
|
||||
readonly imageAlt?: MaybeRefOrGetter<string | null | undefined>
|
||||
readonly type?: MaybeRefOrGetter<string | null | undefined>
|
||||
readonly canonical?: MaybeRefOrGetter<string | null | undefined>
|
||||
readonly structuredData?: MaybeRefOrGetter<
|
||||
| Record<string, unknown>
|
||||
| readonly Record<string, unknown>[]
|
||||
| null
|
||||
| undefined
|
||||
>
|
||||
readonly noindex?: MaybeRefOrGetter<boolean | undefined>
|
||||
}
|
||||
|
||||
export function usePageSeo(options: UsePageSeoOptions) {
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const canonical = computed(() => {
|
||||
const explicitCanonical = options?.canonical
|
||||
? toValue(options.canonical)
|
||||
: null
|
||||
if (explicitCanonical) {
|
||||
return explicitCanonical
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(route.fullPath ?? '/', SITE_URL).toString()
|
||||
} catch {
|
||||
return SITE_URL
|
||||
}
|
||||
})
|
||||
|
||||
const title = computed(() => toValue(options?.title)?.trim() || DEFAULT_TITLE)
|
||||
const description = computed(
|
||||
() => toValue(options?.description)?.trim() || DEFAULT_DESCRIPTION
|
||||
)
|
||||
const image = computed(() => toValue(options?.image) || DEFAULT_SOCIAL_IMAGE)
|
||||
const imageAlt = computed(
|
||||
() => toValue(options?.imageAlt) || DEFAULT_SOCIAL_IMAGE_ALT
|
||||
)
|
||||
|
||||
const ogLocale = computed(() => OG_LOCALES[locale.value] ?? OG_LOCALES.en)
|
||||
const robots = computed(() =>
|
||||
toValue(options?.noindex) ? 'noindex, nofollow' : 'index, follow'
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => title.value,
|
||||
description: () => description.value,
|
||||
robots: () => robots.value,
|
||||
ogTitle: () => title.value,
|
||||
ogDescription: () => description.value,
|
||||
ogType: () => toValue(options?.type) || 'website',
|
||||
ogUrl: () => canonical.value,
|
||||
ogImage: () => image.value,
|
||||
ogImageAlt: () => imageAlt.value,
|
||||
ogLocale: () => ogLocale.value,
|
||||
ogSiteName: () => SITE_TITLE_SUFFIX,
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: () => title.value,
|
||||
twitterDescription: () => description.value,
|
||||
twitterImage: () => image.value,
|
||||
twitterImageAlt: () => imageAlt.value,
|
||||
})
|
||||
|
||||
const structured = computed(() => toValue(options?.structuredData) ?? null)
|
||||
|
||||
useHead(() => {
|
||||
const scripts = []
|
||||
const value = structured.value
|
||||
if (value) {
|
||||
const dataArray = Array.isArray(value) ? value : [value]
|
||||
|
||||
scripts.push(
|
||||
...dataArray.map((item, index) => ({
|
||||
key: `ld-json-${index}`,
|
||||
type: 'application/ld+json',
|
||||
children: JSON.stringify(item),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
link: [
|
||||
{
|
||||
key: 'canonical',
|
||||
rel: 'canonical',
|
||||
href: canonical.value,
|
||||
},
|
||||
],
|
||||
script: scripts,
|
||||
}
|
||||
})
|
||||
}
|
||||
16
src/config/site.ts
Normal file
16
src/config/site.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const SITE_URL = 'https://nachtigall.dev'
|
||||
export const SITE_AUTHOR = 'Christian Nachtigall'
|
||||
export const SITE_TITLE_SUFFIX = 'Christian Nachtigall'
|
||||
export const DEFAULT_TITLE = 'Christian Nachtigall · Front-end Engineer'
|
||||
export const DEFAULT_DESCRIPTION =
|
||||
'Christian Nachtigall is a front-end focused full-stack engineer building accessible Vue and Vuetify interfaces, Rust-backed APIs, and automation that keeps teams shipping fast.'
|
||||
export const DEFAULT_SOCIAL_IMAGE =
|
||||
'https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1200&q=80'
|
||||
export const DEFAULT_SOCIAL_IMAGE_ALT =
|
||||
'Neon-lit workspace representing Christian Nachtigall’s interface projects.'
|
||||
export const CONTACT_EMAIL = 'contact@nachtigall.dev'
|
||||
|
||||
export const OG_LOCALES: Record<string, string> = {
|
||||
en: 'en_US',
|
||||
de: 'de_DE',
|
||||
}
|
||||
@@ -10,6 +10,48 @@ const de = {
|
||||
viewTimeline: 'Zeitleiste ansehen',
|
||||
backToProjects: 'Zurück zu den Projekten',
|
||||
},
|
||||
seo: {
|
||||
defaultTitle: 'Christian Nachtigall · Frontend-Engineer',
|
||||
defaultDescription:
|
||||
'Christian Nachtigall ist ein frontend-orientierter Full-Stack-Engineer, der zugängliche Vue- und Vuetify-Oberflächen, Rust-gestützte Services und GitOps-Automatisierungen entwickelt.',
|
||||
imageAlt:
|
||||
'Arbeitsplatz mit Code-Editor, der die Frontend-Projekte von Christian Nachtigall zeigt.',
|
||||
home: {
|
||||
title: 'Portfolio eines Frontend Engineers',
|
||||
description:
|
||||
'Entdecke Christian Nachtigalls ausgewählte Projekte, den Fokus auf Barrierefreiheit sowie die Zusammenarbeit rund um Vue, Vuetify und Rust.',
|
||||
},
|
||||
projects: {
|
||||
title: 'Projekte & Fallstudien',
|
||||
description:
|
||||
'Detaillierte Einblicke in Christian Nachtigalls Produktarbeit mit Kennzahlen, Tech-Stacks und Wirkung in verschiedenen Projekten.',
|
||||
},
|
||||
experience: {
|
||||
title: 'Erfahrung & Erfolge',
|
||||
description:
|
||||
'Karriereverlauf mit Engineering-Rollen, Erfolgen und Tooling-Leadership in designorientierten Teams.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Kontakt zu Christian Nachtigall',
|
||||
description:
|
||||
'Starte ein Gespräch über barrierefreie, performante Vue-Oberflächen, Designsysteme und Produkt-Tooling.',
|
||||
},
|
||||
project: {
|
||||
title: '{project} · Projekt von Christian Nachtigall',
|
||||
description: '{summary}',
|
||||
fallbackTitle: 'Projektüberblick',
|
||||
fallbackDescription:
|
||||
'Erkunde Christian Nachtigalls aktuelle Projektarbeiten, Kennzahlen und Highlights der Zusammenarbeit.',
|
||||
imageAlt: 'Hero-Bild des Projekts {project}',
|
||||
fallbackImageAlt:
|
||||
'Hero-Bild, das ein Projekt von Christian Nachtigall darstellt.',
|
||||
},
|
||||
person: {
|
||||
jobTitle: 'Frontend-orientierter Full-Stack-Engineer',
|
||||
description:
|
||||
'Christian Nachtigall verbindet Vue/Vuetify-Expertise mit Rust-Automatisierung, um inklusive, leistungsstarke digitale Produkte zu liefern.',
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
ariaMain: 'Hauptnavigation',
|
||||
ariaMobile: 'Mobile Navigation',
|
||||
|
||||
@@ -10,6 +10,48 @@ const en = {
|
||||
viewTimeline: 'View timeline',
|
||||
backToProjects: 'Back to projects',
|
||||
},
|
||||
seo: {
|
||||
defaultTitle: 'Christian Nachtigall · Front-end Engineer',
|
||||
defaultDescription:
|
||||
'Christian Nachtigall is a front-end focused full-stack engineer crafting accessible Vue and Vuetify interfaces, Rust-backed services, and GitOps automation.',
|
||||
imageAlt:
|
||||
'Workspace with code editor representing Christian Nachtigall’s front-end projects.',
|
||||
home: {
|
||||
title: 'Front-end Engineer Portfolio',
|
||||
description:
|
||||
'Explore Christian Nachtigall’s featured projects, accessibility-focused process, and collaboration style across Vue, Vuetify, and Rust.',
|
||||
},
|
||||
projects: {
|
||||
title: 'Projects & Case Studies',
|
||||
description:
|
||||
'Detailed breakdowns of Christian Nachtigall’s product work, highlighting metrics, tech stacks, and impact across client engagements.',
|
||||
},
|
||||
experience: {
|
||||
title: 'Experience & Achievements',
|
||||
description:
|
||||
'Career timeline showcasing engineering roles, achievements, and tooling leadership across design-forward teams.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Christian Nachtigall',
|
||||
description:
|
||||
'Start a conversation about building accessible, high-performing Vue interfaces, design systems, and product tooling.',
|
||||
},
|
||||
project: {
|
||||
title: '{project} · Project by Christian Nachtigall',
|
||||
description: '{summary}',
|
||||
fallbackTitle: 'Project Overview',
|
||||
fallbackDescription:
|
||||
'Explore Christian Nachtigall’s recent project work, metrics, and collaboration highlights.',
|
||||
imageAlt: '{project} case study hero image',
|
||||
fallbackImageAlt:
|
||||
'Hero image representing a Christian Nachtigall project.',
|
||||
},
|
||||
person: {
|
||||
jobTitle: 'Frontend-first Full-Stack Engineer',
|
||||
description:
|
||||
'Christian Nachtigall blends Vue/Vuetify craftsmanship with Rust automation to ship inclusive, high-performing digital products.',
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
ariaMain: 'Main navigation',
|
||||
ariaMobile: 'Mobile navigation',
|
||||
|
||||
@@ -134,12 +134,26 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import ContactGrid from '@/components/portfolio/ContactGrid.vue'
|
||||
import { usePortfolio } from '@/composables/usePortfolio'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
import { DEFAULT_SOCIAL_IMAGE } from '@/config/site'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const portfolio = usePortfolio()
|
||||
const { t } = useI18n()
|
||||
|
||||
const seoTitle = computed(() => t('seo.contact.title'))
|
||||
const seoDescription = computed(() => t('seo.contact.description'))
|
||||
const seoImageAlt = computed(() => t('seo.imageAlt'))
|
||||
|
||||
usePageSeo({
|
||||
title: seoTitle,
|
||||
description: seoDescription,
|
||||
image: DEFAULT_SOCIAL_IMAGE,
|
||||
imageAlt: seoImageAlt,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
@@ -88,11 +88,24 @@
|
||||
import { computed } from 'vue'
|
||||
import ExperienceTimeline from '@/components/portfolio/ExperienceTimeline.vue'
|
||||
import { usePortfolio } from '@/composables/usePortfolio'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
import { DEFAULT_SOCIAL_IMAGE } from '@/config/site'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const portfolio = usePortfolio()
|
||||
const { t } = useI18n()
|
||||
|
||||
const seoTitle = computed(() => t('seo.experience.title'))
|
||||
const seoDescription = computed(() => t('seo.experience.description'))
|
||||
const seoImageAlt = computed(() => t('seo.imageAlt'))
|
||||
|
||||
usePageSeo({
|
||||
title: seoTitle,
|
||||
description: seoDescription,
|
||||
image: DEFAULT_SOCIAL_IMAGE,
|
||||
imageAlt: seoImageAlt,
|
||||
})
|
||||
|
||||
const yearsOfExperience = computed(() => {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const startYear = 2019 // Based on the experience data
|
||||
|
||||
@@ -147,13 +147,48 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import HeroSection from '@/components/portfolio/HeroSection.vue'
|
||||
import ProjectCard from '@/components/portfolio/ProjectCard.vue'
|
||||
import { usePortfolio } from '@/composables/usePortfolio'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
import { CONTACT_EMAIL, DEFAULT_SOCIAL_IMAGE, SITE_URL } from '@/config/site'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const portfolio = usePortfolio()
|
||||
const { t } = useI18n()
|
||||
|
||||
const seoTitle = computed(() => t('seo.home.title'))
|
||||
const seoDescription = computed(() => t('seo.home.description'))
|
||||
const seoImageAlt = computed(() => t('seo.imageAlt'))
|
||||
|
||||
const structuredData = computed(() => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: portfolio.intro.name,
|
||||
jobTitle: t('seo.person.jobTitle'),
|
||||
description: t('seo.person.description'),
|
||||
url: SITE_URL,
|
||||
image: DEFAULT_SOCIAL_IMAGE,
|
||||
email: `mailto:${CONTACT_EMAIL}`,
|
||||
sameAs: portfolio.socials
|
||||
.map(link => link.url)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: portfolio.intro.location,
|
||||
},
|
||||
knowsAbout: portfolio.intro.specialties,
|
||||
}))
|
||||
|
||||
usePageSeo({
|
||||
title: seoTitle,
|
||||
description: seoDescription,
|
||||
image: DEFAULT_SOCIAL_IMAGE,
|
||||
imageAlt: seoImageAlt,
|
||||
structuredData,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
@@ -135,9 +135,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router/auto'
|
||||
|
||||
import { usePortfolio } from '@/composables/usePortfolio'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
import { DEFAULT_SOCIAL_IMAGE } from '@/config/site'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const portfolio = usePortfolio()
|
||||
@@ -147,6 +150,39 @@ const { t } = useI18n()
|
||||
const slug = computed(() => route.params.slug)
|
||||
|
||||
const project = computed(() => portfolio.findProject(slug.value) ?? null)
|
||||
|
||||
const seoTitle = computed(() =>
|
||||
project.value
|
||||
? t('seo.project.title', { project: project.value.name })
|
||||
: t('seo.project.fallbackTitle')
|
||||
)
|
||||
|
||||
const seoDescription = computed(() =>
|
||||
project.value
|
||||
? t('seo.project.description', { summary: project.value.summary })
|
||||
: t('seo.project.fallbackDescription')
|
||||
)
|
||||
|
||||
const seoImage = computed(
|
||||
() => project.value?.heroImage ?? DEFAULT_SOCIAL_IMAGE
|
||||
)
|
||||
|
||||
const seoImageAlt = computed(() =>
|
||||
project.value
|
||||
? t('seo.project.imageAlt', { project: project.value.name })
|
||||
: t('seo.project.fallbackImageAlt')
|
||||
)
|
||||
|
||||
const shouldNoIndex = computed(() => project.value === null)
|
||||
|
||||
usePageSeo({
|
||||
title: seoTitle,
|
||||
description: seoDescription,
|
||||
image: seoImage,
|
||||
imageAlt: seoImageAlt,
|
||||
noindex: shouldNoIndex,
|
||||
type: 'article',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
||||
@@ -123,14 +123,28 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ProjectCard from '@/components/portfolio/ProjectCard.vue'
|
||||
import { usePortfolio } from '@/composables/usePortfolio'
|
||||
import { usePageSeo } from '@/composables/usePageSeo'
|
||||
import { DEFAULT_SOCIAL_IMAGE } from '@/config/site'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const portfolio = usePortfolio()
|
||||
const selectedFilter = ref<'all' | 'featured'>('all')
|
||||
const { t } = useI18n()
|
||||
|
||||
const seoTitle = computed(() => t('seo.projects.title'))
|
||||
const seoDescription = computed(() => t('seo.projects.description'))
|
||||
const seoImageAlt = computed(() => t('seo.imageAlt'))
|
||||
|
||||
usePageSeo({
|
||||
title: seoTitle,
|
||||
description: seoDescription,
|
||||
image: DEFAULT_SOCIAL_IMAGE,
|
||||
imageAlt: seoImageAlt,
|
||||
})
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (selectedFilter.value === 'featured') {
|
||||
return portfolio.featuredProjects
|
||||
|
||||
10
src/plugins/head.ts
Normal file
10
src/plugins/head.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createHead } from '@vueuse/head'
|
||||
|
||||
import { DEFAULT_TITLE, SITE_TITLE_SUFFIX } from '@/config/site'
|
||||
|
||||
export const head = createHead({
|
||||
titleTemplate: title =>
|
||||
title && title !== DEFAULT_TITLE
|
||||
? `${title} · ${SITE_TITLE_SUFFIX}`
|
||||
: DEFAULT_TITLE,
|
||||
})
|
||||
@@ -13,11 +13,12 @@ import pinia from '../stores'
|
||||
import vuetify from './vuetify'
|
||||
import reveal from '@/directives/reveal'
|
||||
import { i18n } from './i18n'
|
||||
import { head } from './head'
|
||||
|
||||
// Types
|
||||
|
||||
export function registerPlugins(app: App) {
|
||||
app.use(vuetify).use(router).use(pinia).use(i18n)
|
||||
app.use(vuetify).use(router).use(pinia).use(i18n).use(head)
|
||||
|
||||
app.directive('reveal', reveal)
|
||||
}
|
||||
|
||||
95
yarn.lock
95
yarn.lock
@@ -1985,6 +1985,60 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@unhead/dom@npm:1.11.20, @unhead/dom@npm:^1.7.0":
|
||||
version: 1.11.20
|
||||
resolution: "@unhead/dom@npm:1.11.20"
|
||||
dependencies:
|
||||
"@unhead/schema": "npm:1.11.20"
|
||||
"@unhead/shared": "npm:1.11.20"
|
||||
checksum: 10c0/344a61a00418ddc6f06b33f313b589945df18e43af29ab8e19e1df97a102f55bfbc7e8e163f7e8f0a415c58c48ad9878d552b29fdf83080e05cdcf30724a17c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@unhead/schema@npm:1.11.20, @unhead/schema@npm:^1.7.0":
|
||||
version: 1.11.20
|
||||
resolution: "@unhead/schema@npm:1.11.20"
|
||||
dependencies:
|
||||
hookable: "npm:^5.5.3"
|
||||
zhead: "npm:^2.2.4"
|
||||
checksum: 10c0/f2f968639bbd18f90ddfb83b77c9256bc4c0379ab75efa24dc759f3f597aae707d4dde97df690823f8902eab31d73a5faa8bdd8daf18c6ac8e4503a78b42be74
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@unhead/shared@npm:1.11.20":
|
||||
version: 1.11.20
|
||||
resolution: "@unhead/shared@npm:1.11.20"
|
||||
dependencies:
|
||||
"@unhead/schema": "npm:1.11.20"
|
||||
packrup: "npm:^0.1.2"
|
||||
checksum: 10c0/65ab0230e6338541f7a62e131c6d82b1160c71c86479fdb17d733fb5187422f6e287739cf50a1e7556690fb10951990d06e861e7aa3a90def479cb7a5ec638fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@unhead/ssr@npm:^1.7.0":
|
||||
version: 1.11.20
|
||||
resolution: "@unhead/ssr@npm:1.11.20"
|
||||
dependencies:
|
||||
"@unhead/schema": "npm:1.11.20"
|
||||
"@unhead/shared": "npm:1.11.20"
|
||||
checksum: 10c0/f4ca6809a2436d23dbb4b57aaaa8981cac97eebd34c0c6237377e86cbd90c26079305adf97db9334966ebd4514ad62985648b653512ec35df916f6448d7624e5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@unhead/vue@npm:^1.7.0":
|
||||
version: 1.11.20
|
||||
resolution: "@unhead/vue@npm:1.11.20"
|
||||
dependencies:
|
||||
"@unhead/schema": "npm:1.11.20"
|
||||
"@unhead/shared": "npm:1.11.20"
|
||||
hookable: "npm:^5.5.3"
|
||||
unhead: "npm:1.11.20"
|
||||
peerDependencies:
|
||||
vue: ">=2.7 || >=3"
|
||||
checksum: 10c0/75f1cd8d671de363b02bc8ad2aaf6dc7fb0a402bdd306046ad71608d370190b539e74d6b03ab455d9c2453c5dd62956a235f92d74b09b98c336b68b2d3911602
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vitejs/plugin-vue@npm:^6.0.1":
|
||||
version: 6.0.1
|
||||
resolution: "@vitejs/plugin-vue@npm:6.0.1"
|
||||
@@ -2354,6 +2408,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vueuse/head@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "@vueuse/head@npm:2.0.0"
|
||||
dependencies:
|
||||
"@unhead/dom": "npm:^1.7.0"
|
||||
"@unhead/schema": "npm:^1.7.0"
|
||||
"@unhead/ssr": "npm:^1.7.0"
|
||||
"@unhead/vue": "npm:^1.7.0"
|
||||
peerDependencies:
|
||||
vue: ">=2.7 || >=3"
|
||||
checksum: 10c0/0c8c51951eb3d4e8394cc04ba6f1fc36a6e6dfbde9ef677c525b10e14d06007f71df9f2c90d6b02afc6476ca53582070c834357a7af205bf8ef856fe5303f7a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"abbrev@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "abbrev@npm:2.0.0"
|
||||
@@ -7960,6 +8028,7 @@ __metadata:
|
||||
"@vitest/ui": "npm:^3.2.4"
|
||||
"@vue/eslint-config-typescript": "npm:^14.6.0"
|
||||
"@vue/test-utils": "npm:^2.4.6"
|
||||
"@vueuse/head": "npm:^2.0.0"
|
||||
autoprefixer: "npm:^10.4.21"
|
||||
core-js: "npm:^3.45.1"
|
||||
eslint: "npm:^9.36.0"
|
||||
@@ -8540,6 +8609,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"packrup@npm:^0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "packrup@npm:0.1.2"
|
||||
checksum: 10c0/8236a89e02a86a1539ee7ff920050544f2abcdc74d1a4c16c6182ad23ca36b8b686adb2203f08ae23f422852d856ff7213e18411a7a3f1ac90b1f850b0b6e86d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parent-module@npm:^1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "parent-module@npm:1.0.1"
|
||||
@@ -10941,6 +11017,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unhead@npm:1.11.20":
|
||||
version: 1.11.20
|
||||
resolution: "unhead@npm:1.11.20"
|
||||
dependencies:
|
||||
"@unhead/dom": "npm:1.11.20"
|
||||
"@unhead/schema": "npm:1.11.20"
|
||||
"@unhead/shared": "npm:1.11.20"
|
||||
hookable: "npm:^5.5.3"
|
||||
checksum: 10c0/cce50a79509984679d3b8f7a2299f7ec7e252c8efdac76e9156ffa816c04c53ff25e526ead88df4cb67dc016b98c37adec8c3e93b7322a972561d3f4cd1c21d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unimport@npm:^5.4.0":
|
||||
version: 5.4.0
|
||||
resolution: "unimport@npm:5.4.0"
|
||||
@@ -11865,6 +11953,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zhead@npm:^2.2.4":
|
||||
version: 2.2.4
|
||||
resolution: "zhead@npm:2.2.4"
|
||||
checksum: 10c0/3d166fb661f1b7fdf8a0ef2222d9e574ab241e72141f2f1fda62a9250ca73aabf2eaf0d66046a3984cd24d1dd9bac231338c6271684d6b8caa6b66af7c45f275
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.24.1":
|
||||
version: 3.25.76
|
||||
resolution: "zod@npm:3.25.76"
|
||||
|
||||
Reference in New Issue
Block a user