From b4a6b4710f26ed6def8fc5f33aff802cf9823478 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 10:29:40 +0000 Subject: [PATCH] refactor(content): introduce typed ContentRepository abstraction Decouple composables from @nuxt/content so the content backend can be swapped for a headless CMS later without touching composables, pages or components. - Add provider-agnostic ContentRepository interface (utils/content/repository.ts) - Add NuxtContentAdapter encapsulating all queryCollection calls, collection-key naming and content-specific casts (utils/content/nuxtContentAdapter.ts) - Add useContentRepository() as the single backend swap point - Move FaqEntry into a dedicated domain type (types/faq.ts) - Route useBlogContent/useHomeContent/useFaqContent/useSponsoring/useTeamProfile through the repository; domain logic (i18n, release filtering, SEO/hreflang) stays in the composables Also removes a stale @nuxt/content module augmentation, fixing 4 pre-existing type errors. https://claude.ai/code/session_01JM95TEbXcXrQignrX6J7mm --- composables/useBlogContent.ts | 106 +++++++++++----------------- composables/useContentRepository.ts | 21 ++++++ composables/useFaqContent.ts | 26 +++---- composables/useHomeContent.ts | 47 +++++------- composables/useSponsoring.ts | 15 ++-- composables/useTeamProfile.ts | 18 ++--- types/faq.ts | 17 +++++ utils/content/nuxtContentAdapter.ts | 90 +++++++++++++++++++++++ utils/content/repository.ts | 55 +++++++++++++++ 9 files changed, 268 insertions(+), 127 deletions(-) create mode 100644 composables/useContentRepository.ts create mode 100644 types/faq.ts create mode 100644 utils/content/nuxtContentAdapter.ts create mode 100644 utils/content/repository.ts diff --git a/composables/useBlogContent.ts b/composables/useBlogContent.ts index 2a78772..2f416e7 100644 --- a/composables/useBlogContent.ts +++ b/composables/useBlogContent.ts @@ -1,6 +1,7 @@ -import { createError, queryCollection } from '#imports' -import type { PageCollectionItemBase } from '@nuxt/content' +import { createError } from '#imports' import type { LocaleObject } from 'vue-i18n-routing' +import { useContentRepository } from '~/composables/useContentRepository' +import type { Locale } from '~/utils/content/collections' import type { BlogArticle, BlogAlternateLanguageLink, @@ -8,18 +9,7 @@ import type { BlogAuthorProfile } from '~/types/blog' -type BlogCollectionKey = 'blog_de' | 'blog_en' type HeadLink = { rel: string; href: string; hreflang?: string; type?: string } -type AuthorCollectionItem = BlogAuthorProfile & PageCollectionItemBase - -declare module '@nuxt/content' { - interface PageCollections { - authors: AuthorCollectionItem - } - interface Collections { - authors: AuthorCollectionItem - } -} const normalizeReleaseDate = (entry: BlogArticle): Date | null => { const raw = entry.releaseDate ?? entry.pubDate @@ -28,8 +18,7 @@ const normalizeReleaseDate = (entry: BlogArticle): Date | null => { return Number.isNaN(parsed.getTime()) ? null : parsed } -const releaseTimestamp = (entry: BlogArticle): number => - normalizeReleaseDate(entry)?.getTime() ?? 0 +const releaseTimestamp = (entry: BlogArticle): number => normalizeReleaseDate(entry)?.getTime() ?? 0 const isReleased = (entry: BlogArticle | null | undefined): entry is BlogArticle => { if (!entry) return false @@ -38,16 +27,14 @@ const isReleased = (entry: BlogArticle | null | undefined): entry is BlogArticle return release.getTime() <= Date.now() } -const normalizeLocales = (list: unknown[]): LocaleObject[] => - list - .filter( - (locale): locale is LocaleObject => - Boolean(locale && typeof locale === 'object' && 'code' in (locale as Record)) - ) - .map((locale) => locale as LocaleObject) +const isLocaleObject = (locale: unknown): locale is LocaleObject => Boolean(locale && typeof locale === 'object' && 'code' in (locale as Record)) + +const normalizeLocales = (list: unknown[]): LocaleObject[] => list.filter(isLocaleObject) -const resolveHreflang = (locale: LocaleObject, fallback: string): string => - locale.iso || (locale as { _hreflang?: string })._hreflang || locale.code || fallback +const resolveHreflang = (locale: LocaleObject, fallback: string): string => { + const hreflang = (locale as { _hreflang?: string })._hreflang + return locale.iso || hreflang || locale.code || fallback +} export interface BlogOverviewOptions { /** @@ -68,17 +55,13 @@ export interface BlogOverviewOptions { */ export function useBlogOverview(options: BlogOverviewOptions = {}) { const { locale } = useI18n() - const blogCollection = computed( - () => (`blog_${locale?.value || 'de'}`) as BlogCollectionKey - ) + const repo = useContentRepository() + const activeLocale = computed(() => (locale?.value || 'de') as Locale) const { data: allPostsData } = useAsyncData( - () => `all-posts-${locale.value}`, - () => - queryCollection(blogCollection.value) - .order('pubDate', 'DESC') - .all(), - { watch: [locale] } + () => `all-posts-${activeLocale.value}`, + () => repo.listBlogArticles(activeLocale.value), + { watch: [activeLocale] } ) const visiblePosts = computed(() => { @@ -128,12 +111,11 @@ export function useBlogArticle() { const { locale, locales } = useI18n() const route = useRoute() const config = useRuntimeConfig() - const blogCollection = computed( - () => (`blog_${locale?.value || 'de'}`) as BlogCollectionKey - ) - const availableLocales = computed(() => - normalizeLocales((locales.value || []) as unknown[]) - ) + const repo = useContentRepository() + const activeLocale = computed(() => (locale?.value || 'de') as Locale) + const availableLocales = computed(() => { + return normalizeLocales((locales.value || []) as unknown[]) + }) // Slug derived from catch-all route param; reactive on client navigation const slugSegments = computed(() => { @@ -164,9 +146,7 @@ export function useBlogArticle() { async () => { if (!slug.value) return null - const doc = (await queryCollection(blogCollection.value) - .where('slug', '=', slug.value) - .first()) as BlogArticle | null + const doc = await repo.getBlogArticleBySlug(activeLocale.value, slug.value) if (doc && !isReleased(doc)) { throw createError({ statusCode: 404, statusMessage: 'Article not released' }) @@ -179,18 +159,12 @@ export function useBlogArticle() { .map((s) => String(s)) const authorDocs = slugs.length - ? await Promise.all( - slugs.map((authorSlug) => - queryCollection('authors') - .where('slug', '=', authorSlug) - .first() - ) - ) + ? await Promise.all(slugs.map((authorSlug) => repo.getAuthorBySlug(authorSlug))) : [] return { article: doc, - authors: (authorDocs.filter(Boolean) as AuthorCollectionItem[]) || [] + authors: authorDocs.filter((a): a is BlogAuthorProfile => Boolean(a)) } }, { watch: [locale, slug] } @@ -213,7 +187,11 @@ export function useBlogArticle() { } // Rebuild alternates whenever article/locale changes - watch([blog, locale, locales], async () => { + watch([ + blog, + locale, + locales + ], async () => { alternateLanguages.value = [] if (!blog.value) return @@ -225,15 +203,17 @@ export function useBlogArticle() { return } - if (!blog.value.translationKey) return + const translationKey = blog.value.translationKey + if (!translationKey) return const otherLocales = availableLocales.value.filter((l) => l.code !== locale.value) const baseUrl = resolveBaseUrl() for (const otherLocale of otherLocales) { - const translated = await queryCollection(`blog_${otherLocale.code}` as BlogCollectionKey) - .where('translationKey', '=', blog.value?.translationKey) - .first() + const translated = await repo.getBlogArticleByTranslationKey( + otherLocale.code as Locale, + translationKey + ) if (translated) { const hreflangValue = resolveHreflang(otherLocale, otherLocale.code) @@ -256,14 +236,14 @@ export function useBlogArticle() { const baseUrl = resolveBaseUrl() - const currentLocaleObj = - availableLocales.value.find((l) => l.code === locale.value) || - ({ code: locale.value } as LocaleObject) + const currentLocaleObj + = availableLocales.value.find((l) => l.code === locale.value) + || ({ code: locale.value } as LocaleObject) const currentHreflang = resolveHreflang(currentLocaleObj, locale.value) // Prefer explicit canonical from front-matter, else compute fallback - const canonicalUrl = - blog.value.canonical || `${baseUrl}/${locale.value}/blog/${blog.value.slug}` + const canonicalUrl + = blog.value.canonical || `${baseUrl}/${locale.value}/blog/${blog.value.slug}` links.push({ rel: 'canonical', href: canonicalUrl }) @@ -293,10 +273,8 @@ export function useBlogArticle() { if (locale.value === defaultLocale) { pushAlt('x-default', canonicalUrl) } else { - const defaultLocaleUrl = - alternateLanguages.value.find( - (alt) => alt.locale?.startsWith('en') || alt.locale === 'en' - )?.url || `${baseUrl}/${defaultLocale}/blog/${blog.value.slug}` + const defaultLocaleUrl + = alternateLanguages.value.find((alt) => alt.locale?.startsWith('en') || alt.locale === 'en')?.url || `${baseUrl}/${defaultLocale}/blog/${blog.value.slug}` pushAlt('x-default', defaultLocaleUrl) } } diff --git a/composables/useContentRepository.ts b/composables/useContentRepository.ts new file mode 100644 index 0000000..44f22b5 --- /dev/null +++ b/composables/useContentRepository.ts @@ -0,0 +1,21 @@ +import type { ContentRepository } from '~/utils/content/repository' +import { createNuxtContentAdapter } from '~/utils/content/nuxtContentAdapter' + +let instance: ContentRepository | null = null + +/** + * Returns the active {@link ContentRepository}. This is the single place that + * decides which content backend the app talks to — swapping @nuxt/content for + * a headless CMS later means changing only the adapter constructed here (or + * branching on `useRuntimeConfig().public.content.provider`); no composable, + * page or component needs to change. + * + * The adapter is stateless, so a module-level singleton is safe for both SSR + * and client. + */ +export function useContentRepository(): ContentRepository { + if (!instance) { + instance = createNuxtContentAdapter() + } + return instance +} diff --git a/composables/useFaqContent.ts b/composables/useFaqContent.ts index 1e2e007..2e7c53f 100644 --- a/composables/useFaqContent.ts +++ b/composables/useFaqContent.ts @@ -1,13 +1,6 @@ -import { queryCollection } from '#imports' -import type { PageCollectionItemBase } from '@nuxt/content' - -type FaqCollectionKey = 'faq_de' | 'faq_en' - -export interface FaqDocument extends PageCollectionItemBase { - key: string - question: string - order?: number -} +import { useContentRepository } from '~/composables/useContentRepository' +import type { Locale } from '~/utils/content/collections' +import type { FaqEntry } from '~/types/faq' /** * Fetches all FAQ entries for the active locale, ordered by the optional @@ -17,15 +10,16 @@ export interface FaqDocument extends PageCollectionItemBase { */ export function useFaqContent() { const { locale } = useI18n() - const collection = computed(() => (`faq_${locale?.value || 'en'}`) as FaqCollectionKey) + const repo = useContentRepository() + const activeLocale = computed(() => (locale?.value || 'en') as Locale) - const { data: entries } = useAsyncData( - () => `faq-${collection.value}`, - () => queryCollection(collection.value).order('order', 'ASC').all() as Promise, - { watch: [collection] } + const { data: entries } = useAsyncData( + () => `faq-${activeLocale.value}`, + () => repo.listFaqEntries(activeLocale.value), + { watch: [activeLocale] } ) - const items = computed(() => entries.value || []) + const items = computed(() => entries.value || []) return { items } } diff --git a/composables/useHomeContent.ts b/composables/useHomeContent.ts index 54ddaa7..5acbfc9 100644 --- a/composables/useHomeContent.ts +++ b/composables/useHomeContent.ts @@ -1,3 +1,5 @@ +import { useContentRepository } from '~/composables/useContentRepository' +import type { Locale } from '~/utils/content/collections' import type { HomeCarouselDocument, HomeCarouselSlide, @@ -7,44 +9,29 @@ import type { export function useHomeContent() { const { locale } = useI18n() + const repo = useContentRepository() + const activeLocale = computed(() => (locale?.value || 'de') as Locale) - const fetchCollection = async (key: string) => { - // @ts-expect-error queryCollection is provided by @nuxt/content at runtime - return (await queryCollection(key).all()) as T[] - } - - // Server concept content from Nuxt Content (i18n) - const { data: conceptData } = useAsyncData( - 'server-concept-home', - () => fetchCollection('server_concept_' + (locale?.value || 'de')), - { watch: [locale] } - ) - - const concept = computed( - () => (conceptData.value?.[0] as ServerConceptDocument | undefined) ?? null - ) - - // Server Connect content from Nuxt Content (i18n) - const { data: connectData } = useAsyncData( - 'server-connect', - () => fetchCollection('server_connect_' + (locale?.value || 'de')), - { watch: [locale] } + const { data: concept } = useAsyncData( + () => `server-concept-home-${activeLocale.value}`, + () => repo.getServerConcept(activeLocale.value), + { watch: [activeLocale] } ) - const connect = computed( - () => (connectData.value?.[0] as ServerConnectDocument | undefined) ?? null + const { data: connect } = useAsyncData( + () => `server-connect-${activeLocale.value}`, + () => repo.getServerConnect(activeLocale.value), + { watch: [activeLocale] } ) - // Carousel content from Nuxt Content (i18n) - const { data: homeCarousel } = useAsyncData( - 'home-carousel', - () => fetchCollection('home_carousel_' + (locale?.value || 'de')), - { watch: [locale] } + const { data: homeCarousel } = useAsyncData( + () => `home-carousel-${activeLocale.value}`, + () => repo.getHomeCarousel(activeLocale.value), + { watch: [activeLocale] } ) const slides = computed(() => { - const doc = homeCarousel.value?.[0] as HomeCarouselDocument | undefined - return (doc?.slides as HomeCarouselSlide[] | undefined) ?? [] + return (homeCarousel.value?.slides as HomeCarouselSlide[] | undefined) ?? [] }) return { diff --git a/composables/useSponsoring.ts b/composables/useSponsoring.ts index c8f9d20..32c177d 100644 --- a/composables/useSponsoring.ts +++ b/composables/useSponsoring.ts @@ -1,17 +1,16 @@ -import { queryCollection } from '#imports' +import { useContentRepository } from '~/composables/useContentRepository' +import type { Locale } from '~/utils/content/collections' import type { SponsorEntry, SponsorsDocument } from '~/types/sponsoring' export function useSponsoring() { const { locale } = useI18n() - type SponsorsCollectionKey = 'sponsors_de' | 'sponsors_en' - const collectionKey = computed( - () => (`sponsors_${locale?.value || 'de'}`) as SponsorsCollectionKey - ) + const repo = useContentRepository() + const activeLocale = computed(() => (locale?.value || 'de') as Locale) const { data } = useAsyncData( - 'sponsors', - () => queryCollection(collectionKey.value).first(), - { watch: [collectionKey] } + () => `sponsors-${activeLocale.value}`, + () => repo.getSponsorsDocument(activeLocale.value), + { watch: [activeLocale] } ) const sponsors = computed(() => { diff --git a/composables/useTeamProfile.ts b/composables/useTeamProfile.ts index 0056ef8..e6af987 100644 --- a/composables/useTeamProfile.ts +++ b/composables/useTeamProfile.ts @@ -1,23 +1,23 @@ +import { useContentRepository } from '~/composables/useContentRepository' +import type { Locale } from '~/utils/content/collections' import type { TeamDocument, TeamMember } from '~/types/team' export function useTeamProfile(slugOverride?: string) { const route = useRoute() const { locale } = useI18n() + const repo = useContentRepository() + const activeLocale = computed(() => (locale?.value || 'de') as Locale) const slug = computed(() => slugOverride ?? (route.params.slug as string)) - const { data: teamData } = useAsyncData( - () => `team-profile-${locale.value}`, - () => { - // @ts-ignore provided by @nuxt/content - return queryCollection('team_' + (locale?.value || 'de')).all() - }, - { watch: [locale] } + const { data: teamDoc } = useAsyncData( + () => `team-profile-${activeLocale.value}`, + () => repo.getTeamDocument(activeLocale.value), + { watch: [activeLocale] } ) const member = computed(() => { - const doc = teamData.value?.[0] as TeamDocument | undefined - const list = doc?.members || [] + const list = teamDoc.value?.members || [] return (list as TeamMember[]).find((m) => m.slug === slug.value) || null }) diff --git a/types/faq.ts b/types/faq.ts new file mode 100644 index 0000000..5e9f3e5 --- /dev/null +++ b/types/faq.ts @@ -0,0 +1,17 @@ +import type { PageCollectionItemBase } from '@nuxt/content' + +/** + * Domain model for a single FAQ entry. + * + * NOTE: `body`/`PageCollectionItemBase` is currently required because the FAQ + * page renders the Markdown body via ``. This is the one + * remaining provider-specific coupling at the rendering layer; a future CMS + * adapter would need to map its rich-text payload onto this shape (or the + * rendering would move to plain HTML). Data access itself is already behind + * the ContentRepository. + */ +export interface FaqEntry extends PageCollectionItemBase { + key: string + question: string + order?: number +} diff --git a/utils/content/nuxtContentAdapter.ts b/utils/content/nuxtContentAdapter.ts new file mode 100644 index 0000000..26ddeba --- /dev/null +++ b/utils/content/nuxtContentAdapter.ts @@ -0,0 +1,90 @@ +import { queryCollection } from '#imports' +import type { Locale } from './collections' +import type { ContentRepository } from './repository' +import type { BlogArticle, BlogAuthorProfile } from '~/types/blog' +import type { FaqEntry } from '~/types/faq' +import type { TeamDocument } from '~/types/team' +import type { + ServerConceptDocument, + ServerConnectDocument, + HomeCarouselDocument +} from '~/types/home' +import type { SponsorsDocument } from '~/types/sponsoring' + +// The `authors` collection is declared in `content.config.ts`, so its types +// are generated by @nuxt/content — no module augmentation needed here. + +// Collection-key naming is a @nuxt/content implementation detail and must +// stay confined to this file. +const blogKey = (locale: Locale) => `blog_${locale}` as 'blog_de' | 'blog_en' +const faqKey = (locale: Locale) => `faq_${locale}` as 'faq_de' | 'faq_en' +const teamKey = (locale: Locale) => `team_${locale}` as 'team_de' | 'team_en' +const sponsorsKey = (locale: Locale) => `sponsors_${locale}` as 'sponsors_de' | 'sponsors_en' +const serverConceptKey = (locale: Locale) => `server_concept_${locale}` as 'server_concept_de' | 'server_concept_en' +const serverConnectKey = (locale: Locale) => `server_connect_${locale}` as 'server_connect_de' | 'server_connect_en' +const homeCarouselKey = (locale: Locale) => `home_carousel_${locale}` as 'home_carousel_de' | 'home_carousel_en' + +/** + * The @nuxt/content-backed {@link ContentRepository} implementation. Every + * `queryCollection` call in the app is funnelled through here. All methods + * must run inside a Nuxt context (e.g. an `useAsyncData` fetcher), which is + * how `queryCollection` resolves the active Nuxt app. + */ +export function createNuxtContentAdapter(): ContentRepository { + return { + listBlogArticles(locale) { + return queryCollection(blogKey(locale)) + .order('pubDate', 'DESC') + .all() as Promise + }, + + getBlogArticleBySlug(locale, slug) { + return queryCollection(blogKey(locale)) + .where('slug', '=', slug) + .first() as Promise + }, + + getBlogArticleByTranslationKey(locale, translationKey) { + return queryCollection(blogKey(locale)) + .where('translationKey', '=', translationKey) + .first() as Promise + }, + + getAuthorBySlug(slug) { + return queryCollection('authors') + .where('slug', '=', slug) + .first() as Promise + }, + + listFaqEntries(locale) { + return queryCollection(faqKey(locale)) + .order('order', 'ASC') + .all() as Promise + }, + + async getTeamDocument(locale) { + const docs = await queryCollection(teamKey(locale)).all() + return (docs[0] ?? null) as TeamDocument | null + }, + + async getServerConcept(locale) { + const docs = await queryCollection(serverConceptKey(locale)).all() + return (docs[0] ?? null) as ServerConceptDocument | null + }, + + async getServerConnect(locale) { + const docs = await queryCollection(serverConnectKey(locale)).all() + return (docs[0] ?? null) as ServerConnectDocument | null + }, + + async getHomeCarousel(locale) { + const docs = await queryCollection(homeCarouselKey(locale)).all() + return (docs[0] ?? null) as HomeCarouselDocument | null + }, + + getSponsorsDocument(locale) { + return queryCollection(sponsorsKey(locale)) + .first() as Promise + } + } +} diff --git a/utils/content/repository.ts b/utils/content/repository.ts new file mode 100644 index 0000000..d1f7428 --- /dev/null +++ b/utils/content/repository.ts @@ -0,0 +1,55 @@ +import type { Locale } from './collections' +import type { BlogArticle, BlogAuthorProfile } from '~/types/blog' +import type { FaqEntry } from '~/types/faq' +import type { TeamDocument } from '~/types/team' +import type { + ServerConceptDocument, + ServerConnectDocument, + HomeCarouselDocument +} from '~/types/home' +import type { SponsorsDocument } from '~/types/sponsoring' + +/** + * Provider-agnostic content access layer. + * + * Composables, pages and components MUST depend only on this interface and the + * domain types in `~/types/*`. Everything provider-specific (collection-key + * naming, query syntax, AST shapes) lives in the concrete adapter + * (`nuxtContentAdapter.ts`). Switching off @nuxt/content to a headless CMS + * later means writing a new adapter that satisfies this contract — no + * composable/page changes required. + * + * Domain logic (i18n resolution, release-date filtering, sorting, SEO/hreflang + * assembly) intentionally stays in the composables; it is provider-independent + * and must not leak into adapters. + */ +export interface ContentRepository { + // --- Blog ----------------------------------------------------------------- + /** All blog articles for a locale (unfiltered, unsorted — caller decides). */ + listBlogArticles(locale: Locale): Promise + /** Single article by its `slug` frontmatter field, or null. */ + getBlogArticleBySlug(locale: Locale, slug: string): Promise + /** Single article in `locale` sharing the given `translationKey`, or null. */ + getBlogArticleByTranslationKey( + locale: Locale, + translationKey: string + ): Promise + /** Author profile (locale-independent collection) by `slug`, or null. */ + getAuthorBySlug(slug: string): Promise + + // --- FAQ ------------------------------------------------------------------ + /** All FAQ entries for a locale, ordered by the `order` field ascending. */ + listFaqEntries(locale: Locale): Promise + + // --- Team ----------------------------------------------------------------- + /** The single team document for a locale, or null. */ + getTeamDocument(locale: Locale): Promise + + // --- Home ----------------------------------------------------------------- + getServerConcept(locale: Locale): Promise + getServerConnect(locale: Locale): Promise + getHomeCarousel(locale: Locale): Promise + + // --- Sponsoring ----------------------------------------------------------- + getSponsorsDocument(locale: Locale): Promise +}