diff --git a/README.md b/README.md
index 30e754e4..5b3d6de3 100644
--- a/README.md
+++ b/README.md
@@ -175,7 +175,7 @@ WEBMENTION_SENDER_MOUNT_PATH=/webmention-sender
# WEBMENTION_SENDER_ENDPOINT=http://127.0.0.1:3000/webmention-sender
```
-- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-production-security.mjs`, `scripts/preflight-mongo-connection.mjs`, `scripts/preflight-activitypub-rsa-key.mjs`, `scripts/preflight-activitypub-profile-urls.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-media-sharp-runtime.mjs`, `scripts/patch-frontend-sharp-runtime.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-endpoint-activitypub-locales.mjs`, `scripts/patch-endpoint-activitypub-docloader-loglevel.mjs`, `scripts/patch-endpoint-activitypub-private-url-docloader.mjs`, `scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs`, `scripts/patch-endpoint-homepage-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-conversations-collection-guards.mjs`, `scripts/patch-indiekit-routes-rate-limits.mjs`, `scripts/patch-indiekit-error-production-stack.mjs`, `scripts/patch-indieauth-devmode-guard.mjs`, `scripts/patch-listening-endpoint-runtime-guards.mjs`).
+- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-production-security.mjs`, `scripts/preflight-mongo-connection.mjs`, `scripts/preflight-activitypub-rsa-key.mjs`, `scripts/preflight-activitypub-profile-urls.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-media-sharp-runtime.mjs`, `scripts/patch-frontend-sharp-runtime.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-endpoint-activitypub-locales.mjs`, `scripts/patch-endpoint-activitypub-docloader-loglevel.mjs`, `scripts/patch-endpoint-activitypub-private-url-docloader.mjs`, `scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs`, `scripts/patch-endpoint-homepage-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-endpoint-comments-locales.mjs`, `scripts/patch-conversations-collection-guards.mjs`, `scripts/patch-indiekit-routes-rate-limits.mjs`, `scripts/patch-indiekit-error-production-stack.mjs`, `scripts/patch-indieauth-devmode-guard.mjs`, `scripts/patch-listening-endpoint-runtime-guards.mjs`).
- The production security preflight blocks startup on insecure auth/session configuration and catches empty-password bcrypt hashes.
- One-time recovery mode is available with `INDIEKIT_ALLOW_PASSWORD_SETUP=1` to bootstrap/reset `PASSWORD_SECRET` when locked out. Remove this flag after setting a valid hash.
- The media scope patch fixes a known upstream issue where file uploads can fail if the token scope is `create update delete` without explicit `media`.
@@ -186,6 +186,7 @@ WEBMENTION_SENDER_MOUNT_PATH=/webmention-sender
- The files upload route patch fixes browser multi-upload by posting to `/files/upload` (session-authenticated) instead of direct `/media` calls without bearer token.
- The files upload locale patch adds missing `files.upload.dropText`/`files.upload.browse`/`files.upload.submitMultiple` labels in endpoint locale files so UI text does not render raw translation keys.
- The ActivityPub locale patch backfills missing `de` locale keys from the endpoint's `en` locale and applies German admin title labels for notifications/profile.
+- The comments locale patch backfills missing comments endpoint locale files, adds translations for de/es/fr/nl/pt/sv, and localizes dashboard labels that were hardcoded in the comments template.
- The frontend serviceworker patch ensures `@indiekit/frontend/lib/serviceworker.js` exists at runtime, forces network-only handling for `/auth` and `/session` pages, patches frontend layout templates to unregister stale service workers and clear caches on load, and suppresses sidebar rendering whenever `app--minimalui` is present.
- The conversations guard patch prevents `Cannot read properties of undefined (reading 'find')` when the `conversation_items` collection is temporarily unavailable.
- The indiekit routes rate-limit patch (ported from `rmdes/indiekit-cloudron`) keeps strict limits on `/session/*`, applies generous limits to public API/well-known routes, and removes extra rate limiting from authenticated routes to avoid admin-side 429 spikes.
diff --git a/package.json b/package.json
index ba9e5db6..3c050b00 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
- "postinstall": "node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs",
- "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs",
+ "postinstall": "node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs",
+ "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-activitypub-private-url-docloader.mjs && node scripts/patch-endpoint-activitypub-migrate-alias-clear.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
diff --git a/scripts/patch-endpoint-comments-locales.mjs b/scripts/patch-endpoint-comments-locales.mjs
new file mode 100644
index 00000000..b997597c
--- /dev/null
+++ b/scripts/patch-endpoint-comments-locales.mjs
@@ -0,0 +1,476 @@
+import { access, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+const endpointCandidates = [
+ "node_modules/@rmdes/indiekit-endpoint-comments",
+ "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-comments",
+];
+
+const sourceLocale = "en";
+const targetLocales = [
+ "de",
+ "es",
+ "fr",
+ "nl",
+ "pt",
+ "sv",
+ "es-419",
+ "pt-BR",
+ "hi",
+ "id",
+ "it",
+ "pl",
+ "sr",
+ "zh-Hans-CN",
+];
+
+const localeAliases = {
+ "es-419": "es",
+ "pt-BR": "pt",
+};
+
+const sourceOverrides = {
+ comments: {
+ dashboard: {
+ hiddenBadge: "Hidden",
+ targetPrefix: "on:",
+ paginationLabel: "Pagination",
+ previous: "Previous",
+ page: "Page",
+ of: "of",
+ next: "Next",
+ },
+ },
+};
+
+const localeOverrides = {
+ de: {
+ comments: {
+ title: "Kommentare",
+ dashboard: {
+ stats: "Statistiken",
+ totalComments: "Kommentare gesamt",
+ thisWeek: "Diese Woche",
+ uniqueCommenters: "Einzigartige Kommentierende",
+ hiddenComments: "Versteckte Kommentare",
+ commentList: "Kommentarliste",
+ filterAll: "Alle",
+ filterPublic: "Oeffentlich",
+ filterHidden: "Versteckt",
+ hide: "Verstecken",
+ purge: "Endgueltig loeschen",
+ purgeConfirm: "Sicher?",
+ restore: "Wiederherstellen",
+ noComments: "Noch keine Kommentare.",
+ recentActivity: "Letzte Aktivitaet",
+ commentPosted: "Kommentar veroeffentlicht",
+ commentHidden: "Kommentar versteckt",
+ commentPurged: "Kommentar geloescht",
+ commentRestored: "Kommentar wiederhergestellt",
+ hiddenBadge: "Versteckt",
+ targetPrefix: "zu:",
+ paginationLabel: "Seitennavigation",
+ previous: "Zurueck",
+ page: "Seite",
+ of: "von",
+ next: "Weiter",
+ },
+ api: {
+ rateLimited: "Zu viele Kommentare. Bitte spaeter erneut versuchen.",
+ authRequired: "Bitte anmelden, um zu kommentieren.",
+ commentTooLong: "Kommentar ist zu lang.",
+ commentEmpty: "Kommentar darf nicht leer sein.",
+ posted: "Kommentar erfolgreich veroeffentlicht.",
+ },
+ },
+ },
+ es: {
+ comments: {
+ title: "Comentarios",
+ dashboard: {
+ stats: "Estadisticas",
+ totalComments: "Comentarios totales",
+ thisWeek: "Esta semana",
+ uniqueCommenters: "Comentaristas unicos",
+ hiddenComments: "Comentarios ocultos",
+ commentList: "Lista de comentarios",
+ filterAll: "Todos",
+ filterPublic: "Publicos",
+ filterHidden: "Ocultos",
+ hide: "Ocultar",
+ purge: "Eliminar definitivamente",
+ purgeConfirm: "Seguro?",
+ restore: "Restaurar",
+ noComments: "Aun no hay comentarios.",
+ recentActivity: "Actividad reciente",
+ commentPosted: "Comentario publicado",
+ commentHidden: "Comentario ocultado",
+ commentPurged: "Comentario eliminado",
+ commentRestored: "Comentario restaurado",
+ hiddenBadge: "Oculto",
+ targetPrefix: "en:",
+ paginationLabel: "Paginacion",
+ previous: "Anterior",
+ page: "Pagina",
+ of: "de",
+ next: "Siguiente",
+ },
+ api: {
+ rateLimited: "Demasiados comentarios. Intentalo mas tarde.",
+ authRequired: "Inicia sesion para comentar.",
+ commentTooLong: "El comentario supera la longitud maxima.",
+ commentEmpty: "El comentario no puede estar vacio.",
+ posted: "Comentario publicado correctamente.",
+ },
+ },
+ },
+ fr: {
+ comments: {
+ title: "Commentaires",
+ dashboard: {
+ stats: "Statistiques",
+ totalComments: "Total des commentaires",
+ thisWeek: "Cette semaine",
+ uniqueCommenters: "Commentateurs uniques",
+ hiddenComments: "Commentaires masques",
+ commentList: "Liste des commentaires",
+ filterAll: "Tous",
+ filterPublic: "Publics",
+ filterHidden: "Masques",
+ hide: "Masquer",
+ purge: "Supprimer definitivement",
+ purgeConfirm: "Confirmer?",
+ restore: "Restaurer",
+ noComments: "Aucun commentaire pour le moment.",
+ recentActivity: "Activite recente",
+ commentPosted: "Commentaire publie",
+ commentHidden: "Commentaire masque",
+ commentPurged: "Commentaire supprime",
+ commentRestored: "Commentaire restaure",
+ hiddenBadge: "Masque",
+ targetPrefix: "sur:",
+ paginationLabel: "Pagination",
+ previous: "Precedent",
+ page: "Page",
+ of: "de",
+ next: "Suivant",
+ },
+ api: {
+ rateLimited: "Trop de commentaires. Reessayez plus tard.",
+ authRequired: "Connectez-vous pour commenter.",
+ commentTooLong: "Le commentaire depasse la longueur maximale.",
+ commentEmpty: "Le commentaire ne peut pas etre vide.",
+ posted: "Commentaire publie avec succes.",
+ },
+ },
+ },
+ nl: {
+ comments: {
+ title: "Reacties",
+ dashboard: {
+ stats: "Statistieken",
+ totalComments: "Totaal reacties",
+ thisWeek: "Deze week",
+ uniqueCommenters: "Unieke reageerders",
+ hiddenComments: "Verborgen reacties",
+ commentList: "Reactielijst",
+ filterAll: "Alles",
+ filterPublic: "Openbaar",
+ filterHidden: "Verborgen",
+ hide: "Verbergen",
+ purge: "Definitief verwijderen",
+ purgeConfirm: "Weet je het zeker?",
+ restore: "Herstellen",
+ noComments: "Nog geen reacties.",
+ recentActivity: "Recente activiteit",
+ commentPosted: "Reactie geplaatst",
+ commentHidden: "Reactie verborgen",
+ commentPurged: "Reactie verwijderd",
+ commentRestored: "Reactie hersteld",
+ hiddenBadge: "Verborgen",
+ targetPrefix: "op:",
+ paginationLabel: "Paginatie",
+ previous: "Vorige",
+ page: "Pagina",
+ of: "van",
+ next: "Volgende",
+ },
+ api: {
+ rateLimited: "Te veel reacties. Probeer later opnieuw.",
+ authRequired: "Meld je aan om te reageren.",
+ commentTooLong: "Reactie is te lang.",
+ commentEmpty: "Reactie mag niet leeg zijn.",
+ posted: "Reactie succesvol geplaatst.",
+ },
+ },
+ },
+ pt: {
+ comments: {
+ title: "Comentarios",
+ dashboard: {
+ stats: "Estatisticas",
+ totalComments: "Total de comentarios",
+ thisWeek: "Esta semana",
+ uniqueCommenters: "Comentadores unicos",
+ hiddenComments: "Comentarios ocultos",
+ commentList: "Lista de comentarios",
+ filterAll: "Todos",
+ filterPublic: "Publicos",
+ filterHidden: "Ocultos",
+ hide: "Ocultar",
+ purge: "Excluir permanentemente",
+ purgeConfirm: "Tem certeza?",
+ restore: "Restaurar",
+ noComments: "Ainda nao ha comentarios.",
+ recentActivity: "Atividade recente",
+ commentPosted: "Comentario publicado",
+ commentHidden: "Comentario ocultado",
+ commentPurged: "Comentario removido",
+ commentRestored: "Comentario restaurado",
+ hiddenBadge: "Oculto",
+ targetPrefix: "em:",
+ paginationLabel: "Paginacao",
+ previous: "Anterior",
+ page: "Pagina",
+ of: "de",
+ next: "Proximo",
+ },
+ api: {
+ rateLimited: "Muitos comentarios. Tente novamente mais tarde.",
+ authRequired: "Faca login para comentar.",
+ commentTooLong: "O comentario excede o tamanho maximo.",
+ commentEmpty: "O comentario nao pode estar vazio.",
+ posted: "Comentario publicado com sucesso.",
+ },
+ },
+ },
+ sv: {
+ comments: {
+ title: "Kommentarer",
+ dashboard: {
+ stats: "Statistik",
+ totalComments: "Totalt antal kommentarer",
+ thisWeek: "Denna vecka",
+ uniqueCommenters: "Unika kommentatorer",
+ hiddenComments: "Dolda kommentarer",
+ commentList: "Kommentarslista",
+ filterAll: "Alla",
+ filterPublic: "Offentliga",
+ filterHidden: "Dolda",
+ hide: "Dolj",
+ purge: "Radera permanent",
+ purgeConfirm: "Ar du saker?",
+ restore: "Aterstall",
+ noComments: "Inga kommentarer annu.",
+ recentActivity: "Senaste aktivitet",
+ commentPosted: "Kommentar publicerad",
+ commentHidden: "Kommentar dold",
+ commentPurged: "Kommentar raderad",
+ commentRestored: "Kommentar aterstalld",
+ hiddenBadge: "Dold",
+ targetPrefix: "pa:",
+ paginationLabel: "Sidindelning",
+ previous: "Foregaende",
+ page: "Sida",
+ of: "av",
+ next: "Nasta",
+ },
+ api: {
+ rateLimited: "For manga kommentarer. Forsok igen senare.",
+ authRequired: "Logga in for att kommentera.",
+ commentTooLong: "Kommentaren overskrider maximal langd.",
+ commentEmpty: "Kommentaren kan inte vara tom.",
+ posted: "Kommentar publicerad.",
+ },
+ },
+ },
+};
+
+const viewReplacements = [
+ {
+ oldSnippet: 'Hidden',
+ newSnippet:
+ '{{ __("comments.dashboard.hiddenBadge") if __ else "Hidden" }}',
+ },
+ {
+ oldSnippet: " on: {{ comment.target }}",
+ newSnippet:
+ ' {{ __("comments.dashboard.targetPrefix") if __ else "on:" }} {{ comment.target }}',
+ },
+ {
+ oldSnippet: ` {% if totalPages > 1 %}
+
+ {% endif %}`,
+ newSnippet: ` {% if totalPages > 1 %}
+
+ {% endif %}`,
+ },
+];
+
+function isObject(value) {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function mergeMissing(target, fallback) {
+ if (target === undefined) {
+ return fallback;
+ }
+
+ if (!isObject(target) || !isObject(fallback)) {
+ return target;
+ }
+
+ const merged = { ...target };
+
+ for (const [key, fallbackValue] of Object.entries(fallback)) {
+ merged[key] = mergeMissing(merged[key], fallbackValue);
+ }
+
+ return merged;
+}
+
+function applyOverrides(target, overrides) {
+ if (!isObject(target) || !isObject(overrides)) {
+ return target;
+ }
+
+ const merged = { ...target };
+
+ for (const [key, value] of Object.entries(overrides)) {
+ if (isObject(value)) {
+ const existing = isObject(merged[key]) ? merged[key] : {};
+ merged[key] = applyOverrides(existing, value);
+ continue;
+ }
+
+ merged[key] = value;
+ }
+
+ return merged;
+}
+
+async function exists(filePath) {
+ try {
+ await access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+let checkedEndpoints = 0;
+let checkedLocales = 0;
+let patchedLocales = 0;
+let checkedTemplates = 0;
+let patchedTemplates = 0;
+
+for (const endpointPath of endpointCandidates) {
+ if (!(await exists(endpointPath))) {
+ continue;
+ }
+
+ checkedEndpoints += 1;
+
+ const sourcePath = path.join(endpointPath, "locales", `${sourceLocale}.json`);
+
+ if (!(await exists(sourcePath))) {
+ continue;
+ }
+
+ let sourceLocaleJson;
+ try {
+ sourceLocaleJson = JSON.parse(await readFile(sourcePath, "utf8"));
+ } catch {
+ continue;
+ }
+
+ const sourcePatched = applyOverrides(sourceLocaleJson, sourceOverrides);
+ checkedLocales += 1;
+
+ if (JSON.stringify(sourcePatched) !== JSON.stringify(sourceLocaleJson)) {
+ await writeFile(sourcePath, `${JSON.stringify(sourcePatched, null, 2)}\n`, "utf8");
+ patchedLocales += 1;
+ }
+
+ for (const locale of targetLocales) {
+ const localePath = path.join(endpointPath, "locales", `${locale}.json`);
+ checkedLocales += 1;
+
+ let localeJson = {};
+ if (await exists(localePath)) {
+ try {
+ localeJson = JSON.parse(await readFile(localePath, "utf8"));
+ } catch {
+ localeJson = {};
+ }
+ }
+
+ const merged = mergeMissing(localeJson, sourcePatched);
+ const overrideKey = localeAliases[locale] || locale;
+ const patched = applyOverrides(merged, localeOverrides[overrideKey] || {});
+
+ if (JSON.stringify(patched) === JSON.stringify(localeJson)) {
+ continue;
+ }
+
+ await writeFile(localePath, `${JSON.stringify(patched, null, 2)}\n`, "utf8");
+ patchedLocales += 1;
+ }
+
+ const viewPath = path.join(endpointPath, "views", "comments.njk");
+ if (!(await exists(viewPath))) {
+ continue;
+ }
+
+ checkedTemplates += 1;
+
+ const viewSource = await readFile(viewPath, "utf8");
+ let viewUpdated = viewSource;
+ let templateChanged = false;
+
+ for (const replacement of viewReplacements) {
+ if (viewUpdated.includes(replacement.newSnippet)) {
+ continue;
+ }
+
+ if (!viewUpdated.includes(replacement.oldSnippet)) {
+ continue;
+ }
+
+ viewUpdated = viewUpdated.replace(replacement.oldSnippet, replacement.newSnippet);
+ templateChanged = true;
+ }
+
+ if (!templateChanged) {
+ continue;
+ }
+
+ await writeFile(viewPath, viewUpdated, "utf8");
+ patchedTemplates += 1;
+}
+
+if (checkedEndpoints === 0) {
+ console.log("[postinstall] No comments endpoint directories found");
+} else if (patchedLocales === 0 && patchedTemplates === 0) {
+ console.log("[postinstall] comments locales and templates already patched");
+} else {
+ console.log(
+ `[postinstall] Patched comments locales in ${patchedLocales}/${checkedLocales} file(s) and templates in ${patchedTemplates}/${checkedTemplates} file(s)`,
+ );
+}