#i18n

143 messages · Page 1 of 1 (latest)

lilac cometBOT
#

Hi @lusty swift. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:

#

Мультиязычность с динамическим роутом в Astro

Вы можете реализовать мультиязычность через встроенный i18n-роутинг Astro или вручную через динамические маршруты. Ниже — оба подхода.


Вариант 1: Встроенный i18n Astro (рекомендуется)

1. Настройка astro.config.mjs

import { defineConfig } from "astro/config";

export default defineConfig({
  i18n: {
    locales: ["ru", "en"],
    defaultLocale: "ru",
    routing: {
      prefixDefaultLocale: false, // /about/ для ru, /en/about/ для en
    }
  }
});

При prefixDefaultLocale: false (по умолчанию):

  • src/pages/index.astroexample.com/
  • src/pages/en/index.astroexample.com/en/

[i18n routing config]

2. Структура файлов

src/pages/
  index.astro       ← русский (по умолчанию)
  about.astro
  en/
    index.astro     ← английский
    about.astro

Вариант 2: Динамический роут [lang]/index.astro

Если вы хотите именно динамический роут pages/[lang]/index.astro:

1. Создайте файл src/pages/[lang]/index.astro

---
export function getStaticPaths() {
  return [
    { params: { lang: "ru" } },
    { params: { lang: "en" } },
  ];
}

const { lang } = Astro.params;
---
<html lang={lang}>
  <body>
    <h1>{lang === "ru" ? "Привет!" : "Hello!"}</h1>
  </body>
</html>

Это сгенерирует маршруты /ru/ и /en/. [static SSG mode]

2. Добавьте редирект с корня на язык по умолчанию

src/pages/index.astro:

<meta http-equiv="refresh" content="0;url=/ru/" />

[i18n recipe]

3. Вспомогательные функции для переводов

#

src/i18n/ui.ts:

export const languages = { ru: 'Русский', en: 'English' };
export const defaultLang = 'ru';

export const ui = {
  ru: {
    'nav.home': 'Главная',
    'nav.about': 'О нас',
  },
  en: {
    'nav.home': 'Home',
    'nav.about': 'About',
  },
} as const;

src/i18n/utils.ts:

import { ui, defaultLang } from './ui';

export function getLangFromUrl(url: URL) {
  const [, lang] = url.pathname.split('/');
  if (lang in ui) return lang as keyof typeof ui;
  return defaultLang;
}

export function useTranslations(lang: keyof typeof ui) {
  return function t(key: keyof typeof ui[typeof defaultLang]) {
    return ui[lang][key] || ui[defaultLang][key];
  }
}

[translate UI strings]


Итог: Если нужен именно [lang]/index.astro — используйте Вариант 2 с getStaticPaths(). Если хотите, чтобы русский был без префикса (/ вместо /ru/), лучше использовать Вариант 1 со встроенным i18n и prefixDefaultLocale: false.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

уточню. у меня серверный рендеринг

#

@lilac comet уточню. у меня серверный рендеринг

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

При серверном рендеринге (SSR) getStaticPaths() не используется — маршруты обрабатываются динамически на лету.

Динамический роут [lang]/index.astro в SSR-режиме

1. src/pages/[lang]/index.astro

---
export const prerender = false; // не нужно, если output: 'server' глобально

const { lang } = Astro.params;

// Поддерживаемые локали
const supportedLangs = ["ru", "en"];

if (!supportedLangs.includes(lang)) {
  return Astro.redirect("/ru/");
}
---
<html lang={lang}>
  <body>
    <h1>{lang === "ru" ? "Привет!" : "Hello!"}</h1>
  </body>
</html>

В SSR-режиме страница будет отдаваться для любого значения lang — поэтому нужно вручную проверять допустимые локали и редиректить на 404 или дефолтный язык. [on-demand dynamic routes]

2. Редирект с корня

src/pages/index.astro:

---
return Astro.redirect('/ru/');
---

[i18n recipe]

3. Встроенный i18n Astro для SSR

Если хотите использовать встроенный i18n Astro, он также поддерживает SSR. Пример конфига:

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  output: "server",
  i18n: {
    locales: ["ru", "en"],
    defaultLocale: "ru",
    routing: {
      prefixDefaultLocale: false, // /about/ для ru, /en/about/ для en
    }
  }
});

[i18n routing config]


#

Ключевое отличие SSR: не нужен getStaticPaths(), но необходимо самостоятельно валидировать параметры и обрабатывать несуществующие маршруты (редирект или 404). [on-demand dynamic routes]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Проблема скорее всего в том, что при использовании встроенного i18n Astro с prefixDefaultLocale: false файлы для дефолтной локали (ru) должны находиться в корне src/pages/, а не в папке src/pages/ru/.

Возможные причины 404

Причина 1: Конфликт i18n-конфига и структуры файлов

Если у вас настроен встроенный i18n с prefixDefaultLocale: false, то:

  • src/pages/ru/index.astroне будет работать для дефолтной локали
  • src/pages/index.astro → правильный путь для example.com/

[prefixDefaultLocale]

Причина 2: Если вы хотите именно /ru/ в URL

Тогда нужно prefixDefaultLocale: true:

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  output: "server",
  i18n: {
    locales: ["ru", "en"],
    defaultLocale: "ru",
    routing: {
      prefixDefaultLocale: true, // /ru/ и /en/ для всех
      redirectToDefaultLocale: true // / → /ru/
    }
  }
});

При этом структура файлов должна быть:

src/pages/
  index.astro       ← обязателен (редиректит на /ru/)
  ru/
    index.astro
  en/
    index.astro

[prefixDefaultLocale true]

Причина 3: Чисто динамический роут без i18n-конфига

Если вы используете src/pages/[lang]/index.astro без встроенного i18n, убедитесь, что в astro.config.mjs нет блока i18n — иначе встроенный middleware может перехватывать запросы и возвращать 404 для /ru/, если ru не совпадает с ожидаемой структурой.


#

Рекомендация: уточните, какой из вариантов вы используете — встроенный i18n или чистый динамический роут [lang]? И покажите текущий astro.config.mjs и структуру src/pages/ — это поможет точнее определить причину.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet чистый динамический роут для серверного реднеринга

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Для чистого динамического роута в SSR без встроенного i18n — убедитесь, что в astro.config.mjs нет блока i18n вообще, иначе middleware будет мешать.

Минимальная рабочая конфигурация

astro.config.mjs

import { defineConfig } from "astro/config";

export default defineConfig({
  output: "server",
  // НЕТ блока i18n
});

src/pages/[lang]/index.astro

---
export const prerender = false;

const { lang } = Astro.params;
const supportedLangs = ["ru", "en"];

if (!supportedLangs.includes(lang)) {
  return Astro.redirect("/ru/");
}
---
<html lang={lang}>
  <body>
    <h1>{lang === "ru" ? "Привет!" : "Hello!"}</h1>
  </body>
</html>

В SSR-режиме страница отдаётся для любого значения lang без getStaticPaths() — поэтому валидацию делаете вручную. [on-demand dynamic routes]

src/pages/index.astro (редирект с корня)

---
return Astro.redirect('/ru/');
---

