itube-specs 0.0.1007 → 0.0.1009

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SITE_UPDATES.md CHANGED
@@ -10,6 +10,27 @@
10
10
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
11
11
  кому именно (всем / сайтам с N), что будет, если не сделать.
12
12
 
13
+ ## 0.0.1008 — 2026-10-07
14
+
15
+ - **Сайтам с несколькими нишами (в `SiteConfig.niches` больше одной), где нет нишевых переводов** — проверить и
16
+ доделать всё по `NEW_SITE_CHECKLIST.md` §3a «Несколько ниш», чего у сайта ещё нет:
17
+ - `config/niches.config.ts` с `NICHE_SLUGS`, `NICHE_CONTENT_ROOTS` и `NicheConfig` (`nicheSlugs`,
18
+ `nicheFreeRoots`, `nicheAdjectives`, `nicheTextKeys`), разложить `...NicheConfig` в `app.config.ts`;
19
+ - `addNicheAliases` в хуке `pages:extend` в `nuxt.config.ts`;
20
+ - `postTranslation: nichePostTranslation` в `i18n/i18n.config.ts`
21
+ (`import { nichePostTranslation } from 'itube-specs/utils/niche-post-translation'`);
22
+ - `v-bind="useIslandNicheProps()"` (через константу в `<script setup>`) на своих `<SectionCategoriesIsland>`.
23
+
24
+ `nicheAdjectives` заполнить для **всех** локалей сайта: без записи вместо слова в `%niche%` подставится код
25
+ ниши (`gay`). Тексты под ниши — ключи `<key>_<niche>` для ключей из `nicheTextKeys` и
26
+ `meta.<page>.<sort>.<key>_<niche>` для меты; это контент, их не выдумывать — вынести в TODO сайта. Не сделать —
27
+ `/gays`, `/shemales` отдают SEO-тексты дефолтной ниши, слово ниши в переводах не подставляется. Сайтам с
28
+ одной нишей — ничего.
29
+
30
+ - **Сайтам, которым нужен свой размер лого в шапке (videosection):** в `config/site.config.ts` добавить
31
+ `logoSize: { width, height }` — размер в px на всех брейкпоинтах. Не задан — остаётся прежний адаптив
32
+ (120×20, с `sm` 144×24); футер свой размер держит сам. Ничего не делать, если текущий размер устраивает.
33
+
13
34
  ## 0.0.1007 — 2026-10-06
14
35
 
15
36
  - **Сайтам, где `npm run dev` подключает локальный слой (`LAYER_PATH` в `.env`):** заменить скрипт `predev` в
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <header
3
- :class="{'--burger-open': burgerShow}"
3
+ :class="{'--burger-open': burgerShow, '--no-notifications': !featureFlags.FeatureFollowEnabled}"
4
4
  class="layout-header"
5
5
  >
6
6
 
@@ -137,6 +137,29 @@ function closeLanguage() {
137
137
  }
138
138
  }
139
139
 
