Compare commits

..

7 Commits

Author SHA1 Message Date
gorbenko.pavel ec13410945 Реестр пакетов: организация Tansu переименована в tansu (#2)
test / test (push) Successful in 20s
Организацию на git.tnsu.kz переименовали в tansu. Адрес реестра @tnsu в .npmrc и resolved в package-lock.json приведены к новому имени. Gitea принимает оба написания, так что сборка работает и до влития.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: #2
2026-10-29 12:46:12 +00:00
Тимур Городецкий 86f4ec379a feat: иконка chair — кресло для раздела «Мебель» (0.15.0)
test / test (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:19:27 +05:00
gorbenko.pavel 84964b62c1 Merge pull request 'Пайплайны test и publish, публикация в реестр организации Tansu' (#1) from feature/publish-pipeline into main
test / test (push) Successful in 15s
2026-10-28 12:49:04 +00:00
gorbenko.pavel 65fc6ced30 Публикация ui-kit: нужен только секрет PACKAGES_TOKEN, логин не используется
test / test (pull_request) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:31:00 +05:00
gorbenko.pavel 6fd3672edc Ссылка на репозиторий — прежняя репа timur.gorodetskiy/tnsu-ui-kit
test / test (pull_request) Successful in 14s
Код остаётся в этой репе, публикация по-прежнему в реестр организации Tansu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:23:56 +05:00
gorbenko.pavel 2a32b998f6 Пайплайны test и publish, реестр — организация Tansu
- test: юнит-тесты и сборка на каждый push в main, на PR и по кнопке
- publish: по кнопке тесты и публикация в реестр Tansu; ci/publish.sh сам считает патч-версию
  по реестру (major.minor — из package.json) и не публикует, если хэш кода совпал с последней
  опубликованной версией
- .npmrc и publishConfig — реестр Tansu, repository — Tansu/tnsu-ui-kit
- README: установка без токена, публикация кнопкой

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:19:14 +05:00
Тимур Городецкий 048fec0696 feat: Sidebar — разделители и перенос длинных названий (0.14.0)
Проп dividers рисует тонкие линии между пунктами (подпункты раскрытой
группы — без линий). Длинное название пункта переносится на вторую
строку вместо обрезки многоточием (не больше 2 строк).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 17:54:45 +05:00
14 changed files with 257 additions and 49 deletions
+3
View File
@@ -0,0 +1,3 @@
# Скрипты и пайплайны запускаются на Linux-раннере: только LF.
*.sh text eol=lf
.gitea/**/*.yml text eol=lf
+34
View File
@@ -0,0 +1,34 @@
name: publish
# Публикация @tnsu/ui-kit в реестр организации tansu — только по кнопке (Actions -> publish -> Run workflow).
# Сначала юнит-тесты и сборка, потом ci/publish.sh: версию он считает сам (major.minor — из package.json,
# патч — следующий после опубликованного), а если код не менялся с последней опубликованной версии,
# падает с «Публиковать нечего».
#
# Секрет: PACKAGES_TOKEN — токен Gitea со scope write:package (его владелец должен состоять в организации
# tansu с правом записи). Логин не нужен: npm авторизуется одним токеном.
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install
run: npm ci --no-audit --no-fund
- name: Unit tests
run: npm test
- name: Publish
env:
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: bash ci/publish.sh
+29
View File
@@ -0,0 +1,29 @@
name: test
# Юнит-тесты и сборка пакета: на каждый push в main, на pull request и по кнопке.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install
run: npm ci --no-audit --no-fund
- name: Unit tests
run: npm test
- name: Build
run: npm run build
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ @tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+4 -4
View File
@@ -169,11 +169,11 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
## Публикация (Gitea, НЕ GitLab!) ## Публикация (Gitea, НЕ GitLab!)
- Хостинг git — **Gitea 1.26.4** на http://git.tnsu.kz (не GitLab). Registry npm привязан - Хостинг git — **Gitea 1.26.4** на https://git.tnsu.kz (не GitLab). Registry npm привязан
к ВЛАДЕЛЬЦУ, не к проекту. Никакого "project ID" не нужно. к ВЛАДЕЛЬЦУ, не к проекту. Никакого "project ID" не нужно.
- Адрес registry: `http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/` - Адрес registry: `https://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
- `.npmrc` в проекте (можно коммитить, НЕ секрет): - `.npmrc` в проекте (можно коммитить, НЕ секрет):
`@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/` `@tnsu:registry=https://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/`
- Токен (СЕКРЕТ) — в `~/.npmrc` (C:\Users\gorodetskiy.timur\.npmrc), в git НЕ попадает: - Токен (СЕКРЕТ) — в `~/.npmrc` (C:\Users\gorodetskiy.timur\.npmrc), в git НЕ попадает:
`//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=<TOKEN>` `//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=<TOKEN>`
- Токен создаётся в Gitea: аватар → Настройки → Приложения → права `package` = Чтение и запись. - Токен создаётся в Gitea: аватар → Настройки → Приложения → права `package` = Чтение и запись.
@@ -188,4 +188,4 @@ StatusBadge переписан в тонкий маппер REQUEST_STATUS→var
- Node: v22.x (как в SPFx: >=22.14 <23) - Node: v22.x (как в SPFx: >=22.14 <23)
- React 17.0.1, @fluentui/react ^8.106.4, tslib 2.3.1 - React 17.0.1, @fluentui/react ^8.106.4, tslib 2.3.1
- GitLab: http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git - GitLab: https://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git
+22 -37
View File
@@ -4,40 +4,15 @@
> Внутренняя память проекта (решения, план, прогресс) — в [PROJECT.md](./PROJECT.md). > Внутренняя память проекта (решения, план, прогресс) — в [PROJECT.md](./PROJECT.md).
## Первичная настройка (один раз на каждого разработчика)
Пакет лежит в приватном registry (Gitea), поэтому перед первой установкой нужно один
раз настроить доступ. Дальше про это можно забыть.
**Шаг 1. Создать токен в Gitea:**
1. Открой http://git.tnsu.kz → кликни на свой аватар справа вверху → **Настройки**.
2. Слева: **Приложения**.
3. В блоке «Управление токенами» → **Создать новый токен**:
- Имя: например `npm-read`.
- Права (scopes): найди строку **`package`** и поставь **«Прочитанные»** (Read).
Остальное — «Нет доступа».
4. Нажми **Генерировать токен** и **сразу скопируй** его (показывается один раз).
**Шаг 2. Прописать токен у себя на компьютере.**
Создай (или дополни) файл `.npmrc` в своей домашней папке
(`C:\Users\<твоё-имя>\.npmrc`) одной строкой:
```
//git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ВСТАВЬ_СВОЙ_ТОКЕН
```
> ⚠️ Токен — это как пароль. Он лежит в домашней папке, **вне проекта**, и в git не попадает.
> Никогда не коммить его и не вставляй в код.
Всё. Теперь `npm i` в любом проекте, где подключён `@tnsu/ui-kit`, будет работать.
## Установка в проект ## Установка в проект
Убедись, что в корне проекта есть файл `.npmrc` со строкой (для request-portal он уже Пакет лежит в реестре организации **tansu** на git.tnsu.kz. Реестр публичный, а сам git.tnsu.kz
закоммичен): доступен только из внутренней сети, поэтому **токен для установки не нужен** (из сети или по VPN).
В корне проекта — файл `.npmrc` со строкой:
``` ```
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ @tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
``` ```
Затем: Затем:
@@ -86,11 +61,21 @@ npm run storybook # витрина компонентов (localhost:6006)
### Публикация новой версии ### Публикация новой версии
```powershell Публикует пайплайн **publish** в Gitea — по кнопке: Actions → publish → Run workflow. Сначала юнит-тесты
# 1. поднять version в package.json (напр. 0.0.1 → 0.0.2) и сборка, потом публикация в реестр tansu.
npm run build
npm publish
```
> npm не даёт перезаписать уже опубликованную версию — перед публикацией всегда - **Версию поднимает пайплайн.** В `package.json` задаются только major.minor (патч там не важен):
> бампай `version` в package.json. новая функциональность — поднять minor руками (`0.14.x` → `0.15.0`). Патч — следующий после самого
большого опубликованного с тем же major.minor. В git ничего не коммитится.
- **Тот же код не публикуется.** Пайплайн считает хэш исходников (`src` без тестов и историй Storybook,
`package.json`, конфиги сборки, README), кладёт его в публикуемый `package.json` (поле `sourceHash`)
и сравнивает с последней опубликованной версией. Совпало — пайплайн падает с «Публиковать нечего».
- Секрет: `PACKAGES_TOKEN` — токен Gitea со scope `write:package` (владелец — участник организации tansu
с правом записи). Логин не нужен.
Пробный прогон без публикации: `DRY_RUN=1 bash ci/publish.sh`.
Версии до 0.14.0 скопированы из прежнего реестра `timur.gorodetskiy` без хэша, поэтому первая публикация
пайплайном выпустит `0.14.1` даже без изменений кода; дальше проверка работает как обычно.
На каждый push и pull request пайплайн **test** гоняет юнит-тесты и сборку.
Executable
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Публикация @tnsu/ui-kit в реестр организации tansu с автоматической версией.
#
# bash ci/publish.sh
#
# Версия: в package.json задаются только major.minor (патч там не важен). Патч считается здесь:
# следующий после самого большого опубликованного с тем же major.minor, или 0. В git ничего не коммитится.
#
# Хэш кода: sha256 от путей и git-хэшей файлов пакета (тесты и истории Storybook не входят). Кладётся
# в публикуемый package.json (поле sourceHash). Перед публикацией скачивается последняя опубликованная
# версия и хэш сравнивается: код тот же — публикации нет. Хэш берётся из самого архива, а не из
# метаданных реестра: архив реестр хранит как есть.
#
# Переменные: PACKAGES_TOKEN (scope write:package), REGISTRY_BASE (по умолчанию
# https://git.tnsu.kz/api/packages/tansu), DRY_RUN=1 — всё посчитать и собрать, но не публиковать.
#
# Коды выхода: 0 — опубликовано; 1 — ошибка; 3 — публиковать нечего (код не менялся).
set -euo pipefail
cd "$(dirname "$0")/.."
REGISTRY_BASE="${REGISTRY_BASE:-https://git.tnsu.kz/api/packages/tansu}"
DRY_RUN="${DRY_RUN:-}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
source_hash() {
git ls-files -s -- "$@" | grep -vE '\.(test\.ts|stories\.tsx)$' | awk '{print $2" "$4}' | sha256sum | cut -c1-16
}
max_version() {
node -e '
const v = require("fs").readFileSync(0, "utf8").split(/\s+/).filter((s) => /^\d+\.\d+\.\d+$/.test(s));
const key = (s) => s.split(".").map(Number);
v.sort((a, b) => { const x = key(a), y = key(b); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; });
process.stdout.write(v.length ? v[v.length - 1] : "");
'
}
next_version() {
local repo_version="$1" published="$2"
node -e '
const [repo, published] = [process.argv[1], process.argv[2]];
const [major, minor] = repo.split(".").map(Number);
const versions = published.split(/\s+/).filter((s) => /^\d+\.\d+\.\d+$/.test(s)).map((s) => s.split(".").map(Number));
const newer = versions.find(([a, b]) => a > major || (a === major && b > minor));
if (newer) { console.error(`В реестре уже есть ${newer.join(".")}, а в репозитории ${major}.${minor}: поднимите версию в репозитории.`); process.exit(1); }
const patches = versions.filter(([a, b]) => a === major && b === minor).map(([, , c]) => c);
process.stdout.write(`${major}.${minor}.${patches.length ? Math.max(...patches) + 1 : 0}`);
' "$repo_version" "$published"
}
# Реестр публичный, читать можно без токена.
fetch() {
local url="$1" output="$2" status
status=$(curl -sS -o "$output" -w '%{http_code}' "$url") || status=000
echo "$status"
}
hash=$(source_hash src package.json vite.config.ts tsconfig.json global.d.ts README.md)
repo_version=$(node -p "require('./package.json').version")
latest="" published="" latest_hash=""
status=$(fetch "$REGISTRY_BASE/npm/@tnsu%2Fui-kit" "$WORK/packument.json")
case "$status" in
200)
published=$(node -e 'process.stdout.write(Object.keys(require(process.argv[1]).versions || {}).join(" "))' "$WORK/packument.json")
latest=$(echo "$published" | tr ' ' '\n' | max_version)
;;
404) ;;
*) echo "Реестр npm ответил $status" >&2; cat "$WORK/packument.json" >&2 || true; exit 1 ;;
esac
if [[ -n $latest ]]; then
tarball=$(node -e 'process.stdout.write(require(process.argv[1]).versions[process.argv[2]].dist.tarball)' "$WORK/packument.json" "$latest")
status=$(fetch "$tarball" "$WORK/latest.tgz")
[[ $status == 200 ]] || { echo "Не скачался @tnsu/ui-kit@$latest: $status" >&2; exit 1; }
latest_hash=$(tar -xzOf "$WORK/latest.tgz" package/package.json | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0, "utf8")).sourceHash || "")')
fi
echo "хэш кода $hash; последняя опубликованная ${latest:-нет} (хэш ${latest_hash:-нет})"
if [[ -n $latest_hash && $latest_hash == "$hash" ]]; then
echo "Публиковать нечего: код не менялся с $latest." >&2
exit 3
fi
version=$(next_version "$repo_version" "$published")
echo "публикую $version"
# Версия и хэш — только в рабочей копии на время публикации.
cp package.json "$WORK/package.json.orig"
trap 'cp "$WORK/package.json.orig" package.json 2> /dev/null; rm -rf "$WORK"' EXIT
node -e '
const fs = require("fs"); const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
p.version = process.argv[1]; p.sourceHash = process.argv[2];
fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");
' "$version" "$hash"
npm run build > /dev/null
if [[ -n $DRY_RUN ]]; then
npm pack --dry-run 2>&1 | grep -E 'name:|version:|total files'
echo "DRY_RUN: не публикую"
else
printf '//%s:_authToken=%s\n' "${REGISTRY_BASE#*://}/npm/" "$PACKAGES_TOKEN" > "$WORK/npmrc"
NPM_CONFIG_USERCONFIG="$WORK/npmrc" npm publish
fi
echo "Готово: @tnsu/ui-kit@$version"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.13.0", "version": "0.15.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.13.0", "version": "0.15.0",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "latest", "@chromatic-com/storybook": "latest",
+5 -2
View File
@@ -1,12 +1,15 @@
{ {
"name": "@tnsu/ui-kit", "name": "@tnsu/ui-kit",
"version": "0.13.0", "version": "0.15.0",
"description": "TNSU shared UI components for SPFx web parts", "description": "TNSU shared UI components for SPFx web parts",
"author": "Timur Gorodetskiy", "author": "Timur Gorodetskiy",
"license": "ISC", "license": "ISC",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "http://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git" "url": "https://git.tnsu.kz/timur.gorodetskiy/tnsu-ui-kit.git"
},
"publishConfig": {
"registry": "https://git.tnsu.kz/api/packages/tansu/npm/"
}, },
"type": "module", "type": "module",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
+5
View File
@@ -78,6 +78,11 @@ export const IconSprite: React.FC = () => (
<circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" /> <circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" /> <circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
</symbol> </symbol>
<symbol id="i-chair" viewBox="0 0 24 24">
<path d="M6 10V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4" />
<path d="M4 16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4a2 2 0 0 0-4 0v1H8v-1a2 2 0 0 0-4 0z" />
<path d="M6 18v2M18 18v2" />
</symbol>
<symbol id="i-chat" viewBox="0 0 24 24"> <symbol id="i-chat" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-5.46-1.84L3 21l1.84-3.54A9 9 0 1 1 21 12z" /> <path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-5.46-1.84L3 21l1.84-3.54A9 9 0 1 1 21 12z" />
</symbol> </symbol>
+1
View File
@@ -16,6 +16,7 @@ export const ICON_NAMES = [
'clipboard', 'clipboard',
'people', 'people',
'vehicle', 'vehicle',
'chair',
'chat', 'chat',
'camera', 'camera',
'image', 'image',
+19 -3
View File
@@ -99,7 +99,8 @@
text-align: left; text-align: left;
// Пункт-ссылка (<a>) выглядит так же, как пункт-кнопка. // Пункт-ссылка (<a>) выглядит так же, как пункт-кнопка.
text-decoration: none; text-decoration: none;
white-space: nowrap; // Длинное название переносится (не больше 2 строк — см. .itemTitle), а не обрезается многоточием.
white-space: normal;
cursor: pointer; cursor: pointer;
transition: color .18s, background .18s, border-color .18s; transition: color .18s, background .18s, border-color .18s;
@@ -130,11 +131,26 @@
color: inherit; color: inherit;
} }
// Не больше 2 строк: совсем длинное название обрезается многоточием на второй строке.
.itemTitle { .itemTitle {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; line-height: 1.3;
overflow-wrap: anywhere;
}
// ---- Линии между пунктами (проп dividers) ----
// Линия — над каждым пунктом, кроме первого; подпункты раскрытой группы лежат внутри .group — их не трогаем.
.navDividers .items {
gap: 0;
> * + * {
border-top: 1px solid rgba(255, 255, 255, .06);
}
} }
.badge { .badge {
@@ -189,7 +205,7 @@ $sub-item-indent: 48px; // 18px отступ пункта + 3px маркер +
font-size: 13px; font-size: 13px;
text-align: left; text-align: left;
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: normal;
cursor: pointer; cursor: pointer;
transition: color .18s, background .18s; transition: color .18s, background .18s;
+20
View File
@@ -66,6 +66,7 @@ export const Collapsed: Story = {
// Пункт с children — раскрывающаяся группа; подпункт с href открывается в новой вкладке. // Пункт с children — раскрывающаяся группа; подпункт с href открывается в новой вкладке.
const ITEMS_WITH_GROUP: ISidebarItem[] = [ const ITEMS_WITH_GROUP: ISidebarItem[] = [
...ITEMS.slice(0, 3), ...ITEMS.slice(0, 3),
{ id: 'long', title: 'Загрузить работы из сметы проекта', icon: 'download' },
{ {
id: 'catalogs', id: 'catalogs',
title: 'Справочники', title: 'Справочники',
@@ -96,6 +97,25 @@ export const WithGroupAndLinks: Story = {
}, },
}; };
// Линии между пунктами; длинное название переносится на вторую строку.
export const WithDividers: Story = {
render: () => {
const [active, setActive] = useState('approvals');
const [collapsed, setCollapsed] = useState(false);
return (
<Sidebar
items={ITEMS_WITH_GROUP}
activeId={active}
onChange={setActive}
brand="Портал заявок"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
dividers
/>
);
},
};
// Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook) // Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook)
// ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента. // ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента.
export const MobileDrawer: Story = { export const MobileDrawer: Story = {
+4
View File
@@ -24,6 +24,8 @@ export interface ISidebarProps {
mobileOpen?: boolean; mobileOpen?: boolean;
/** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */ /** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */
onMobileClose?: () => void; onMobileClose?: () => void;
/** Тонкие линии между пунктами (подпункты раскрытой группы — без линий, они читаются как одно целое). */
dividers?: boolean;
} }
/** /**
@@ -43,11 +45,13 @@ export const Sidebar: React.FC<ISidebarProps> = ({
onToggleCollapsed, onToggleCollapsed,
mobileOpen = false, mobileOpen = false,
onMobileClose, onMobileClose,
dividers = false,
}) => { }) => {
const navClasses = [ const navClasses = [
styles.nav, styles.nav,
collapsed ? styles.navCollapsed : '', collapsed ? styles.navCollapsed : '',
mobileOpen ? styles.navMobileOpen : '', mobileOpen ? styles.navMobileOpen : '',
dividers ? styles.navDividers : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');