[i18n recipe]


Ключевой момент: при наличии блока i18n в конфиге Astro подключает свой middleware, который проверяет локали и может возвращать 404 для /ru/, если структура не совпадает с его ожиданиями. Без блока i18n — роут [lang] работает как обычный динамический маршрут SSR.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet работает, но мне не нравится отображение дефолтной локали в адресной строке

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Если вы хотите, чтобы дефолтная локаль (ru) не отображалась в URL (т.е. / вместо /ru/), при чистом динамическом роуте без встроенного i18n это нужно реализовать вручную.

Вариант: скрыть дефолтную локаль в URL

Структура файлов

src/pages/
  index.astro          ← страница для ru (без префикса)
  about.astro          ← другие страницы для ru
  en/
    index.astro        ← страница для en
    about.astro

Либо, если хотите сохранить динамический роут только для не-дефолтных локалей:

src/pages/
  index.astro          ← русский контент напрямую
  [lang]/
    index.astro        ← только для en, fr и т.д.

src/pages/[lang]/index.astro

---
export const prerender = false;

const { lang } = Astro.params;
const supportedLangs = ["en"]; // ru здесь не нужен — он обслуживается корневым index.astro

if (!supportedLangs.includes(lang)) {
  return Astro.redirect("/");
}
---
<html lang={lang}>
  <body>
    <h1>Hello!</h1>
  </body>
</html>

src/pages/index.astro

---
export const prerender = false;
---
<html lang="ru">
  <body>
    <h1>Привет!</h1>
  </body>
</html>

Таким образом:

  • / → русский контент (без префикса)
  • /en/ → английский контент

Это соответствует подходу «скрыть дефолтный язык в URL», описанному в рецептах Astro. [hide default language]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet давай попробуем встроенные средства i18n. динамический роутинг. серверный рендеринг. ru en

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Хорошо, давайте используем встроенный i18n Astro с серверным рендерингом и локалями ru (по умолчанию) и en.

Встроенный i18n Astro + SSR

1. astro.config.mjs

import { defineConfig } from "astro/config";

export default defineConfig({
  output: "server",
  i18n: {
    locales: ["ru", "en"],
    defaultLocale: "ru",
    routing: {
      prefixDefaultLocale: true,       // /ru/ и /en/ для всех
      redirectToDefaultLocale: true    // / → /ru/
    }
  }
});

[prefixDefaultLocale true] [redirectToDefaultLocale]

2. Структура файлов

src/pages/
  index.astro       ← обязателен (редиректит на /ru/ автоматически)
  ru/
    index.astro
    about.astro
  en/
    index.astro
    about.astro

Важно: при prefixDefaultLocale: true файлы дефолтной локали тоже должны лежать в папке ru/. [prefixDefaultLocale true]

3. Пример страницы src/pages/ru/index.astro

---
import { getRelativeLocaleUrl } from 'astro:i18n';
---
<html lang="ru">
  <body>
    <h1>Привет!</h1>
    <a href={getRelativeLocaleUrl('en', '')}>English</a>
  </body>
</html>

[getRelativeLocaleUrl]

4. Фолбэк (опционально)

Если страница не переведена на en, можно настроить фолбэк на ru:

i18n: {
  locales: ["ru", "en"],
  defaultLocale: "ru",
  routing: {
    prefixDefaultLocale: true,
    redirectToDefaultLocale: true,
    fallbackType: "rewrite" // показывает контент ru без редиректа
  },
  fallback: {
    en: "ru"
  }
}
#

[fallbackType]


Итог:

  • / → редирект на /ru/
  • /ru/ → русский контент
  • /en/ → английский контент

Встроенный middleware Astro сам обрабатывает валидацию локалей и редиректы — вручную ничего проверять не нужно.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lusty swift динамический роутинг!!!

#

@lilac comet динамический роутинг!!!

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Уточните, что именно вы имеете в виду под «динамическим роутингом» в контексте встроенного i18n Astro?

Встроенный i18n Astro не использует динамический роут [lang] — он работает на основе файловой структуры папок (ru/, en/). Это принципиальное отличие.

[i18n routing]


Если вам нужен именно динамический роут [lang] с SSR и без отображения дефолтной локали в URL — оптимальным решением остаётся чистый динамический роут без блока i18n, как мы разбирали ранее:

  • / → русский контент (корневой index.astro)
  • /en/ → английский контент ([lang]/index.astro)

Встроенный i18n Astro и динамический роут [lang]несовместимые подходы. Встроенный i18n отключает возможность использовать [lang] как динамический параметр, так как его middleware перехватывает запросы и проверяет локали по файловой структуре.

Какой именно результат вы хотите получить? Уточните — и я помогу выбрать правильный подход.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet понял. значит без i18n. как теперь формировать урлы?

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Без встроенного i18n и без хелперов astro:i18n URL-ы формируете вручную.

Формирование URL-ов без встроенного i18n

Создайте вспомогательные функции для генерации путей:

src/i18n/utils.ts

export const defaultLang = 'ru';
export const supportedLangs = ['ru', 'en'] as const;
export type Lang = typeof supportedLangs[number];

export function getLangFromUrl(url: URL): Lang {
  const [, lang] = url.pathname.split('/');
  if (supportedLangs.includes(lang as Lang)) return lang as Lang;
  return defaultLang;
}

// Формирует URL с учётом языка
// ru → / (без префикса), en → /en/
export function useTranslatedPath(lang: Lang) {
  return function translatePath(path: string) {
    return lang === defaultLang ? path : `/${lang}${path}`;
  }
}

[hide default language]

Использование в компоненте

---
import { getLangFromUrl, useTranslatedPath } from '../i18n/utils';

const lang = getLangFromUrl(Astro.url);
const translatePath = useTranslatedPath(lang);
---
<nav>
  <a href={translatePath('/')}>Главная</a>
  <a href={translatePath('/about/')}>О нас</a>
</nav>

Переключатель языков

---
import { getLangFromUrl, useTranslatedPath } from '../i18n/utils';

const lang = getLangFromUrl(Astro.url);
---
<ul>
  <li><a href="/">RU</a></li>
  <li><a href="/en/">EN</a></li>
</ul>

[translate routes]


Итог:

  • / → русский (без префикса)
  • /en/ → английский
  • Все ссылки формируете через translatePath(), которая автоматически добавляет /en для английского и оставляет путь как есть для русского.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Проблема понятна. В вашей текущей реализации src/pages/[lang]/index.astro вы, скорее всего, указали supportedLangs = ["en"] (без "ru"), так как мы договорились, что ru обслуживается корневым index.astro. Но при этом у вас нет файла src/pages/[lang]/about.astro для русского, либо в нём ru не входит в список допустимых локалей.

Причина

Ваша структура сейчас, вероятно, такая:

src/pages/
  index.astro          ← ru главная
  about.astro          ← ru about (без префикса)
  [lang]/
    index.astro        ← en главная
    about.astro        ← en about

Запрос /ru/about попадает в [lang]/about.astro с lang = "ru", но там supportedLangs = ["en"] — и срабатывает редирект на /.

Решение

