Files
windwiderstand/src/lib/translations.ts
T
Peter Meier af90483dc4
Deploy / verify (push) Successful in 1m18s
Deploy / deploy (push) Successful in 1m34s
feat(newsletter): newsletter signup as internal component
Wire a newsletter signup form into the CMS internal-component pattern
(form-newsletter). Posts to /api/newsletter which subscribes to listmonk
(double opt-in) and mirrors a best-effort backup record to the RustyCMS
forms plugin. Reuses the contact-form anti-spam stack: Cloudflare
Turnstile, honeypots, client-age check, origin validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:02:48 +02:00

394 lines
12 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_anlagen_hinweis",
"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",
// Mitmachen form (MitmachenFormComponent.svelte). Generische Submit-/
// Server-Error-Texte werden aus contact_* wiederverwendet.
"mitmachen_label_title",
"mitmachen_placeholder_title",
"mitmachen_label_description",
"mitmachen_placeholder_description",
"mitmachen_label_contact_name",
"mitmachen_placeholder_contact_name",
"mitmachen_label_phone",
"mitmachen_placeholder_phone",
"mitmachen_label_email",
"mitmachen_placeholder_email",
"mitmachen_label_files",
"mitmachen_files_help", // {{n}}, {{mb}}
"mitmachen_files_add",
"mitmachen_files_remove",
"mitmachen_label_consent",
"mitmachen_privacy_note",
"mitmachen_submit",
"mitmachen_success_title",
"mitmachen_success_body",
"mitmachen_error_title_min", // {{n}}
"mitmachen_error_title_max", // {{n}}
"mitmachen_error_title_invalid",
"mitmachen_error_description_min", // {{n}}
"mitmachen_error_description_max", // {{n}}
"mitmachen_error_description_too_many_links", // {{n}}
"mitmachen_error_description_invalid",
"mitmachen_error_contact_name_max", // {{n}}
"mitmachen_error_contact_name_invalid",
"mitmachen_error_phone_invalid",
"mitmachen_error_email_invalid",
"mitmachen_error_consent_required",
"mitmachen_error_files_count", // {{n}}
"mitmachen_error_files_size", // {{mb}}
"mitmachen_error_files_type",
// Newsletter form (NewsletterFormComponent.svelte). Generische Submit-/
// Server-Error-Texte werden aus contact_* wiederverwendet.
"newsletter_label_name",
"newsletter_placeholder_name",
"newsletter_label_email",
"newsletter_placeholder_email",
"newsletter_label_consent",
"newsletter_privacy_note",
"newsletter_submit",
"newsletter_submitting",
"newsletter_success_title",
"newsletter_success_body",
"newsletter_error_email_required",
"newsletter_error_email_invalid",
"newsletter_error_email_max", // {{n}}
"newsletter_error_consent_required",
] 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);
}