itube-specs

Общий **Nuxt Layer** для сайтов itube. Держит единую базу: компоненты, composables, сервисы, утилиты, runtime-хелперы, типы, lib-данные, middleware, SSR-плагины, BFF-хендлеры (`server/api`) и **весь SCSS — палитру, темы, миксины и глобальные стили**. Прил
106,633
Install
npm install itube-specs
Latest Version:0.0.1051
Vue:3
Last Updated:Oct 21, 2026
Links: npm
Maintainer: extern85

itube-specs

Общий Nuxt Layer для сайтов itube. Держит единую базу: компоненты, composables, сервисы, утилиты, runtime-хелперы, типы, lib-данные, middleware, SSR-плагины, BFF-хендлеры (server/api) и весь SCSS — палитру, темы, миксины и глобальные стили. Приложения расширяют слой (extends: ['itube-specs']) и форкаются под разные сайты одной ниши — поэтому слой должен оставаться самодостаточным и site-agnostic.

Bitbucket: https://bitbucket.org/luckytube/specs/src/master/

Заводишь новый сайт поверх слоя — NEW_SITE_CHECKLIST.md.


Стек

  • Nuxt 3 + Vue 3.5, TypeScript
  • @nuxt/icon, @nuxtjs/i18n, @nuxt/eslint — как модули слоя
  • ESLint + Stylelint (гейтят на ноль варнингов), Vitest (юнит)
  • Публикуется в npm; приложения ставят опубликованную версию

Требования: Node >=22.12.


Как работать со слоем

Слой публикуется в npm, прод/CI собираются с опубликованной копии (node_modules/itube-specs). Локально приложение подсасывает слой напрямую через симлинк — правки видны сразу, без публикации.

Локальная разработка (авто-линк из приложения)

В приложении npm run dev перед стартом (хук predev) вешает симлинк node_modules/itube-specs → LAYER_PATH (путь берётся из .env приложения). Дальше:

  1. Правим слой прямо здесь (по LAYER_PATH) — изменения видны на локалке приложения сразу (HMR), cmd+click по сущностям слоя ведёт в эти исходники.
  2. Правки готовы: коммит в слое → npm run patch → пуш → мерж → публикация.
  3. В приложении: npm run spec (npm install itube-specs@latest) тянет опубликованную версию (симлинк заменяется реальным пакетом), коммит, пуш на прод.

npm run dev:published в приложении — дев против опубликованной версии, минуя авто-линк.

npm install и другие команды, меняющие зависимости, в этом репозитории не запускаем — зависимости и публикацию ведёт мейнтейнер вручную. Править package.json можно, ставить пакеты — нет.

Проектные оверрайды

Приоритет авто-импорта в приложении: проектные components//composables/ > слой.

Приложение может завести файл с тем же именем, что сущность слоя (напр. <UiBtn>), в своих components//composables/ — победит проектный (он выше слоя). Так форк меняет вёрстку/логику под себя без переименования. Осмысленно: если разницу решает пропс — делать пропсом, а не форкать весь компонент.


Команды

npm run lint         # ESLint (гейтит на ноль варнингов)
npm run lint:fix     # ESLint --fix
npm run lint:css     # Stylelint по **/*.{scss,vue}
npm run lint:css:fix # Stylelint --fix
npm run check:themes # сверяет наборы имён токенов во всех темах
npm run check:styles # компилирует стили каждого SFC каждой темой (~1.5s)
npm run check:breakpoints # сверяет JS-зеркало брейкпоинтов с $brkpnts в SCSS
npm run i18n:check   # отчёт по общим переводам (не гейт)
npm run i18n:sheet:export # общие локали → i18n/i18n.csv для переводчика
npm run i18n:sheet:import # i18n/i18n.csv → общие локали
npm run test         # vitest однократно
npm run patch        # npm version patch — бамп версии перед публикацией