В файле src/pages/[lang]/about.astro (и всех остальных [lang]/...) добавьте "ru" в список допустимых, либо редиректьте /ru/... на соответствующий путь без префикса:

---
export const prerender = false;

const { lang } = Astro.params;
const supportedLangs = ["en"];

if (!supportedLangs.includes(lang)) {
  // Если кто-то зашёл на /ru/about — редиректим на /about
  const url = new URL(Astro.request.url);
  const pathWithoutLang = url.pathname.replace(`/${lang}`, '') || '/';
  return Astro.redirect(pathWithoutLang);
}
---
<html lang={lang}>
  <body>
    <h1>About (EN)</h1>
  </body>
</html>

Таким образом:

  • /ru/about → редирект на /about (корректная страница для дефолтной локали)
  • /en/about → отображается корректно
  • /de/about → редирект на /about (неизвестная локаль)

[hide default language]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet забыли про мультиязычность. поговорим про route caching
требуется инвалидация астро кеша при изменении любой записи в strapi

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Хорошая задача. Вот как реализовать инвалидацию кеша Astro при изменении записи в Strapi.

Общая схема

  1. Strapi отправляет webhook при изменении записи
  2. Astro API-роут принимает webhook и вызывает cache.invalidate()

1. Включите route caching в astro.config.mjs

import { defineConfig, memoryCache } from 'astro/config';

export default defineConfig({
  output: "server",
  experimental: {
    cache: {
      provider: memoryCache(),
    },
  },
});

[route caching]


2. Пометьте страницы тегами

Например, страница со списком записей Strapi:

---
export const prerender = false;

context.cache.set({
  maxAge: 300,
  tags: ['strapi-content'],
});
---

Или в API-роуте:

export function GET(context) {
  context.cache.set({
    maxAge: 300,
    tags: ['strapi-content'],
  });
  return Response.json({ ok: true });
}

[setting cache options]


3. Создайте API-роут для приёма webhook от Strapi

src/pages/api/strapi-webhook.ts:

export async function POST(context) {
  // Опционально: проверьте секрет из заголовка
  // const secret = context.request.headers.get('Authorization');
  // if (secret !== import.meta.env.STRAPI_WEBHOOK_SECRET) {
  //   return new Response('Unauthorized', { status: 401 });
  // }

  // Инвалидируем все записи с тегом 'strapi-content'
  await context.cache.invalidate({ tags: ['strapi-content'] });

  return Response.json({ invalidated: true });
}

[invalidation]


4. Настройте webhook в Strapi

#

В панели Strapi укажите URL вашего webhook-эндпоинта: <https://your-site.com/api/strapi-webhook.> [strapi webhooks]


Важные ограничения

  • cache.invalidate() требует настроенного cache provider — без него выбросит ошибку. [cache.enabled]
  • Встроенный memoryCache() подходит для single-instance деплоев. Для multi-instance (например, несколько серверов) потребуется кастомный провайдер (Redis и т.п.). [memory cache provider]
  • Это экспериментальная функция, добавленная в [email protected]. [route caching]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet как мне включить astro.sessions для node

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Для Node-адаптера сессии настраиваются очень просто — адаптер автоматически использует локальную файловую систему как хранилище, дополнительная конфигурация не требуется. [Node sessions]

Минимальная настройка

astro.config.mjs

import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({
    mode: 'standalone',
  }),
  // Блок session не обязателен — Node использует filesystem по умолчанию
});

Использование в странице

---
export const prerender = false; // не нужно при output: 'server'

const cart = await Astro.session?.get('cart');
---
<a href="/checkout">🛒 {cart?.length ?? 0} items</a>

Использование в API-роуте

export async function POST(context: APIContext) {
  const cart = await context.session?.get('cart') || [];
  const data = await context.request.json<{ item: string }>();
  cart.push(data.item);
  await context.session?.set('cart', cart);
  return Response.json(cart);
}

[sessions guide]


Если хотите использовать другой драйвер (например, Redis)

import { defineConfig, sessionDrivers } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  session: {
    driver: sessionDrivers.redis({
      url: process.env.REDIS_URL,
    }),
  },
});

[session driver config]


#

Итог: для Node достаточно просто добавить адаптер — сессии заработают автоматически с файловой системой. Требуется Astro 5.7.0+.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet как правильно реализовать добавление товара в корзину без перезагрузки страницы используя астро сессии и экшены?

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Вот как реализовать добавление товара в корзину без перезагрузки страницы, используя Astro Actions и Sessions.

1. Определите Action

src/actions/index.ts:

import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';

export const server = {
  addToCart: defineAction({
    input: z.object({ productId: z.string() }),
    handler: async (input, context) => {
      const cart = await context.session?.get('cart') ?? [];
      cart.push(input.productId);
      await context.session?.set('cart', cart);
      return cart;
    },
  }),
};

[actions sessions]

2. Отображение корзины на странице

src/components/CartButton.astro:

---
export const prerender = false;
const cart = await Astro.session?.get('cart');
---
<a href="/checkout">🛒 {cart?.length ?? 0} items</a>

[session in components]

3. Вызов Action без перезагрузки страницы

Используйте actions из astro:actions в клиентском <script>:

---
export const prerender = false;
const cart = await Astro.session?.get('cart');
---

<a href="/checkout">🛒 <span id="cart-count">{cart?.length ?? 0}</span> items</a>

<button id="add-to-cart" data-product-id="product-123">
  Добавить в корзину
</button>

<script>
import { actions } from 'astro:actions';

const button = document.querySelector('#add-to-cart');
button?.addEventListener('click', async () => {
  const productId = button.dataset.productId;
  const { data, error } = await actions.addToCart({ productId });

  if (!error) {
    // Обновляем счётчик без перезагрузки
    document.querySelector('#cart-count').textContent = data.length;
  }
});
</script>

[actions basic usage]


#

Ключевые моменты:

  • Action обращается к context.session на сервере — данные хранятся в сессии, а не в cookie.
  • Клиент вызывает Action через astro:actions — запрос уходит на сервер, страница не перезагружается.
  • Счётчик корзины обновляется через DOM на основе ответа data от Action.
  • Для Node-адаптера сессии работают автоматически без дополнительной конфигурации. [Node sessions]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet мы правда можем использовать astro actions на стороне клиента? пошутил?

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Да, это не шутка — Astro Actions можно и нужно вызывать на стороне клиента. Это одна из ключевых возможностей Actions.

Из документации:

Your actions are available as functions from the astro:actions module. Import actions and call them client-side within a UI framework component, a form POST request, or by using a <script> tag in an Astro component.

Пример из официальной документации — вызов Action в клиентском <script>:

---
---
<button>Get greeting</button>

<script>
import { actions } from 'astro:actions';

const button = document.querySelector('button');
button?.addEventListener('click', async () => {
  const { data, error } = await actions.getGreeting({ name: "Houston" });
  if (!error) alert(data);
})
</script>

[basic usage]

А также в React-компоненте:

import { actions } from 'astro:actions';
import { navigate } from 'astro:transitions/client';

export function LogoutButton() {
  return (
    <button onClick={async () => {
      const { error } = await actions.logout();
      if (!error) navigate('/');
    }}>
      Logout
    </button>
  );
}

