b4581e4d4f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
294 lines
8.7 KiB
TypeScript
294 lines
8.7 KiB
TypeScript
/**
|
||
* 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<string, string>;
|
||
|
||
/** 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<string, string | number>;
|
||
|
||
/** 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<TFunction | undefined>(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_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",
|
||
] 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);
|
||
}
|