Pre-commit (husky + lint-staged): на закоммиченных файлах прогоняются eslint --fix и stylelint --fix. Если в коммите есть assets/scss/themes/**, гоняется check:themes; если любой .vue или assets/scss/** — check:styles; если пара файлов брейкпоинтов — check:breakpoints. Все три есть и в CI, в шаге Lint. ⚠️ Шаблоны .vue не типизируются вообще. Обычный tsc их не читает, а vue-tsc не подключён (накопился бэклог ошибок в шаблонах, подключение — отдельная задача). Значит чистый tsc ничего не говорит про выражения в шаблоне: арифметика над строковым union, неверный тип пропа, переименованное поле пройдут молча.


Структура

components/    # общие компоненты (pathPrefix: имя = папка + файл)
composables/   # автоимпортируемые composables (+ fetch/, __tests__/)
services/      # синглтон-клиенты внешнего API (services/api/*) + SiteDataService
utils/         # доменные утилиты (file = function, co-located __tests__/)
runtime/       # runtime-значения и хелперы (barrel index.ts)
  constants/   # доменные `as const` (Niche, PlaylistType, AdSpotType, …)
  utils/       # чистые хелперы, cleaners/, converters/
types/         # .d.ts типы (barrel index.d.ts — с расширением .d.ts в реэкспортах)
lib/           # продуктовые статические данные (barrel index.ts): scheme/*, *-items, *-scheme
i18n/locales/  # общие переводы, по файлу на локаль (см. «Переводы»)
config/        # build-time конфиг для приложения (barrel index.ts): themes, css-breakpoints
middleware/    # глобальные: auth.global, normalize.global, age.global
plugins/       # SSR/клиент: country/countries/mobile (SSR), services.client, sentry.client, adv
server/        # BFF: api/** (тонкие прокси к внешнему API), utils/metrics, plugins/*, tasks
assets/
  icons/       # спрайты/svg для <UiIcon>
  scss/
    vars/      # _palette (сырые $color-*), _vars (скаляры), _tokens (CSS custom props), _z-index, _index
    themes/    # по папке на тему: light/_theme.scss, dark/_theme.scss — токены по компонентам
    mixins/    # font-sizes, breakpoints, accessibility, page, video-card, surface, button
    base/      # document, global, hacks, animations, loading
    layout/    # layout, page
    main.scss  # точка входа: vars/tokens + base + layout

middleware/plugins/server и каждая подпапка assets/scss/* обязаны быть в files (whitelist) — иначе не попадут в паблиш (см. раздел files/exports).


Темы

Темы живут здесь, по папке на тему, и раздаются всем сайтам:

assets/scss/themes/light/_theme.scss
assets/scss/themes/dark/_theme.scss

Приложение только выбирает одну — theme: в его config/site.config.ts. Оттуда значение читают и app.config.ts (в рантайм), и nuxt.config.ts, который добавляет themes/<theme>/ в Sass loadPaths. @forward 'theme' в vars/_index.scss резолвится туда, потому что рядом с ним _theme.scss нет.

Папка-на-тему обязательна: Sass требует литеральный путь в @use/@forward, значит имя файла всегда _theme.scss, а варьироваться может только папка. Плоские themes/light.scss потребовали бы @forward 'themes/light' — литерал, который нельзя подставить на билде.

Приоритет loadPaths — приложение первым, поэтому сайт может перекрыть любой партиал по имени: свой assets/scss/vars/_theme.scss в проекте выиграет у темы слоя (относительный резолв Sass старше loadPaths). Это точка пер-сайтовой темизации.

Все токены обязаны быть во всех темах. Отсутствующий токен — жёсткая ошибка Sass в момент, когда тема выбрана, то есть падает вся сборка сайта, а не «поехал цвет». Это гейтит npm run check:themes (pre-commit при правке тем + CI). Сверяются только верхнеуровневые $name:, поэтому начинка $surfaces / $button-variant-overrides игнорируется — она по замыслу разная.

Паритет доказывает лишь то, что темы согласованы между собой. Токен, использованный в компоненте, но не заведённый ни в одной теме, эту проверку пройдёт и всё равно уронит сборку — для этого есть npm run check:styles: он достаёт каждый <style lang="scss">, приписывает тот же @use-префикс, что подставляет приложение, и компилирует по разу на тему. То же, что делает Vite, — значит ловит и неизвестные миксины, и неверные аргументы @include, и показывает, в какой теме падает.

Промежуточного семантического слоя нет — темы ссылаются на палитру напрямую, без тира вида $text-muted / $role-border-subtle. Сайты форкаются с принципиально разными схемами, и каждому селектору может понадобиться произвольный цвет, а не одна из горстки ролей. Такой слой либо оброс бы ролью на селектор (и перестал быть семантическим), либо компоненты начали бы тянуться в обход него. Имя токена по компоненту, который он обслуживает, держит соответствие честным.

Контекстные различия (контрол внутри попапа, вариант кнопки, зависящий от темы) решают миксины surface() и button-variant() из mixins/: место объявляет себя, а значения даёт тема через карты $surfaces / $button-variant-overrides. Компоненты про темы не знают. appConfig.theme — только для структурных различий (v-if, другой элемент), никогда для цвета.


Переводы

Ключи разделены на два набора, как темы: общая часть здесь, пер-сайтовая в приложении.

Слой, i18n/locales/*.json обвязка интерфейса, одинаковая на всех сайтах: навигация, сортировки и фильтры, кнопки-глаголы, пагинация, плеер, авторизация, снекбары, формы репорта и контактов, ошибки, плюрали, длительности, единицы, атрибуты модели
Приложение, i18n/locales/*.json только пер-сайтовое: SEO (meta.*, h1_*, *_footer_text, карты *_footer_links), нишевая лексика и брендовые формулировки, плюс точечные оверрайды общих ключей

Граница проходит по значению, а не по имени ключа: all_categories («Alphabetical A-Z») — общий, main_categories («All Gay Porn Categories (A-Z)») — сайтовый, хотя имена похожи.

Как склеивается

@nuxtjs/i18n мержит локали слоёв сам, вручную ничего подключать не надо. Файл слоя попадает в files локали раньше проектного, значит грузится первым, а проектный ложится поверх. Мерж глубокий, поэтому приложение может перекрыть один ключ внутри общей группы, не переобъявляя всю группу.

Направление одно: приложение всегда сильнее. Слой не может «дожать» значение — если сайт держит свой ключ, победит он.

Коды локалей выводятся из имён файлов здесь (zh-CN.json → zh), поэтому набор файлов слоя — канонический. Код, которого нет в LOCALES приложения, всё равно доедет до него лишней локалью, так что наборы должны совпадать.

Куда добавлять новый ключ

  • Строка видна на всех сайтах → сюда, в начало i18n/locales/en-US.json («выше — новее»), дальше обычный цикл: коммит → npm run patch → пуш → мерж и публикация → в приложении npm run spec.
  • Строка про нишу, бренд или SEO → в приложение, слой не трогаем.

⚠️ Не копировать общий ключ в приложение «чтобы был». Дубль с тем же значением ничего не меняет, но замораживает перевод: правки в слое до этого сайта уже никогда не доедут. i18n:check в приложении зовёт такое мёртвым оверрайдом и показывает списком.

CSV-мост (работа с переводчиком)

Переводы никогда не правятся руками по локалям — только через CSV. Скрипты живут здесь (scripts/i18n-sheet.mjs, scripts/i18n-check.mjs), берут пути от cwd и публикуются в пакете, поэтому один и тот же код обслуживает оба набора: здесь npm run i18n:* работает с общими ключами, в приложении те же команды зовут копию из node_modules/itube-specs/scripts/ и работают с его собственными. У каждого набора свой i18n/i18n.csv (в git не хранится, пересобирается экспортом).

  1. npm run i18n:sheet:export — собирает CSV: колонка key, дальше по колонке на локаль, en первой. Новые ключи (заполнен только en) поднимаются наверх файла — переводчику сразу видно TODO.
  2. Переводчик правит в Excel / Numbers / Google Sheets и сохраняет обратно в CSV (.numbers/.xlsx не читаются). Разделитель определяется сам — ,, ; или таб, редакторы пишут по-разному.
  3. npm run i18n:sheet:import — раскладывает значения назад. Каждый файл пересобирается в своём порядке ключей (минимальный diff), непереведённый ключ остаётся отсутствующим, а не подменяется английским, чтобы отчёт продолжал его показывать.
  4. npm run i18n:check — отчёт, не гейт: рассинхрон с эталоном en-US, мёртвые оверрайды, а в приложении ещё и SEO keyword-ссылки.

Удаление ключей едет от en-US: импорт выкидывает лист, которого больше нет в эталоне, так что устаревший CSV не воскресит удалённый ключ.

Важное следствие двух наборов: непереведённый общий ключ чинится в CSV слоя — в CSV приложения он не появится вовсе. Отчёт приложения такие ключи показывает отдельным блоком «не переведено в слое» и не считает их своим рассинхроном.

Что мимо CSV

*_footer_links в приложении — это карты «keyword → URL категории», а не строки: сам ключ локализован (en: twinks, ru: твинки), наборы по локалям разные by design. Они исключены из CSV и из сверки ключей, курирует их разработчик/SEO прямо в JSON. Проверяется другое: каждый keyword должен встречаться в парном *_footer_text, иначе ссылка не отрендерится — это и гоняет i18n:check в приложении.

Правила плюрализации (i18n/plurals.ts) и numberFormats остаются в приложении, хотя сами строки plural.* общие: правила задаются на уровне локали, а не ключа.


Импорты

Внутри слоя — относительные пути (../../runtime, ../types, ../../services/api/...), никогда не через имя пакета. Приложения импортируют как itube-specs/runtime, itube-specs/services/..., itube-specs/utils/..., а типы — itube-specs/types.

Алиасы ~/…/@/… в файле слоя резолвятся на приложение, не на слой — в коде слоя их не используем. Сейчас таких app-зависимостей в слое не осталось: то, что раньше приходило из приложения (фича-флаги, ad-конфиг), теперь читается из runtimeConfig.public (значения задаёт приложение).

files / exports

package.json определяет, что публикуется и как резолвится. Добавляя новую top-level папку, которую импортируют приложения:

  • добавь её в files (иначе код не попадёт в пакет);
  • добавь exports: barrel'ы (types/runtime/lib) — явные index-энтри; extensionless-сабпасы (services/composables/utils) — паттерн с .ts ("./services/*": "./services/*.ts"); компоненты импортятся с .vue и идут через "./*": "./*".

Типы экспортируются из types/index.d.ts — обязательно с расширением .d.ts в реэкспортах.


Что живёт в слое, а что в приложении

  • Слой: компоненты, composables, API-сервисы, доменные утилиты, runtime-константы/хелперы, типы, lib/ (продуктовые данные), config/ (темы, брейкпоинты), middleware (auth/normalize/age), SSR-плагины (country/mobile/services/sentry/adv), BFF-хендлеры server/api/**, общие i18n-ключи (i18n/locales/ — навигация, сортировки, UI-обвязка) — всё site-agnostic.
  • Приложение: только per-site — значения config (флаги/реклама/локали) через runtimeConfig.public (слой держит пустые контейнеры-заглушки), свои i18n-ключи (SEO/ниша) и оверрайды общих, public/ и выбор темы (theme: в config/site.config.ts). Своего SCSS у приложения нет: assets/scss/ — пустой слот-оверрайд. server/ целиком в слое, включая locale-кластер.

Конвенции

Полные правила — в CLAUDE.md. Кратко:

  • Компоненты — pathPrefix: true: имя = папка + файл (ui/icon.vue → <UiIcon>), BEM-класс = kebab тега. Корень папки — index.vue (или main.vue, если иначе имя было бы односложным).
  • as const вместо enum — доменные наборы в runtime/constants/, тип через typeof X[keyof typeof X], значения строковые. Enum остаётся в бандле, в опубликованном пакете import type на нём ломается в рантайме, а там, где значение приходит из API/URL/куки, сырую строку в него не положить. Фреймворковые union'ы не оборачивать. Подробнее — в CLAUDE.md.
  • Стили — co-location в <style lang="scss"> без scoped, flat-BEM, только дизайн-токены. vars и mixins авто-инжектятся через additionalData приложения, поэтому в блоке компонента @use писать не надо. Сам sass в слое не установлен — SCSS компилируется внутри приложения, но все исходники (палитра, темы, миксины) лежат здесь.
  • Разметка — семантические теги, <div> только когда ничего не подходит.
  • TS/JS — всегда точка с запятой, без лишних комментариев.

Тесты

Vitest через @nuxt/test-utils. В слое — только тесты без app-специфичного окружения (чистая логика composables/utils/runtime + composable-тесты в nuxt-env без монтирования SFC). Компонентные тесты (mountSuspended) живут в приложении: в слое подделан i18n (авто-импорты useI18n/useLocalePath от @nuxtjs/i18n флапают недетерминированно) и нет SCSS-инъекции токенов. Значения runtimeConfig (featureFlags/adsConfig) в слое — пустые контейнеры, поэтому его код при бутстрапе в CI не падает, но реальные значения приходят только из приложения.

Version History

Version Released License
0.0.1051
0.0.1050
0.0.1049
0.0.1047
0.0.1046