[client redirects]

Actions автоматически генерируют типобезопасные функции для вызова с клиента — именно в этом их главное преимущество перед обычными API-эндпоинтами.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet
можно это както красиво упаковать в использование через alpinejs ?

<script>
import { actions } from 'astro:actions';

document.querySelectorAll('.add-to-cart').forEach(btn => {
  btn.addEventListener('click', async () => {
    const id = Number(btn.dataset.id);
    const name = btn.dataset.name;
    const price = Number(btn.dataset.price);

    const { data, error } = await actions.addToCart({ id, name, price });

    if (!error) {
      document.getElementById('cart-count').textContent = data;
    }
  });
});

document.getElementById('clear-cart')?.addEventListener('click', async () => {
  const { data, error } = await actions.clearCart();

  if (!error) {
    document.getElementById('cart-count').textContent = '0';
    location.reload();
  }
});

</script>

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Да, можно упаковать это в Alpine.js. Вот как это выглядит:

Установка Alpine.js

npx astro add alpinejs

[alpinejs integration]

Реализация через Alpine.js

---
export const prerender = false;
const cart = await Astro.session?.get('cart');
---

<div x-data="{
  cartCount: 0,
  async addToCart(id, name, price) {
    const { actions } = await import('astro:actions');
    const { data, error } = await actions.addToCart({ id, name, price });
    if (!error) this.cartCount = data;
  },
  async clearCart() {
    const { actions } = await import('astro:actions');
    const { data, error } = await actions.clearCart();
    if (!error) {
      this.cartCount = 0;
      location.reload();
    }
  }
}" x-init="cartCount = 0">

  <!-- Счётчик корзины -->
  <span id="cart-count" x-text="cartCount"></span>

  <!-- Кнопки товаров -->
  <button
    class="add-to-cart"
    @click="addToCart(1, 'Товар 1', 100)"
  >
    Добавить в корзину
  </button>

  <!-- Очистить корзину -->
  <button @click="clearCart()">
    Очистить корзину
  </button>

</div>

[alpinejs usage]


Важный нюанс: import из astro:actions — это модульный импорт, который не работает напрямую в строке x-data. Поэтому используется динамический await import('astro:actions') внутри методов.

Альтернативно — вынести логику в отдельный <script> и зарегистрировать Alpine-компонент через Alpine.data():

<div x-data="cart">
  <span x-text="cartCount"></span>
  <button @click="addToCart(1, 'Товар 1', 100)">Добавить</button>
  <button @click="clearCart()">Очистить</button>
</div>

<script>
import { actions } from 'astro:actions';
#

document.addEventListener('alpine:init', () => {
Alpine.data('cart', () => ({
cartCount: 0,
async addToCart(id, name, price) {
const { data, error } = await actions.addToCart({ id, name, price });
if (!error) this.cartCount = data;
},
async clearCart() {
const { data, error } = await actions.clearCart();
if (!error) {
this.cartCount = 0;
location.reload();
}
}
}));
});
</script>


Это более чистый подход — логика отделена от разметки, `import` работает корректно, и Alpine-компонент переиспользуем. [[alpinejs usage](<https://docs.astro.build/en/guides/integrations-guide/alpinejs/#usage>)]
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet почему очистка корзины перезагружает страницу?

clearCart: defineAction({
handler: async (_, context) => {
await context.session?.set("cart", []);
return 0;
},
}),

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Потому что в предыдущем коде вы сами написали location.reload() после успешной очистки:

async clearCart() {
  const { data, error } = await actions.clearCart();
  if (!error) {
    this.cartCount = 0;
    location.reload(); // ← вот причина
  }
}

Если перезагрузка не нужна — просто уберите location.reload():

async clearCart() {
  const { data, error } = await actions.clearCart();
  if (!error) {
    this.cartCount = 0;
    // обновите UI вручную, если нужно
  }
}

Astro Actions возвращают результат без перезагрузки страницы — это их ключевое преимущество. [basic usage] Перезагрузка была добавлена вручную, вероятно, чтобы обновить список товаров в корзине на странице. Если хотите обновить UI без перезагрузки — нужно обновлять состояние Alpine-компонента реактивно, а не через location.reload().

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet как правильно починить у document.getElementById('cart-count').... Object is possibly 'null'.

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Это стандартная TypeScript-ошибка — getElementById может вернуть null, если элемент не найден на странице.

Способы исправления

1. Опциональная цепочка (?.)

document.getElementById('cart-count')?.textContent = data.toString();

2. Проверка перед использованием

const cartCount = document.getElementById('cart-count');
if (cartCount) {
  cartCount.textContent = data.toString();
}

3. Non-null assertion (!) — если уверены, что элемент всегда есть

document.getElementById('cart-count')!.textContent = data.toString();

Используйте ! только если точно знаете, что элемент присутствует в DOM — иначе получите runtime-ошибку.


Astro обрабатывает скрипты как TypeScript по умолчанию [script processing], поэтому строгая проверка типов применяется и к клиентским <script> тегам. Рекомендуемый подход — вариант 1 или 2, так как они безопасны в runtime.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet The left-hand side of an assignment expression may not be an optional property access.

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You're right — ?. cannot be used on the left side of an assignment. Here are the correct ways to fix it:

1. Проверка через if

const cartCount = document.getElementById('cart-count');
if (cartCount) {
  cartCount.textContent = data.toString();
}

2. Non-null assertion (!)

document.getElementById('cart-count')!.textContent = data.toString();

3. Nullish coalescing с переменной

const cartCount = document.getElementById('cart-count');
cartCount && (cartCount.textContent = data.toString());

Рекомендуется вариант 1 — он безопасен в runtime и понятен. Astro обрабатывает <script> теги как TypeScript по умолчанию [script processing], поэтому такие ошибки типизации будут подсвечиваться в редакторе.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet как же бесит Property 'dataset' does not exist on type 'Element'.

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Это классическая TypeScript-проблема — querySelectorAll возвращает NodeListOf<Element>, а dataset есть только у HTMLElement.

Решение: приведите тип к HTMLElement

1. Через querySelectorAll с дженериком

document.querySelectorAll<HTMLElement>('.add-to-cart').forEach(btn => {
  const id = Number(btn.dataset.id);
  const name = btn.dataset.name;
  const price = Number(btn.dataset.price);
});

2. Через as HTMLElement при деструктуризации

document.querySelectorAll('.add-to-cart').forEach(el => {
  const btn = el as HTMLElement;
  const id = Number(btn.dataset.id);
});

3. Через проверку instanceof

document.querySelectorAll('.add-to-cart').forEach(el => {
  if (!(el instanceof HTMLElement)) return;
  const id = Number(el.dataset.id);
});

Рекомендуется вариант 1 — дженерик в querySelectorAll<HTMLElement>() самый чистый и идиоматичный. Astro обрабатывает <script> теги как TypeScript по умолчанию [script processing], поэтому строгая типизация применяется ко всем клиентским скриптам.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet
а вот тут как быть?

document.querySelectorAll<HTMLElement>(".add-to-cart").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = Number(btn.dataset.id);
const name = btn.dataset.name;
const price = Number(btn.dataset.price);

  const { data, error } = await actions.addToCart({ id, name, price });

  if (!error && cartCount) {
    cartCount.textContent = data;
  }
});

});

