/** * Zentrale Übersetzungen (i18n-ähnlich). * Quelle: CMS translation_bundle (API, hooks.server.ts setzt event.locals.translations). * Wird in +layout.server.ts geladen und über TranslationProvider/Svelte-Context bereitgestellt. */ import { getContext } from "svelte"; export type Translations = Record; /** Context-Key für die t-Funktion (von TranslationProvider gesetzt). */ export const T_CONTEXT_KEY = Symbol("translations.t"); /** Platzhalter für Ersetzung: z. B. { name: "Max" } ersetzt {{name}} im Übersetzungstext. */ export type TranslationReplacements = Record; /** t(key) oder t(key, { placeholder: value }) – mit optionalen Ersetzungen für {{placeholder}}. */ export type TFunction = ( key: string, replacements?: TranslationReplacements, ) => string; /** * Ersetzt {{name}} im Text durch replacements[name]; fehlende Keys bleiben als {{name}}. */ export function replacePlaceholders( text: string, replacements?: TranslationReplacements | null, ): string { if (!replacements || typeof text !== "string") return text; return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => { const val = replacements[name]; return val !== undefined && val !== null ? String(val) : `{{${name}}}`; }); } /** * t-Funktion aus Svelte-Context holen. * Zuverlässig innerhalb des TranslationProvider-Baums. */ export function useTranslate(): TFunction { const fromContext = getContext(T_CONTEXT_KEY); if (fromContext) return fromContext; return (key: string, replacements?: TranslationReplacements): string => { return replacePlaceholders(key, replacements); }; } /** Bekannte Übersetzungs-Keys – nur hier eintragen, Nutzung über T.xy (Autocomplete, keine Tippfehler). */ const TRANSLATION_KEYS = [ "searchable_text_help", "searchable_text_help_aria", "searchable_text_placeholder", "searchable_text_search_aria", "searchable_text_clear_search", "searchable_text_tag_all", "searchable_text_no_results", "searchable_text_count_all", "searchable_text_count_filtered", "searchable_text_copy_aria", "searchable_text_copy_button", "searchable_text_copied", "searchable_text_untitled", "footer_copyright", "nav_start", "nav_posts", "nav_home", "header_search_open", "header_search_close", "header_menu_open", "header_menu_close", "header_nav_aria", "header_overlay_close", "header_social_aria", "site_name_fallback", "pagination_prev", "pagination_next", "blog_tag_all", "blog_filter_aria", "blog_count", "page_404_title", "page_404_text", "page_404_back", "posts_title", "posts_subtitle", "posts_tag_title", "posts_tag_subtitle", "posts_cms_error", "iframe_activate_aria", "iframe_missing", "image_gallery_swipe_aria", "image_gallery_prev_aria", "image_gallery_next_aria", "image_gallery_position_aria", "image_gallery_slide_aria", "image_gallery_empty", "image_gallery_download", "image_gallery_close", "image_gallery_open_aria", "image_gallery_modal_aria", "youtube_missing", "youtube_title_fallback", "calendar_prev_month", "calendar_next_month", "calendar_weekday_mo", "calendar_weekday_di", "calendar_weekday_mi", "calendar_weekday_do", "calendar_weekday_fr", "calendar_weekday_sa", "calendar_weekday_so", "calendar_more_info", "calendar_no_events", "calendar_time_suffix", "calendar_select_day", "calendar_next_events", "calendar_all_upcoming", "calendar_today", "calendar_tomorrow", "calendar_in_days", // {{n}} "calendar_no_future_events", "calendar_clear_filter", "calendar_event_count", // {{n}} "calendar_download_ics", "calendar_open_maps", "calendar_show_past", "calendar_hide_past", "calendar_past_events", "calendar_filter_by_tag", "calendar_all_tags", "calendar_multi_day_range", // {{from}}, {{to}} "calendar_starts_in", // {{n}} "calendar_starts_in_minutes", // {{n}} "calendar_running_now", "calendar_jump_to_month", "calendar_today_marker", "calendar_untitled", "calendar_show_more", "calendar_show_less", "calendar_subscribe", "calendar_subscribe_aria", "calendar_export_all", "calendar_add_to_google", "calendar_share", "calendar_share_copied", "calendar_share_aria", "post_overview_all", "blog_search_label", "blog_search_placeholder", "blog_search_clear", "blog_year_label", "blog_year_all", "blog_upcoming_events", "blog_results_count", "blog_results_for", "blog_results_in_year", "blog_filter_reset", "blog_no_results", "blog_rss_subscribe", "post_map", "post_map_activate", "post_map_open_osm", "post_comments", // Comment plugin (Comments.svelte / PostCard count pill / PostActions toolbar). "comments_loading", "comments_load_error_title", "comments_retry", "comments_empty", "comments_edit", "comments_delete", "comments_save", "comments_cancel", "comments_confirm_delete", "comments_reply", "comments_reply_count_one", "comments_reply_count_other", "comments_reply_send", "comments_send", "comments_sending", "comments_form_title", "comments_name_placeholder", "comments_body_placeholder", "comments_reply_placeholder", "comments_approval_hint", "comments_edited", "comments_pending_short", "comments_pending_full", "comments_self_marker", "comments_pending_summary_one", "comments_pending_summary_other", "comments_count_aria", "comments_jump_to", // Post actions toolbar (share / copy link / print / reading time). "post_action_share", "post_action_copy", "post_action_copied", "post_action_print", "post_action_reading_time", // Windkarte (WindkarteBlock / WindAreaPanel). "windkarte_loading", "windkarte_tap_to_activate", "windkarte_panel_close", "windkarte_reset_aria", "windkarte_status_rechtskraeftig", "windkarte_status_entwurf_2", "windkarte_status_entwurf_2_voraussichtlich", "windkarte_status_entwurf_3", "windkarte_label_flaeche", "windkarte_label_anlagen_geplant", "windkarte_label_hoehe_max", "windkarte_label_investor", "windkarte_label_gemeinden", "windkarte_label_notizen", "windkarte_label_kriterien", "windkarte_kriterien_loading", "windkarte_label_geodaten_export", "windkarte_action_download_geojson", "windkarte_action_download_kml", "windkarte_action_show", "windkarte_action_hide", "windkarte_stellungnahme_link", "windkarte_action_copy_link", "windkarte_action_copied", "windkarte_action_osm", "windkarte_legend_rechtskraeftig", "windkarte_legend_entwurf_2", "windkarte_legend_entwurf_2_voraussichtlich", "windkarte_legend_distances", // Strommix-Live-Widget (StrommixBlock.svelte). "strommix_header", "strommix_stand", // {{time}} "strommix_load_error", // {{error}} "strommix_loading", "strommix_wind_solar_share", "strommix_load_missing", "strommix_conventional_now", "strommix_conventional_formula", "strommix_saldo", "strommix_import", "strommix_export", "strommix_delayed", "strommix_price_de", // {{price}} "strommix_price_fr_inline", // {{price}} "strommix_slot", // {{time}} "strommix_negative_price", "strommix_check_values", "strommix_field_wind_onshore", "strommix_field_wind_offshore", "strommix_field_solar", "strommix_field_lignite", "strommix_field_hard_coal", "strommix_field_gas", "strommix_field_nuclear", "strommix_field_biomass", "strommix_field_hydro", "strommix_field_load", "strommix_source", "strommix_source_smard", "strommix_source_hybrid", "strommix_source_energy_charts", "strommix_source_resolution", // {{time}} "strommix_conventional_explanation", "strommix_stored_data", "strommix_how_calculated", // Compact variant for homepage / sidebar. "strommix_compact_label", // {{pct}}, {{status}} "strommix_compact_more", // Akkordeon + history-panel headings. "strommix_history_summary", "strommix_residual_today_title", "strommix_residual_today_desc", // Loading / error UX. "strommix_retry", "strommix_trend_up", // {{delta}} "strommix_trend_down", // {{delta}} "strommix_trend_flat", "strommix_sparkline_aria", // {{from}}, {{to}} "strommix_stale_badge", // Contact form (ContactFormComponent.svelte). "contact_label_name", "contact_placeholder_name", "contact_label_email", "contact_placeholder_email", "contact_label_subject", "contact_placeholder_subject", "contact_label_message", "contact_placeholder_message", "contact_message_remaining", // {{n}} "contact_message_overflow", // {{n}} "contact_label_consent", "contact_privacy_note", "contact_submit", "contact_submitting", "contact_success_title", "contact_success_body", "contact_fix_errors", "contact_server_error_generic", "contact_server_error_network", "contact_server_error_blocked", "contact_server_error_throttle", "contact_server_error_validation", // Per-field validation errors (params: n). "contact_error_name_min", // {{n}} "contact_error_name_max", // {{n}} "contact_error_name_no_links", "contact_error_name_invalid_chars", "contact_error_email_required", "contact_error_email_max", // {{n}} "contact_error_email_invalid", "contact_error_subject_max", // {{n}} "contact_error_subject_invalid_chars", "contact_error_message_min", // {{n}} "contact_error_message_max", // {{n}} "contact_error_message_too_many_links", // {{n}} "contact_error_message_invalid_chars", "contact_error_consent_required", "contact_captcha_pending", "contact_server_error_captcha", ] as const; export type TranslationKey = (typeof TRANSLATION_KEYS)[number]; /** Keys als Objekt: T.searchable_text_help → "searchable_text_help" (für t(T.xy)). */ export const T: { [K in TranslationKey]: K } = Object.fromEntries( TRANSLATION_KEYS.map((k) => [k, k]), ) as { [K in TranslationKey]: K }; /** * Gibt den übersetzten Text für key zurück, optional mit Ersetzung von {{placeholder}}. * Fallback: key selbst (damit fehlende Einträge im CMS erkennbar sind). */ export function t( translations: Translations | null | undefined, key: string, replacements?: TranslationReplacements, ): string { if (!key) return key; const raw = translations?.[key] ?? key; return replacePlaceholders(raw, replacements); }