diff --git a/composables/useBlogContent.ts b/composables/useBlogContent.ts index 9d4ad76..48fe09c 100644 --- a/composables/useBlogContent.ts +++ b/composables/useBlogContent.ts @@ -1,24 +1,13 @@ -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, BlogAlternateHeader, BlogAuthorProfile } from '~/types/blog' -type BlogCollectionKey = 'blog_de' | 'blog_en' -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 if (!raw) return null @@ -84,17 +73,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(() => { @@ -143,9 +128,8 @@ export function useBlogOverview(options: BlogOverviewOptions = {}) { export async function useBlogArticle() { const { locale, locales } = useI18n() const route = useRoute() - const blogCollection = computed( - () => (`blog_${locale?.value || 'de'}`) as BlogCollectionKey - ) + const repo = useContentRepository() + const activeLocale = computed(() => (locale?.value || 'de') as Locale) const availableLocales = computed(() => normalizeLocales((locales.value || []) as unknown[]) ) @@ -185,9 +169,7 @@ export async 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) // A missing or not-yet-released article is a 404. Returning a marker // (instead of throwing here) lets us raise a single fatal error after @@ -203,17 +185,13 @@ export async function useBlogArticle() { const authorDocs = slugs.length ? await Promise.all( - slugs.map((authorSlug) => - queryCollection('authors') - .where('slug', '=', authorSlug) - .first() - ) + 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] } @@ -266,17 +244,19 @@ export async function useBlogArticle() { return } - if (!blog.value.translationKey) { + const translationKey = blog.value.translationKey + if (!translationKey) { publishLocaleParams(localeSlugs) return } const otherLocales = availableLocales.value.filter((l) => l.code !== locale.value) for (const otherLocale of otherLocales) { - const translated = await queryCollection(`blog_${otherLocale.code}` as BlogCollectionKey) - .where('translationKey', '=', blog.value?.translationKey) - .first() - const translatedSlug = (translated as BlogArticle | null)?.slug + const translated = await repo.getBlogArticleByTranslationKey( + otherLocale.code as Locale, + translationKey + ) + const translatedSlug = translated?.slug if (translatedSlug) localeSlugs[otherLocale.code] = translatedSlug } 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 +}