на name ругается Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

btn.dataset.name возвращает string | undefined, а ваш Action ожидает string. Несколько способов исправить:

1. Проверка перед вызовом

document.querySelectorAll<HTMLElement>(".add-to-cart").forEach((btn) => {
  btn.addEventListener("click", async () => {
    const id = Number(btn.dataset.id);
    const name = btn.dataset.name;
    const price = Number(btn.dataset.price);

    if (!name) return; // guard clause

    const { data, error } = await actions.addToCart({ id, name, price });

    if (!error && cartCount) {
      cartCount.textContent = data;
    }
  });
});

2. Fallback через ??

const name = btn.dataset.name ?? '';

3. Non-null assertion ! — если уверены, что атрибут всегда есть

const name = btn.dataset.name!;

Рекомендуется вариант 1 — он безопасен в runtime: если data-name вдруг отсутствует на элементе, клик просто ничего не сделает. Astro обрабатывает <script> теги как TypeScript по умолчанию [script processing], поэтому строгая типизация применяется ко всем клиентским скриптам.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet как файл actions/index.ts разбить правильно на несколько

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Astro поддерживает разбивку src/actions/index.ts на несколько файлов с группировкой по смыслу.

Как разбить actions на несколько файлов

1. Создайте отдельные файлы для каждой группы

src/actions/cart.ts:

import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';

export const cart = {
  addToCart: defineAction({
    input: z.object({ id: z.number(), name: z.string(), price: z.number() }),
    handler: async (input, context) => {
      const cart = await context.session?.get('cart') ?? [];
      cart.push(input);
      await context.session?.set('cart', cart);
      return cart.length;
    },
  }),
  clearCart: defineAction({
    handler: async (_, context) => {
      await context.session?.set('cart', []);
      return 0;
    },
  }),
};

src/actions/user.ts:

import { defineAction } from 'astro:actions';

export const user = {
  getUser: defineAction(/* ... */),
  createUser: defineAction(/* ... */),
};

2. Импортируйте всё в src/actions/index.ts

import { cart } from './cart';
import { user } from './user';

export const server = {
  cart,
  user,
};

3. Вызов на клиенте

import { actions } from 'astro:actions';

// Корзина
const { data, error } = await actions.cart.addToCart({ id: 1, name: 'Товар', price: 100 });

// Пользователь
const { data: userData } = await actions.user.getUser();

[organizing actions]


Итог: каждая группа экшенов выносится в отдельный файл и экспортируется как объект, затем импортируется в index.ts и добавляется как ключ в server. Вложенность может быть любой глубины.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet
давай немного отвлечемся.
сделаем простую форму, которая будет имитировать отправку

у меня есть этот экшен
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro/zod";

const formSchema = z.object({
name: z.string().min(2),
email: z.email(),
phone: z.string().min(10),
username: z.string().max(0), // Honeypot
});

export const form = {
submitForm: defineAction({
accept: "form",
input: formSchema,
handler: async (input: any) => {
// Используем переменные напрямую, они гарантированно есть и валидны
const res = await fetch(${import.meta.env.STRAPI_URL}/api/leads, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: Bearer ${import.meta.env.STRAPI_API_TOKEN},
},
body: JSON.stringify({
data: {
name: input.name,
email: input.email,
phone: input.phone,
},
}),
});

  if (!res.ok) {
    console.error("Strapi error:", await res.text());
    throw new ActionError({
      code: "BAD_REQUEST",
      message: "Ошибка при сохранении заявки",
    });
  }

  return { success: true };
},

}),
};

требуется его поправить под "черновую" форму, запрос в страпи закомментируй с комментариями.

поля оставь как есть.

в общем использую астро экшен нужно валидировать форму по zod
и выводить сообщения об ошибках валидации.

применяй предыдущий опыт

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Вот обновлённый Action и форма с валидацией и выводом ошибок.

Action (src/actions/form.ts)

import { defineAction, ActionError } from "astro:actions";
import { z } from "astro/zod";

const formSchema = z.object({
  name: z.string().min(2),
  email: z.email(),
  phone: z.string().min(10),
  username: z.string().max(0), // Honeypot
});

export const form = {
  submitForm: defineAction({
    accept: "form",
    input: formSchema,
    handler: async (input) => {
      // TODO: отправка в Strapi
      // const res = await fetch(`${import.meta.env.STRAPI_URL}/api/leads`, {
      //   method: "POST",
      //   headers: {
      //     "Content-Type": "application/json",
      //     Authorization: `Bearer ${import.meta.env.STRAPI_API_TOKEN}`,
      //   },
      //   body: JSON.stringify({
      //     data: {
      //       name: input.name,
      //       email: input.email,
      //       phone: input.phone,
      //     },
      //   }),
      // });

      // if (!res.ok) {
      //   console.error("Strapi error:", await res.text());
      //   throw new ActionError({
      //     code: "BAD_REQUEST",
      //     message: "Ошибка при сохранении заявки",
      //   });
      // }

      // Имитация задержки отправки
      await new Promise((resolve) => setTimeout(resolve, 500));

      return { success: true };
    },
  }),
};

Форма (src/pages/contact.astro)

Используем Astro.getActionResult() + isInputError() для вывода ошибок валидации на сервере (zero-JS подход): [handle form action errors]

---
import { actions, isInputError } from 'astro:actions';

const result = Astro.getActionResult(actions.form.submitForm);
const inputErrors = isInputError(result?.error) ? result.error.fields : {};
const success = result?.data?.success;
---
#

{success && (
<p style="color: green;">✅ Заявка успешно отправлена!</p>
)}

{result?.error && !isInputError(result.error) && (
<p style="color: red;">❌ {result.error.message}</p>
)}

<form method="POST" action={actions.form.submitForm}>

<!-- Honeypot — скрытое поле, боты заполняют его -->
<input type="text" name="username" style="display:none" tabindex="-1" autocomplete="off" />

<label>
Имя
<input type="text" name="name" required />
</label>
{inputErrors.name && <p style="color: red;">{inputErrors.name.join(', ')}</p>}

<label>
Email
<input type="email" name="email" required />
</label>
{inputErrors.email && <p style="color: red;">{inputErrors.email.join(', ')}</p>}

<label>
Телефон
<input type="tel" name="phone" required />
</label>
{inputErrors.phone && <p style="color: red;">{inputErrors.phone.join(', ')}</p>}

<button type="submit">Отправить</button>
</form>