140
+ .layout-header.--no-notifications {
141
+ @include from-br($brkpnt-burger) {
142
+ grid-template-columns: auto minmax(0, 1fr) auto auto auto;
143
+ }
144
+
145
+ @include to-br($brkpnt-burger) {
146
+ grid-template-columns: auto 1fr auto;
147
+ }
148
+ }
149
+
150
+ .layout-header.--no-notifications .layout-header__language {
151
+ @include from-br($brkpnt-burger) {
152
+ grid-column: 4;
153
+ }
154
+ }
155
+
156
+ .layout-header.--no-notifications .layout-header__user,
157
+ .layout-header.--no-notifications .layout-header__sign-up {
158
+ @include from-br($brkpnt-burger) {
159
+ grid-column: 5;
160
+ }
161
+ }
162
+
140
163
  .layout-header__logo {
141
164
  @include from-br($brkpnt-burger) {
142
165
  grid-column: 1;
@@ -151,6 +151,7 @@ const {
151
151
  countriesItems,
152
152
  setCountry,
153
153
  loadCountries,
154
+ withCountryCarry,
154
155
  } = useClientCountry();
155
156
 
156
157
  // Именительный падеж считает ICU, своей таблицы имён нет. Пустой `place` = служебный код
@@ -213,7 +214,7 @@ function changeLocale(locale: string) {
213
214
  emit('change', locale);
214
215
  rememberLangChoice(locale);
215
216
  localeCookie.value = locale;
216
- window.location.href = switchLocalePath(locale);
217
+ window.location.href = withCountryCarry(switchLocalePath(locale));
217
218
  }
218
219
 
219
220
  async function changeCountry(item: ISelectItem) {
@@ -12,10 +12,11 @@
12
12
  >
13
13
  <img
14
14
  :alt="brandName"
15
+ :class="{'--adaptive': !logoSize}"
16
+ :width="logoSize?.width ?? 120"
17
+ :height="logoSize?.height ?? 20"
15
18
  class="layout-logo__image"
16
19
  src="/img/logo.svg"
17
- width="120"
18
- height="20"
19
20
  >
20
21
  </NuxtLink>
21
22
  </template>
@@ -29,7 +30,7 @@ const emit = defineEmits<{
29
30
  }>();
30
31
 
31
32
  const { generateLink } = useGenerateLink();
32
- const { brandName } = useAppConfig();
33
+ const { brandName, logoSize } = useAppConfig();
33
34
 
34
35
  function onLogoClick() {
35
36
  emit('click');
@@ -45,7 +46,7 @@ function onLogoClick() {
45
46
  }
46
47
  }
47
48
 
48
- .layout-logo__image {
49
+ .layout-logo__image.--adaptive {
49
50
  @include from-br(sm) {
50
51
  width: 144px;
51
52
  height: 24px;
@@ -0,0 +1,35 @@
1
+ import { useSharedCookieDomain } from './use-shared-cookie-domain';
2
+
3
+ const AUTH_COOKIE = 'jwtoken';
4
+ const EXPIRED = 'expires=Thu, 01 Jan 1970 00:00:00 GMT';
5
+
6
+ /**
7
+ * Кука авторизации `jwtoken`. У локалей на поддоменах — общая для всех языков (`.a.com`), иначе
8
+ * вход на `ru.a.com` не виден на `a.com`. Старые host-only куки (до общего домена) и куку на
9
+ * `xDomain` гасит при входе и выходе, чтобы под одним именем не висели две разные.
10
+ */
11
+ export const useAuthCookie = () => {
12
+ const domain = useSharedCookieDomain();
13
+ const xDomain = useRuntimeConfig().public.xDomain as string | undefined;
14
+ const cookie = useCookie<string | null | undefined>(AUTH_COOKIE, { path: '/', secure: true, httpOnly: false, sameSite: 'lax', domain });
15
+
16
+ function expireStale() {
17
+ if (!import.meta.client) return;
18
+ const stale = [undefined, xDomain].filter(item => item !== domain);
19
+ for (const item of stale) {
20
+ document.cookie = [`${AUTH_COOKIE}=`, 'path=/', item ? `domain=${item}` : '', EXPIRED, 'Secure'].filter(Boolean).join('; ');
21
+ }
22
+ }
23
+
24
+ function setToken(token: string) {
25
+ expireStale();
26
+ cookie.value = token;
27
+ }
28
+
29
+ function clearToken() {
30
+ cookie.value = null;
31
+ expireStale();
32
+ }
33
+
34
+ return { cookie, setToken, clearToken };
35
+ };
@@ -1,9 +1,20 @@
1
1
  import type { ISelectItem } from '../types';
2
2
  import { isoCodeMap } from '../utils/iso-code-map';
3
3
  import { DictionariesApiService } from '../services/api/dictionaries.service';
4
+ import { useSharedCookieDomain } from './use-shared-cookie-domain';
5
+ import { COUNTRY_CARRY_PARAM, hasLocaleSubdomains, type LocaleSubdomains } from '../config/locale-domains';
4
6
 
5
- /** Ручной выбор страны переживает перезагрузку год — читает его server/middleware/country.ts */
6
- const COOKIE_OPTS = { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' as const };
7
+ /**
8
+ * Ручной выбор страны переживает перезагрузку год — читает его server/middleware/country.ts. У
9
+ * локалей на поддоменах кука общая для всех языков, иначе смена языка теряет выбор.
10
+ */
11
+ const cookieOpts = () => ({
12
+ maxAge: 60 * 60 * 24 * 365,
13
+ path: '/',
14
+ secure: true,
15
+ sameSite: 'lax' as const,
16
+ domain: useSharedCookieDomain(),
17
+ });
7
18
 
8
19
  /**
9
20
  * Единый источник страны клиента: ISO-код (гео-заголовок прокси или ручной выбор
@@ -24,7 +35,7 @@ export const useClientCountry = () => {
24
35
  // падежей и артиклей в ECMA-402 нет. Ручной выбор страны в селекторе перезагружает
25
36
  // страницу, поэтому клиенту словарь не нужен.
26
37
  const place = useState<string>('clientCountryPlace', () => '');
27
- const codeCookie = useCookie<string | null>('clientCountryCode', COOKIE_OPTS);
38
+ const codeCookie = useCookie<string | null>('clientCountryCode', cookieOpts());
28
39
 
29
40
  /** ISO-код → имя файла флага (у части стран код локали отличается от кода страны) */
30
41
  const toIcon = (value: string) => {
@@ -71,5 +82,19 @@ export const useClientCountry = () => {
71
82
  }
72
83
  };
73
84
 
74
- return { code, icon, place, flagSrc, countriesItems, setCountry, loadCountries };
85
+ /**
86
+ * Страна не зависит от языка. Обычно её переносит общая cookie, а где её не бывает (localhost, IP
87
+ * при локалях на поддоменах) — ручной выбор едет в URL перехода на другую локаль.
88
+ */
89
+ const withCountryCarry = (url: string): string => {
90
+ const subdomains = useRuntimeConfig().public.locales?.subdomains as LocaleSubdomains | undefined;
91
+ if (!import.meta.client || !hasLocaleSubdomains(subdomains) || useSharedCookieDomain() || !codeCookie.value) return url;
92
+
93
+ const target = new URL(url, window.location.href);
94
+ target.searchParams.set(COUNTRY_CARRY_PARAM, codeCookie.value);
95
+
96
+ return target.toString();
97
+ };
98
+
99
+ return { code, icon, place, flagSrc, countriesItems, setCountry, loadCountries, withCountryCarry };
75
100
  };
@@ -1,4 +1,4 @@
1
- import { LANG_COOKIE, baseDomainFromHost, hasLocaleSubdomains, type LocaleSubdomains } from '../config/locale-domains';
1
+ import { LANG_COOKIE, hasLocaleSubdomains, sharedCookieDomain, type LocaleSubdomains } from '../config/locale-domains';
2
2
 
3
3
  /**
4
4
  * Явный выбор языка на сайтах с локалями на поддоменах. Пишет cookie `lang` (значение — поддомен
@@ -12,8 +12,7 @@ export const useLangChoice = () => {
12
12
  function rememberLangChoice(code: string) {
13
13
  if (!import.meta.client || !hasLocaleSubdomains(subdomains)) return;
14
14
 
15
- const base = baseDomainFromHost(window.location.host, subdomains);
16
- const isLocalhost = /^localhost(?::\d+)?$|^\d+\.\d+\.\d+\.\d+/.test(base);
15
+ const domain = sharedCookieDomain(window.location.host, subdomains);
17
16
 
18
17
  const parts = [
19
18
  `${LANG_COOKIE}=${subdomains[code] || ''}`,
@@ -21,8 +20,7 @@ export const useLangChoice = () => {
21
20
  `max-age=${60 * 60 * 24 * 365}`,
22
21
  'samesite=lax',
23
22
  ];
24
- // на localhost domain-атрибут с точкой браузеры отбрасывают
25
- if (!isLocalhost) parts.push(`domain=.${base}`);
23
+ if (domain) parts.push(`domain=${domain}`);
26
24
  if (window.location.protocol === 'https:') parts.push('secure');
27
25
 
28
26
  document.cookie = parts.join('; ');
@@ -0,0 +1,13 @@
1
+ import { sharedCookieDomain, type LocaleSubdomains } from '../config/locale-domains';
2
+
3
+ /**
4
+ * `domain` для cookie, общих для всех языковых поддоменов (авторизация, выбор страны): `.a.com`
5
+ * с `ru.a.com`. У префиксных локалей, на localhost и IP — `undefined` (host-only).
6
+ */
7
+ export const useSharedCookieDomain = (): string | undefined => {
8
+ const subdomains = useRuntimeConfig().public.locales?.subdomains as LocaleSubdomains | undefined;
9
+ const headers = import.meta.server ? useRequestHeaders(['x-forwarded-host', 'host']) : {};
10
+ const host = import.meta.server ? headers['x-forwarded-host'] || headers['host'] || '' : window.location.host;
11
+
12
+ return sharedCookieDomain(host, subdomains);
13
+ };
@@ -7,6 +7,7 @@ import type {
7
7
  IRegistrateForm,
8
8
  IChangeEmail
9
9
  } from '../types';
10
+ import { useAuthCookie } from './use-auth-cookie';
10
11
 
11
12
  function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
12
13
  const result = {} as Omit<T, K>;
@@ -24,10 +25,11 @@ function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K
24
25
  */
25
26
  export const useUser = (apiService) => {
26
27
  const { show, showError } = useSnackbar();
28
+ const { cookie: authCookie, setToken, clearToken } = useAuthCookie();
27
29
  const register = async (form: IRegistrateForm) => {
28
30
  const response = await apiService.register(form);
29
31
  if (response) {
30
- useCookie('jwtoken', { path: '/', secure: true, httpOnly: false }).value = response.token;
32
+ setToken(response.token);
31
33
  await getProfile();
32
34
  }
33
35
  };
@@ -64,7 +66,7 @@ export const useUser = (apiService) => {
64
66
  const login = async (form: ILoginForm) => {
65
67
  const response = await apiService.login(form);
66
68
  if (response) {
67
- useCookie('jwtoken', { path: '/', secure: true, httpOnly: false }).value = response.token;
69
+ setToken(response.token);
68
70
  await getProfile();
69
71
  }
70
72
  };
@@ -111,10 +113,7 @@ export const useUser = (apiService) => {
111
113
 
112
114
  const signOut = () => {
113
115
  const { generateLink } = useGenerateLink();
114
- const cookie = useCookie('jwtoken', { path: '/', secure: true, httpOnly: false });
115
- cookie.value = undefined; // или null
116
- const domain = useRuntimeConfig().public.xDomain;
117
- document.cookie = `jwtoken=; path=/; domain=${domain}; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure`;
116
+ clearToken();
118
117
 
119
118
  const profile = useState<IProfileData | null>('profileData');
120
119
  profile.value = null;
@@ -124,7 +123,7 @@ export const useUser = (apiService) => {
124
123
  };
125
124
 
126
125
  return {
127
- isAuthorized: computed(() => !!useCookie('jwtoken').value),
126
+ isAuthorized: computed(() => !!authCookie.value),
128
127
  register,
129
128
  login,
130
129
  password,
@@ -6,6 +6,7 @@ import {
6
6
  localeDomains,
7
7
  localeFromHost,
8
8
  localesWithDomains,
9
+ sharedCookieDomain,
9
10
  subdomainsOf,
10
11
  } from '../locale-domains';
11
12
 
@@ -36,6 +37,15 @@ describe('locale-domains', () => {
36
37
  expect(baseDomainFromHost('api.a.com', SUBS)).toBe('api.a.com');
37
38
  });
38
39
 
40
+ it('общий домен cookie: базовый с точкой, без поддоменов и на localhost/IP — host-only', () => {
41
+ expect(sharedCookieDomain('ru.a.com', SUBS)).toBe('.a.com');
42
+ expect(sharedCookieDomain('a.com', SUBS)).toBe('.a.com');
43
+ expect(sharedCookieDomain('jp.stage.a.com:8080', SUBS)).toBe('.stage.a.com');
44
+ expect(sharedCookieDomain('ru.localhost:3000', SUBS)).toBeUndefined();
45
+ expect(sharedCookieDomain('192.168.1.5:3000', SUBS)).toBeUndefined();
46
+ expect(sharedCookieDomain('ru.a.com', {})).toBeUndefined();
47
+ });
48
+
39
49
  it('локаль по хосту, иначе дефолтная', () => {
40
50
  expect(localeFromHost('jp.a.com', SUBS, 'en')).toBe('ja');
41
51
  expect(localeFromHost('a.com', SUBS, 'en')).toBe('en');
@@ -14,6 +14,12 @@
14
14
  /** Имя cookie явного выбора языка. Значение — поддомен локали, у дефолтной пусто. */
15
15
  export const LANG_COOKIE = 'lang';
16
16
 
17
+ /**
18
+ * Query, в котором переключатель языка переносит ручной выбор страны на хост другой локали, когда
19
+ * общей cookie нет (localhost, IP). Читает и тут же убирает server/middleware/country.ts.
20
+ */
21
+ export const COUNTRY_CARRY_PARAM = 'country';
22
+
17
23
  /** Код локали → её поддомен. У дефолтной локали поддомена нет. */
18
24
  export type LocaleSubdomains = Readonly<Record<string, string>>;
19
25
 
@@ -63,6 +69,20 @@ export function baseDomainFromHost(host: string, subdomains: LocaleSubdomains):
63
69
  return Object.values(subdomains).includes(firstLabel) ? clean.slice(firstLabel.length + 1) : clean;
64
70
  }
65
71
 
72
+ /**
73
+ * Атрибут `domain` для cookie, общих для всех языковых поддоменов (выбор языка, страны):
74
+ * `.a.com` с `ru.a.com` и `a.com`. Без поддоменов, на localhost и IP — `undefined` (host-only):
75
+ * domain-атрибут без точки в имени браузеры там отбрасывают.
76
+ */
77
+ export function sharedCookieDomain(host: string, subdomains?: LocaleSubdomains | null): string | undefined {
78
+ if (!hasLocaleSubdomains(subdomains)) return undefined;
79
+
80
+ const base = baseDomainFromHost(host, subdomains as LocaleSubdomains).replace(/:\d+$/, '');
81
+ if (base === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(base)) return undefined;
82
+
83
+ return `.${base}`;
84
+ }
85
+
66
86
  /** Код локали по фактическому хосту (по языковому поддомену), иначе дефолтная. */
67
87
  export function localeFromHost(host: string, subdomains: LocaleSubdomains, defaultCode: string): string {
68
88
  const firstLabel = cleanHost(host).split('.')[0];
@@ -1,8 +1,10 @@
1
1
  // Глобальный auth-middleware: при наличии jwtoken-куки подтягивает профиль в
2
2
  // useState('profileData') один раз за сессию (если ещё не загружен). На 400/401
3
3
  // считает токен невалидным и чистит куку. Работает и на SSR, и на клиенте.
4
+ import { useAuthCookie } from '../composables/use-auth-cookie';
5
+
4
6
  export default defineNuxtRouteMiddleware(async () => {
5
- const cookie = useCookie('jwtoken', { path: '/', secure: true, httpOnly: false, sameSite: 'lax' });
7
+ const { cookie, clearToken } = useAuthCookie();
6
8
  if (!cookie.value) return;
7
9
 
8
10
  const profileData = useState<any>('profileData', () => null);
@@ -23,11 +25,7 @@ export default defineNuxtRouteMiddleware(async () => {
23
25
  profileData.value = await fetchProfile();
24
26
  } catch (error: any) {
25
27
  if (error?.statusCode === 400 || error?.statusCode === 401) {
26
- cookie.value = null;
27
- if (import.meta.client) {
28
- // Nuxt-кука не всегда удаляется на клиенте с нужным domain — дочищаем вручную
29
- document.cookie = `jwtoken=; path=/; domain=${runtimeConfig.public.xDomain}; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure`;
30
- }
28
+ clearToken();
31
29
  } else {
32
30
  console.error('Error get profile:', error);
33
31
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1007",
4
+ "version": "0.0.1009",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -6,6 +6,7 @@
6
6
  // сюда не кладётся. Полный список стран лениво догружает селектор языка на клиенте.
7
7
  import { getCountryPlace } from '../lib/countries';
8
8
  import { getPathLocale } from '../runtime/utils/server/get-path-locale';
9
+ import { hasLocaleSubdomains, localeFromHost, type LocaleSubdomains } from '../config/locale-domains';
9
10
 
10
11
  /** Опции легаси-кук — нужны только чтобы их погасить (см. ниже) */
11
12
  const LEGACY_COOKIE_OPTS = { path: '/', secure: true, sameSite: 'lax' as const };
@@ -26,7 +27,14 @@ export default defineNuxtPlugin(async () => {
26
27
 
27
28
  if (!code) return;
28
29
 
29
- const { codes, default: defaultCode } = useRuntimeConfig().public.locales as { codes: string[], default: string };
30
+ const { codes, default: defaultCode, subdomains } = useRuntimeConfig().public.locales as {
31
+ codes: string[], default: string, subdomains?: LocaleSubdomains
32
+ };
33
+ // Локаль i18n тут ещё не готова: у локалей на поддоменах она в хосте, у префиксных — в пути
34
+ const host = event?.node.req.headers['x-forwarded-host'] as string || event?.node.req.headers.host || '';
35
+ const locale = hasLocaleSubdomains(subdomains)
36
+ ? localeFromHost(host, subdomains as LocaleSubdomains, defaultCode)
37
+ : getPathLocale(event?.path || '', codes, defaultCode);
30
38
 
31
- countryPlace.value = await getCountryPlace(code, getPathLocale(event?.path || '', codes, defaultCode));
39
+ countryPlace.value = await getCountryPlace(code, locale);
32
40
  });
@@ -1,7 +1,19 @@
1
1
  // Определяет страну клиента и кладёт ISO-код в event.context.clientCountryCode:
2
2
  // сначала из cookie `clientCountryCode` (ручной выбор в селекторе), иначе из
3
3
  // заголовков прокси (Cloudflare `cf-ipcountry` / `x-country-code` / `x-country`).
4
+ // Выбор, перенесённый переключателем языка в query (там, где общей cookie между языковыми
5
+ // поддоменами нет), кладётся в cookie этого хоста, а query убирается редиректом.
6
+ import { COUNTRY_CARRY_PARAM } from '../../config/locale-domains';
7
+
4
8
  export default defineEventHandler((event) => {
9
+ const carried = getQuery(event)[COUNTRY_CARRY_PARAM];
10
+ if (event.method === 'GET' && typeof carried === 'string' && /^[a-z]{2}$/i.test(carried)) {
11
+ setCookie(event, 'clientCountryCode', carried.toLowerCase(), { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' });
12
+ const url = getRequestURL(event);
13
+ url.searchParams.delete(COUNTRY_CARRY_PARAM);
14
+ return sendRedirect(event, `${url.pathname}${url.search}`, 302);
15
+ }
16
+
5
17
  const cookie = getCookie(event, 'clientCountryCode');
6
18
  if (cookie) {
7
19
  event.context.clientCountryCode = cookie.trim().toLowerCase();
@@ -44,6 +44,14 @@ describe('resolveLocaleRedirect', () => {
44
44
  expect(resolveLocaleRedirect({ ...base, method: 'POST' }).location).toBeUndefined();
45
45
  });
46
46
 
47
+ it('переход с самого сайта — выбор языка, без редиректа', () => {
48
+ const withReferer = (referer: string) => resolveLocaleRedirect({ ...base, headers: { ...base.headers, referer } });
49
+ expect(withReferer('https://jp.a.com/models')).toEqual({ vary: true });
50
+ expect(withReferer('https://a.com/')).toEqual({ vary: true });
51
+ expect(withReferer('https://google.com/search?q=a')).toEqual({ vary: true, location: 'https://jp.a.com/videos?page=2' });
52
+ expect(withReferer('not a url')).toEqual({ vary: true, location: 'https://jp.a.com/videos?page=2' });
53
+ });
54
+
47
55
  it('неизвестное значение cookie — как отсутствие выбора', () => {
48
56
  expect(resolveLocaleRedirect({ ...base, langCookie: 'xx' }).location).toBe('https://jp.a.com/videos?page=2');
49
57
  });
@@ -33,6 +33,17 @@ function isKnownLangChoice(value: string, subdomains: LocaleSubdomains, codes: r
33
33
  return !sub || Object.values(subdomains).includes(sub) || codes.includes(sub);
34
34
  }
35
35
 
36
+ /** Пришёл ли запрос со страницы этого же сайта: базовый домен Referer совпадает с текущим. */
37
+ function isSameSiteReferer(referer: string | undefined, host: string, subdomains: LocaleSubdomains): boolean {
38
+ if (!referer) return false;
39
+ try {
40
+ return baseDomainFromHost(new URL(referer).host, subdomains) === baseDomainFromHost(host, subdomains);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+
36
47
  export interface ILocaleRedirectInput {
37
48
  method: string
38
49
  path: string
@@ -54,7 +65,8 @@ export interface ILocaleRedirectDecision {
54
65
  *
55
66
  * Не уводит: не-GET, служебные пути и не-html, ботов (иначе поисковик индексирует не то,
56
67
  * что объявлено в canonical/hreflang), заход уже на языковой поддомен, явный выбор в cookie
57
- * `lang`.
68
+ * `lang`, переход с самого сайта (Referer на том же базовом домене) — это выбор языка
69
+ * переключателем, а cookie могла не доехать: на localhost она живёт только на своём поддомене.
58
70
  */
59
71
  export function resolveLocaleRedirect(input: ILocaleRedirectInput): ILocaleRedirectDecision {
60
72
  const { method, path, headers, langCookie, subdomains, codes, defaultCode } = input;
@@ -74,6 +86,8 @@ export function resolveLocaleRedirect(input: ILocaleRedirectInput): ILocaleRedir
74
86
 
75
87
  if (langCookie !== undefined && isKnownLangChoice(langCookie, subdomains, codes)) return skip;
76
88
 
89
+ if (isSameSiteReferer(headers['referer'], host, subdomains)) return { vary: true };
90
+
77
91
  const acceptLanguage = headers['accept-language'] || '';
78
92
  if (!acceptLanguage) return skip;
79
93