Files
windwiderstand/src/lib/translations.ts
T
Peter Meier d03afe780e
Deploy / verify (push) Failing after 44s
Deploy / deploy (push) Has been skipped
feat(contact): add Cloudflare Turnstile captcha
Client renders explicit Turnstile widget (flexible, light theme) when
PUBLIC_TURNSTILE_SITE_KEY is set. Token sent with form payload, server
verifies via siteverify endpoint before forwarding to CMS. Skipped
entirely when no site key configured (dev fallback). New i18n keys:
contact_captcha_pending, contact_server_error_captcha. Deploy workflow
forwards PUBLIC_TURNSTILE_SITE_KEY + TURNSTILE_SECRET.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:37:09 +02:00

341 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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_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);
}