[[call actions from HTML form](<https://docs.astro.build/en/guides/actions/#call-actions-from-an-html-form-action>)]

---
#

Ключевые моменты:

  • isInputError() проверяет, является ли ошибка ошибкой валидации Zod — тогда в error.fields будут сообщения по каждому полю. [isInputError]
  • action={actions.form.submitForm} — передаёт action как атрибут формы, Astro сам обрабатывает запрос на сервере.
  • Страница должна быть on-demand rendered (не prerender). [call actions from HTML form]
  • Если хотите сохранять введённые значения при ошибке — добавьте transition:persist на каждый <input> (требует включённых View Transitions). [preserve input values]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet так ведь будет перезагрузка страницы

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Да, при использовании method="POST" action={actions.form.submitForm} — это HTML form action, и страница перезагружается. Это нормальное поведение для zero-JS подхода. [call actions from HTML form]

Если хотите без перезагрузки — перехватите сабмит через <script> и вызывайте Action программно:

---
---

<p id="success-msg" style="display:none; color:green;">✅ Заявка отправлена!</p>
<p id="error-msg" style="display:none; color:red;"></p>

<form id="contact-form">
  <input type="text" name="username" style="display:none" tabindex="-1" autocomplete="off" />

  <label>
    Имя
    <input type="text" name="name" required />
  </label>
  <p id="error-name" style="color:red;"></p>

  <label>
    Email
    <input type="email" name="email" required />
  </label>
  <p id="error-email" style="color:red;"></p>

  <label>
    Телефон
    <input type="tel" name="phone" required />
  </label>
  <p id="error-phone" style="color:red;"></p>

  <button type="submit">Отправить</button>
</form>

<script>
import { actions, isInputError } from 'astro:actions';

const form = document.querySelector<HTMLFormElement>('#contact-form');

form?.addEventListener('submit', async (event) => {
  event.preventDefault(); // отменяем перезагрузку

  // Сбрасываем ошибки
  document.getElementById('error-name')!.textContent = '';
  document.getElementById('error-email')!.textContent = '';
  document.getElementById('error-phone')!.textContent = '';
  document.getElementById('error-msg')!.style.display = 'none';
  document.getElementById('success-msg')!.style.display = 'none';

  const formData = new FormData(form);
  const { data, error } = await actions.form.submitForm(formData);
#

if (isInputError(error)) {
if (error.fields.name) document.getElementById('error-name')!.textContent = error.fields.name.join(', ');
if (error.fields.email) document.getElementById('error-email')!.textContent = error.fields.email.join(', ');
if (error.fields.phone) document.getElementById('error-phone')!.textContent = error.fields.phone.join(', ');
return;
}

if (error) {
const msg = document.getElementById('error-msg')!;
msg.textContent = ❌ ${error.message};
msg.style.display = 'block';
return;
}

if (data?.success) {
document.getElementById('success-msg')!.style.display = 'block';
form.reset();
}
});
</script>


[[validating form data](<https://docs.astro.build/en/guides/actions/#validating-form-data>)] [[displaying form input errors](<https://docs.astro.build/en/guides/actions/#displaying-form-input-errors>)]

---

**Ключевое отличие:**
- `action={actions.form.submitForm}` на `<form>` → перезагрузка страницы (zero-JS)
- `event.preventDefault()` + `actions.form.submitForm(formData)` в `<script>` → без перезагрузки
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

400 (Bad Request) от Action означает ошибку валидации Zod — входные данные не прошли проверку по схеме.

Наиболее вероятные причины

1. Honeypot-поле username не пустое

Ваша схема содержит:

username: z.string().max(0), // Honeypot

Если поле username отсутствует в FormData вообще — Zod получит null вместо пустой строки, и валидация упадёт.

Исправление: добавьте .optional() или используйте z.string().max(0).optional():

username: z.string().max(0).optional(),

2. Пустые поля возвращают null, а не строку

Из документации Astro:

Empty values are converted to null except when validating arrays or booleans.

[accepting form data]

Если пользователь не заполнил поле — Zod получит null, а z.string() это не примет.

Исправление для необязательных полей:

phone: z.string().min(10).nullable(),

3. Проверьте, что accept: "form" указан в Action

submitForm: defineAction({
  accept: "form", // ← обязательно для FormData
  input: formSchema,
  handler: async (input) => { /* ... */ },
}),

[accept property]


Как увидеть конкретные ошибки валидации

Добавьте в <script> вывод ошибок в консоль:

const { data, error } = await actions.form.submitForm(formData);
console.log('error:', error);
if (isInputError(error)) {
  console.log('fields:', error.fields);
}

Это покажет, какое именно поле не прошло валидацию. [displaying form input errors]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet как мне указать сообщения об ошибках в zod

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

В Zod 4 (который использует Astro v6) кастомные сообщения об ошибках указываются через свойство error (не message, как было в Zod 3):

[Zod 4 upgrade]

import { z } from 'astro/zod';

const formSchema = z.object({
  name: z.string().min(2, { error: "Имя должно содержать минимум 2 символа" }),
  email: z.email({ error: "Введите корректный email" }),
  phone: z.string().min(10, { error: "Телефон должен содержать минимум 10 символов" }),
  username: z.string().max(0).optional(),
});

Для более сложной валидации через .refine():

const constrainedString = z
  .string()
  .refine((val) => val.length > 0 && val.length <= 255, {
    error: "Должно быть от 1 до 255 символов.",
  });

[using Zod methods]


Важно: в Zod 3 использовалось { message: "..." }, в Zod 4 — { error: "..." }. Если напишете message — ошибка будет проигнорирована и выведется дефолтное сообщение Zod.

// Zod 3 (устарело)
z.string().min(5, { message: "Слишком коротко." });

// Zod 4 (правильно)
z.string().min(5, { error: "Слишком коротко." });

[Zod 4 upgrade]

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet корректно отображается только для email. для других полей выводится дефолтное сообщение об ошибке

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Это связано с тем, что в Zod 4 синтаксис передачи кастомных сообщений об ошибках изменился для некоторых методов.

Из документации Astro по миграции на Zod 4: [Zod 4 upgrade]

// Zod 3 (устарело)
z.string().min(5, { message: "Too short." });

// Zod 4 (правильно)
z.string().min(5, { error: "Too short." });

Убедитесь, что везде используете error, а не message:

import { z } from 'astro/zod';

const formSchema = z.object({
  name: z.string().min(2, { error: "Имя должно содержать минимум 2 символа" }),
  email: z.email({ error: "Введите корректный email" }),
  phone: z.string().min(10, { error: "Телефон должен содержать минимум 10 символов" }),
  username: z.string().max(0).optional(),
});

z.email() работает корректно, потому что это top-level валидатор Zod 4 — он принимает объект с error напрямую. А вот .min() и .max() — это методы цепочки, и если вы передавали им { message: "..." } вместо { error: "..." }, кастомное сообщение игнорировалось и выводилось дефолтное.

Проверьте, что нигде не осталось старого синтаксиса message.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet я понял. на пустое отображается дефолтное сообщение. как на пустоту тоже проверить

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Из документации Astro:

Empty values are converted to null except when validating arrays or booleans.

[accepting form data]

Это значит, что пустое поле name или phone придёт в Zod как null, а не как пустая строка — и .min() просто не сработает.

Решение: добавьте .nullable() с кастомным сообщением через .refine()

import { z } from 'astro/zod';

const formSchema = z.object({
  name: z.string({ error: "Введите имя" }).min(2, { error: "Имя должно содержать минимум 2 символа" }),
  email: z.email({ error: "Введите корректный email" }),
  phone: z.string({ error: "Введите телефон" }).min(10, { error: "Телефон должен содержать минимум 10 символов" }),
  username: z.string().max(0).optional(),
});

Передача { error: "..." } прямо в z.string() — это сообщение для случая, когда значение не является строкой (в том числе null). [using Zod methods]

Таким образом:

  • Пустое поле → nullz.string({ error: "Введите имя" }) → ваше сообщение
  • Слишком короткое → z.string().min(2, { error: "..." }) → ваше сообщение

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet как выводить ошибки в случае мультиязычности сайта не используя нативные средства i18n

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Из предыдущего контекста у вас: чистый динамический роут [lang] без встроенного i18n, Astro Actions с Zod-валидацией, и форма с выводом ошибок через isInputError().

Для мультиязычных сообщений об ошибках — просто передавайте язык в Action и выбирайте нужный текст вручную.

Подход: словарь ошибок + передача языка

src/i18n/errors.ts

export const errorMessages = {
  ru: {
    name_required: "Введите имя",
    name_min: "Имя должно содержать минимум 2 символа",
    email_invalid: "Введите корректный email",
    phone_required: "Введите телефон",
    phone_min: "Телефон должен содержать минимум 10 символов",
    server_error: "Ошибка при отправке заявки",
  },
  en: {
    name_required: "Please enter your name",
    name_min: "Name must be at least 2 characters",
    email_invalid: "Please enter a valid email",
    phone_required: "Please enter your phone",
    phone_min: "Phone must be at least 10 characters",
    server_error: "Error submitting the form",
  },
} as const;

export type Lang = keyof typeof errorMessages;

src/actions/form.ts

Передавайте lang как скрытое поле формы, и стройте схему динамически:

import { defineAction, ActionError } from "astro:actions";
import { z } from "astro/zod";
import { errorMessages, type Lang } from "../i18n/errors";

export const form = {
  submitForm: defineAction({
    accept: "form",
    input: z.object({ lang: z.string() }).passthrough(),
    handler: async (rawInput) => {
      const lang = (rawInput.lang as Lang) in errorMessages
        ? (rawInput.lang as Lang)
        : "ru";
      const e = errorMessages[lang];
#

const schema = z.object({
name: z.string({ error: e.name_required }).min(2, { error: e.name_min }),
email: z.email({ error: e.email_invalid }),
phone: z.string({ error: e.phone_required }).min(10, { error: e.phone_min }),
username: z.string().max(0).optional(),
});

  const parsed = schema.safeParse(rawInput);
  if (!parsed.success) {
    throw new ActionError({
      code: "BAD_REQUEST",
      message: parsed.error.issues[0]?.message ?? e.server_error,
    });
  }

  // TODO: отправка в Strapi
  await new Promise((resolve) => setTimeout(resolve, 500));

  return { success: true };
},

}),
};


### Форма (`src/pages/[lang]/contact.astro`)

```astro
---
export const prerender = false;
const { lang } = Astro.params;
---

<p id="success-msg" style="display:none; color:green;">
  {lang === 'ru' ? '✅ Заявка отправлена!' : '✅ Form submitted!'}
</p>
<p id="error-msg" style="display:none; color:red;"></p>

<form id="contact-form">
  <!-- Передаём язык скрытым полем -->
  <input type="hidden" name="lang" value={lang} />
  <input type="text" name="username" style="display:none" tabindex="-1" autocomplete="off" />

  <label>
    <input type="text" name="name" required />
  </label>
  <p id="error-name" style="color:red;"></p>

  <label>
    <input type="email" name="email" required />
  </label>
  <p id="error-email" style="color:red;"></p>

  <label>
    <input type="tel" name="phone" required />
  </label>
  <p id="error-phone" style="color:red;"></p>

  <button type="submit">
    {lang === 'ru' ? 'Отправить' : 'Submit'}
  </button>
</form>

<script>
import { actions, isInputError } from 'astro:actions';

const form = document.querySelector<HTMLFormElement>('#contact-form');

form?.addEventListener('submit', async (event) => {
  event.preventDefault();
#

document.getElementById('error-name')!.textContent = '';
document.getElementById('error-email')!.textContent = '';
document.getElementById('error-phone')!.textContent = '';
document.getElementById('error-msg')!.style.display = 'none';
document.getElementById('success-msg')!.style.display = 'none';

const formData = new FormData(form);
const { data, error } = await actions.form.submitForm(formData);

if (isInputError(error)) {
if (error.fields.name) document.getElementById('error-name')!.textContent = error.fields.name.join(', ');
if (error.fields.email) document.getElementById('error-email')!.textContent = error.fields.email.join(', ');
if (error.fields.phone) document.getElementById('error-phone')!.textContent = error.fields.phone.join(', ');
return;
}

if (error) {
const msg = document.getElementById('error-msg')!;
msg.textContent = error.message;
msg.style.display = 'block';
return;
}

if (data?.success) {
document.getElementById('success-msg')!.style.display = 'block';
form.reset();
}
});
</script>


[[displaying form input errors](<https://docs.astro.build/en/guides/actions/#displaying-form-input-errors>)] [[handling backend errors](<https://docs.astro.build/en/guides/actions/#handling-backend-errors-in-your-action>)]

---

**Итог:**
- Язык передаётся скрытым полем `lang` в FormData
- Action строит Zod-схему с нужными сообщениями динамически
- `isInputError()` возвращает уже локализованные сообщения из Zod
- Никакого встроенного i18n Astro не используется
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet если username заполнен, то отправка не должна происходить. это honeypot

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

В вашем текущем Action honeypot-поле username валидируется через z.string().max(0) — если бот заполнит его, Zod вернёт ошибку валидации (400 Bad Request). Это технически работает, но с точки зрения UX лучше явно проверять honeypot в handler и тихо возвращать успех (чтобы бот не знал, что был заблокирован).

Исправление в Action

handler: async (rawInput) => {
  // Honeypot: если поле заполнено — тихо возвращаем успех, не обрабатывая
  if (rawInput.username && rawInput.username.length > 0) {
    return { success: true };
  }

  // ... остальная логика
}

Либо, если хотите убрать username из Zod-схемы и проверять до валидации — используйте .passthrough() и проверяйте сырые данные:

export const form = {
  submitForm: defineAction({
    accept: "form",
    input: z.object({ lang: z.string() }).passthrough(),
    handler: async (rawInput) => {
      // Honeypot check — тихий выход
      if (rawInput.username) {
        return { success: true };
      }

      const lang = (rawInput.lang as Lang) in errorMessages
        ? (rawInput.lang as Lang)
        : "ru";
      const e = errorMessages[lang];

      const schema = z.object({
        name: z.string({ error: e.name_required }).min(2, { error: e.name_min }),
        email: z.email({ error: e.email_invalid }),
        phone: z.string({ error: e.phone_required }).min(10, { error: e.phone_min }),
        username: z.string().max(0).optional(),
      });

      // ... остальная логика
    },
  }),
};

[handling backend errors]


#

Почему тихий возврат { success: true } лучше, чем ошибка:

  • Бот получает ответ "успех" и не знает, что был заблокирован
  • Реальный пользователь никогда не заполнит скрытое поле — для него всё работает как обычно
  • Нет лишних 400-ошибок в логах от ботов

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet надо запретить автокомплит поля, я это поле буду скрывать визуально, но браузер можно сам подставить. или от бота можно использовать display: none?

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Этот вопрос касается HTML/CSS поведения браузеров и защиты от ботов, а не специфики Astro — поэтому я не могу дать авторитетный ответ на основе документации Astro.

Могу лишь сказать, что в примерах из документации Astro honeypot-поля скрываются через инлайн-стили:

<input type="text" name="username" style="display:none" tabindex="-1" autocomplete="off" />

[build HTML forms]

Атрибут autocomplete="off" там уже присутствует — именно для предотвращения автозаполнения браузером.

Для более детальных вопросов о поведении браузеров, display: none vs visibility: hidden, и надёжности honeypot-техник — рекомендую обратиться к MDN или специализированным ресурсам по веб-безопасности.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet почему не работает

if (input.username && input.username.length > 0) {
console.log('BOT!')
return { success: true };
}

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Проблема в том, что в предыдущем варианте Action используется input: z.object({ lang: z.string() }).passthrough() — и Zod-валидация происходит до вызова handler. Если username не проходит схему (например, z.string().max(0) в схеме), то до handler дело просто не доходит, и ваша проверка никогда не выполняется.

Также, если поле username пустое (не заполнено пользователем), Astro конвертирует его в null — и тогда input.username будет null, а не пустой строкой. [accepting form data]

Решение

Уберите username из Zod-схемы полностью и проверяйте его через сырой FormData в handler, используя context.request:

Либо — самый простой вариант — оставьте username как z.string().nullable().optional() в схеме, чтобы Zod пропускал любое значение, и проверяйте уже в handler:

const schema = z.object({
  name: z.string({ error: e.name_required }).min(2, { error: e.name_min }),
  email: z.email({ error: e.email_invalid }),
  phone: z.string({ error: e.phone_required }).min(10, { error: e.phone_min }),
  username: z.string().nullable().optional(), // ← пропускаем любое значение
  lang: z.string().optional(),
});

const parsed = schema.safeParse(rawInput);
if (!parsed.success) { /* ... */ }

// Honeypot: проверяем после валидации
if (parsed.data.username) {
  return { success: true };
}

Таким образом:

  • Пустое поле → nullnullable().optional() → проходит валидацию → parsed.data.username будет null → honeypot не срабатывает
  • Заполненное поле → строка → проходит валидацию → parsed.data.username будет строкой → honeypot срабатывает, возвращаем тихий успех

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet а давай валидацию будем дополнительно делать на клиенте еще до отправки

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Для клиентской валидации до отправки есть два подхода, которые можно комбинировать.

1. Нативная HTML-валидация (без JS)

Добавьте атрибуты валидации прямо на <input> — работает даже без JavaScript:

<input type="text" name="name" required minlength="2" />
<input type="email" name="email" required />
<input type="tel" name="phone" required minlength="10" />

[build HTML forms]

2. Валидация через JS в обработчике submit

В вашем <script> перед вызовом Action проверяйте поля вручную:

<script>
import { actions, isInputError } from 'astro:actions';

const form = document.querySelector<HTMLFormElement>('#contact-form');

// Вспомогательная функция для показа ошибки
function showError(id: string, message: string) {
  const el = document.getElementById(id);
  if (el) el.textContent = message;
}

function clearErrors() {
  ['error-name', 'error-email', 'error-phone'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.textContent = '';
  });
  const msg = document.getElementById('error-msg');
  if (msg) msg.style.display = 'none';
}

function validateClient(formData: FormData): boolean {
  let valid = true;
  const name = formData.get('name') as string | null;
  const email = formData.get('email') as string | null;
  const phone = formData.get('phone') as string | null;

  if (!name || name.length < 2) {
    showError('error-name', 'Имя должно содержать минимум 2 символа');
    valid = false;
  }

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    showError('error-email', 'Введите корректный email');
    valid = false;
  }

  if (!phone || phone.length < 10) {
    showError('error-phone', 'Телефон должен содержать минимум 10 символов');
    valid = false;
  }

  return valid;
}
#

form?.addEventListener('submit', async (event) => {
event.preventDefault();
clearErrors();

const formData = new FormData(form);

// Клиентская валидация до отправки
if (!validateClient(formData)) return;

// Если прошла — отправляем на сервер
const { data, error } = await actions.form.submitForm(formData);

if (isInputError(error)) {
if (error.fields.name) showError('error-name', error.fields.name.join(', '));
if (error.fields.email) showError('error-email', error.fields.email.join(', '));
if (error.fields.phone) showError('error-phone', error.fields.phone.join(', '));
return;
}

if (error) {
const msg = document.getElementById('error-msg');
if (msg) {
msg.textContent = error.message;
msg.style.display = 'block';
}
return;
}

if (data?.success) {
const success = document.getElementById('success-msg');
if (success) success.style.display = 'block';
form.reset();
}
});
</script>


[[displaying form input errors](<https://docs.astro.build/en/guides/actions/#displaying-form-input-errors>)]

---

**Итог:**
- Нативные атрибуты (`required`, `minlength`, `type="email"`) — первый уровень, работает без JS
- `validateClient()` в `submit`-обработчике — второй уровень, до отправки на сервер
- `isInputError()` после Action — третий уровень, серверная валидация Zod как финальная защита

Сообщения об ошибках в `validateClient()` можно так же вынести в словарь `errorMessages` из `src/i18n/errors.ts`, который вы уже создали, передав язык через `data-lang` атрибут на форме.
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
#

@lusty swift Kapa.ai is still learning and improving, please let me know how I did by reacting below.

lusty swift
#

@lilac comet не совсем то. проверять по мере заполнения. и подсветить зеленым в случае ок

lilac cometBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

Это стандартная клиентская логика на JavaScript/HTML — не специфика Astro. Документация Astro не описывает валидацию «по мере заполнения» (on-input validation) или подсветку полей.

Могу лишь сказать, что Astro обрабатывает <script> теги как TypeScript и поддерживает стандартные DOM-события [script processing], поэтому вы можете использовать стандартные события input и blur в клиентском скрипте вашего Astro-компонента:

<script>
const fields = {
  name: { el: document.querySelector<HTMLInputElement>('[name="name"]'), validate: (v: string) => v.length >= 2 },
  email: { el: document.querySelector<HTMLInputElement>('[name="email"]'), validate: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) },
  phone: { el: document.querySelector<HTMLInputElement>('[name="phone"]'), validate: (v: string) => v.length >= 10 },
};

Object.entries(fields).forEach(([key, { el, validate }]) => {
  el?.addEventListener('input', () => {
    const valid = validate(el.value);
    el.style.borderColor = valid ? 'green' : '';
    const errorEl = document.getElementById(`error-${key}`);
    if (errorEl) errorEl.textContent = valid ? '' : errorEl.textContent;
  });
});
</script>

Для более сложной реактивной валидации по мере ввода рекомендую рассмотреть UI-фреймворк (React, Vue, Svelte) с библиотекой форм, либо Alpine.js — всё это поддерживается в Astro [framework components], но конкретная реализация выходит за рамки документации Astro.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: