@rt-tools/agent-kit 0.22.0 → 0.23.0

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.
Files changed (58) hide show
  1. package/assets/checks/board-long-work.github.mjs +101 -0
  2. package/assets/checks/board-runs.github.mjs +34 -0
  3. package/assets/checks/check-board.github.mjs +16 -2
  4. package/assets/checks/check-reuse.mjs +4 -1
  5. package/assets/checks/check-schema-drift.mjs +65 -6
  6. package/assets/checks/rt-kit-checks.config.mjs +13 -0
  7. package/assets/checks/signals.mjs +41 -1
  8. package/assets/commands/next-session.md +16 -5
  9. package/assets/defaults/project.sh +16 -0
  10. package/assets/hooks/browser-guard-no-asking.sh +5 -1
  11. package/assets/hooks/git-guard-delivery.sh +15 -11
  12. package/assets/hooks/git-guard-push-tests.sh +22 -0
  13. package/assets/hooks/glossary-load.sh +23 -2
  14. package/assets/hooks/grill-gate.sh +62 -0
  15. package/assets/laws/autonomous-work.md +30 -0
  16. package/assets/laws/project-documentation.md +8 -0
  17. package/assets/patterns/autonomous-work-run.md +105 -0
  18. package/assets/patterns/browser-verification-stand.md +1 -1
  19. package/assets/patterns/doc-style-write.md +16 -0
  20. package/assets/patterns/git-workflow-pr-ready.md +93 -0
  21. package/assets/patterns/git-workflow-pr.azure.md +1 -1
  22. package/assets/patterns/git-workflow-pr.github.md +1 -1
  23. package/assets/patterns/git-workflow-pr.gitlab.md +1 -1
  24. package/assets/patterns/task-flow-start.md +47 -47
  25. package/assets/patterns/ts-procedure.md +3 -2
  26. package/assets/rules/autonomous-work.md +92 -0
  27. package/assets/rules/browser-verification.md +6 -0
  28. package/assets/rules/deploy-flow.azure.md +7 -0
  29. package/assets/rules/deploy-flow.github.md +7 -0
  30. package/assets/rules/deploy-flow.gitlab.md +7 -0
  31. package/assets/rules/doc-style.md +18 -0
  32. package/assets/rules/git-workflow.azure.md +8 -0
  33. package/assets/rules/git-workflow.github.md +44 -45
  34. package/assets/rules/git-workflow.gitlab.md +8 -0
  35. package/assets/rules/reuse-first.md +17 -3
  36. package/assets/rules/task-flow.md +62 -63
  37. package/assets/skills/agent-kit.md +20 -20
  38. package/lib/commands.d.ts.map +1 -1
  39. package/lib/commands.js +6 -0
  40. package/lib/commands.js.map +1 -1
  41. package/lib/push-gate.d.ts +14 -0
  42. package/lib/push-gate.d.ts.map +1 -0
  43. package/lib/push-gate.js +93 -0
  44. package/lib/push-gate.js.map +1 -0
  45. package/package.json +1 -1
  46. package/rt-tools-agent-kit-0.23.0.tgz +0 -0
  47. package/assets/laws/application/access.md +0 -34
  48. package/assets/laws/application/locales.md +0 -33
  49. package/assets/laws/application/search-visibility.md +0 -24
  50. package/assets/patterns/permissions-procedure.md +0 -71
  51. package/assets/patterns/seo-page.md +0 -104
  52. package/assets/patterns/seo-verify.md +0 -83
  53. package/assets/patterns/translations-content.md +0 -107
  54. package/assets/patterns/translations-key.md +0 -64
  55. package/assets/rules/permissions.md +0 -116
  56. package/assets/rules/seo.md +0 -139
  57. package/assets/rules/translations.md +0 -96
  58. package/rt-tools-agent-kit-0.22.0.tgz +0 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Набор проверок перед пушем — итоговый и снятое из умолчания.
3
+ *
4
+ * Набор собирается из двух файлов: умолчания пакета и надстройки профиля дерева. Результат
5
+ * сборки не показывался нигде — чтобы его прочитать, надо было звать функцию профиля руками из
6
+ * оболочки. Из этой невидимости выросли три разных промаха: дерево дописало в надстройку команду,
7
+ * уже стоявшую в умолчании, и гоняло проверку дважды за пуш; другое дерево выкусило из умолчания
8
+ * сплошную проверку одной строкой `rt_push_checks_default "$@" | grep -v …` — строка выглядела
9
+ * настройкой, а была снятием охраны; а расхождение набора с конвейером узнавалось из красного
10
+ * конвейера после заявки.
11
+ *
12
+ * Набор ЗОВЁТСЯ, а не пересказывается чтением: он ветвится по содержимому дерева — есть ли
13
+ * настройка раскладки, есть ли конфиг линтера стилей, какие проверки разложены, — и разобранный
14
+ * по тексту функции разойдётся с настоящим молча.
15
+ *
16
+ * ОТКАЗ В ПОЛЬЗУ ЧИТАТЕЛЯ: нет профиля, нет функции, вызов упал или завис — раздела нет, а
17
+ * причина названа строкой. Разбор состояния ничего не пишет, и своим отказом чинить тут нечего.
18
+ */
19
+ import { execFileSync } from 'node:child_process';
20
+ import { existsSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ /** Имя функции профиля, собирающей набор, и имя её умолчания. */
23
+ const PUSH_CHECKS = 'rt_push_checks';
24
+ const PUSH_CHECKS_DEFAULT = `${PUSH_CHECKS}_default`;
25
+ /** Сколько ждать сборки набора. Сама она ничего не гоняет — печатает строки. */
26
+ const TIMEOUT_MS = 10_000;
27
+ /**
28
+ * Строки, которые вернула названная функция профиля. Профиль собирается из тех же файлов и в том
29
+ * же порядке, что читает сам гард: сперва разложенное умолчание пакета, поверх — надстройка
30
+ * дерева. Судить по одной надстройке значило бы получить пустой набор у всякого дерева, которое
31
+ * умолчаний не переписывало.
32
+ */
33
+ function callProfile(root, defaults, fn) {
34
+ const sources = [join(root, defaults, 'project.sh'), join(root, '.claude/rt-kit', 'project.sh')].filter((one) => existsSync(one));
35
+ if (!sources.length) {
36
+ return null;
37
+ }
38
+ // База пустая: с ней умолчание печатает полный набор, а не набор по вкладу ветки. Разбор
39
+ // состояния читают, чтобы увидеть набор целиком, а не тот, что выпал бы на сегодняшнюю ветку.
40
+ const script = sources.map((one) => `. ${JSON.stringify(one)} 2>/dev/null`).join('; ') +
41
+ `; command -v ${fn} >/dev/null 2>&1 || exit 3; ${fn} ''`;
42
+ try {
43
+ // Полный путь к оболочке, а не имя: имя разрешается через `PATH`, а он в чужой среде
44
+ // бывает записываемым — тогда «bash» указывает не туда. Ищется он тем же способом, каким
45
+ // ищут его хуки: в двух местах, где оболочка лежит на всякой машине.
46
+ const shell = ['/bin/bash', '/usr/bin/bash'].find((one) => existsSync(one)) ?? '/bin/bash';
47
+ const out = execFileSync(shell, ['-c', script], {
48
+ cwd: root,
49
+ encoding: 'utf8',
50
+ timeout: TIMEOUT_MS,
51
+ stdio: ['ignore', 'pipe', 'ignore'],
52
+ });
53
+ return out
54
+ .split('\n')
55
+ .map((line) => line.trim())
56
+ .filter((line) => line.length > 0);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Итоговый набор перед пушем и то, что печатало умолчание, а в итог не попало.
64
+ *
65
+ * Разница считается по вызовам, а не по признаку переопределения: дерево, переписавшее функцию
66
+ * целиком и вернувшее тот же набор, из умолчания не убрало ничего, а дерево, дописавшее фильтр,
67
+ * убрало — и по одному факту переопределения эти два случая неразличимы.
68
+ *
69
+ * Названа разница ровно тем, что она есть: строкой умолчания, которой в итоге нет. Снятием
70
+ * охраны она НЕ называется — дерево могло заменить команду своим вариантом, зовущим ту же
71
+ * проверку другим прогонщиком, и такая замена выглядит отсюда точно так же. Что из двух
72
+ * случилось, видно только в надстройке, и читает её человек.
73
+ */
74
+ export function pushGateLines(root, defaults) {
75
+ const final = callProfile(root, defaults, PUSH_CHECKS);
76
+ if (final === null) {
77
+ return ['набор перед пушем: собрать не удалось — нет профиля дерева либо функции rt_push_checks'];
78
+ }
79
+ const base = callProfile(root, defaults, PUSH_CHECKS_DEFAULT);
80
+ const cut = base === null ? [] : base.filter((one) => !final.includes(one));
81
+ return [
82
+ `набор перед пушем: ${final.length}`,
83
+ ...final.map((one) => ` ${one}`),
84
+ ...(cut.length
85
+ ? [
86
+ `умолчание печатало, а в наборе нет: ${cut.length}`,
87
+ ...cut.map((one) => ` ${one}`),
88
+ ' это либо снятие охраны, либо замена своим вариантом той же проверки — различает их надстройка профиля',
89
+ ]
90
+ : []),
91
+ ];
92
+ }
93
+ //# sourceMappingURL=push-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-gate.js","sourceRoot":"","sources":["../../../projects/agent-kit/src/lib/push-gate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Набор проверок перед пушем — итоговый и снятое из умолчания.\n *\n * Набор собирается из двух файлов: умолчания пакета и надстройки профиля дерева. Результат\n * сборки не показывался нигде — чтобы его прочитать, надо было звать функцию профиля руками из\n * оболочки. Из этой невидимости выросли три разных промаха: дерево дописало в надстройку команду,\n * уже стоявшую в умолчании, и гоняло проверку дважды за пуш; другое дерево выкусило из умолчания\n * сплошную проверку одной строкой `rt_push_checks_default \"$@\" | grep -v …` — строка выглядела\n * настройкой, а была снятием охраны; а расхождение набора с конвейером узнавалось из красного\n * конвейера после заявки.\n *\n * Набор ЗОВЁТСЯ, а не пересказывается чтением: он ветвится по содержимому дерева — есть ли\n * настройка раскладки, есть ли конфиг линтера стилей, какие проверки разложены, — и разобранный\n * по тексту функции разойдётся с настоящим молча.\n *\n * ОТКАЗ В ПОЛЬЗУ ЧИТАТЕЛЯ: нет профиля, нет функции, вызов упал или завис — раздела нет, а\n * причина названа строкой. Разбор состояния ничего не пишет, и своим отказом чинить тут нечего.\n */\nimport { execFileSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { join } from 'node:path';\n\n/** Имя функции профиля, собирающей набор, и имя её умолчания. */\nconst PUSH_CHECKS: string = 'rt_push_checks';\nconst PUSH_CHECKS_DEFAULT: string = `${PUSH_CHECKS}_default`;\n\n/** Сколько ждать сборки набора. Сама она ничего не гоняет — печатает строки. */\nconst TIMEOUT_MS: number = 10_000;\n\n/**\n * Строки, которые вернула названная функция профиля. Профиль собирается из тех же файлов и в том\n * же порядке, что читает сам гард: сперва разложенное умолчание пакета, поверх — надстройка\n * дерева. Судить по одной надстройке значило бы получить пустой набор у всякого дерева, которое\n * умолчаний не переписывало.\n */\nfunction callProfile(root: string, defaults: string, fn: string): readonly string[] | null {\n const sources: readonly string[] = [join(root, defaults, 'project.sh'), join(root, '.claude/rt-kit', 'project.sh')].filter(\n (one: string): boolean => existsSync(one)\n );\n if (!sources.length) {\n return null;\n }\n\n // База пустая: с ней умолчание печатает полный набор, а не набор по вкладу ветки. Разбор\n // состояния читают, чтобы увидеть набор целиком, а не тот, что выпал бы на сегодняшнюю ветку.\n const script: string =\n sources.map((one: string): string => `. ${JSON.stringify(one)} 2>/dev/null`).join('; ') +\n `; command -v ${fn} >/dev/null 2>&1 || exit 3; ${fn} ''`;\n\n try {\n // Полный путь к оболочке, а не имя: имя разрешается через `PATH`, а он в чужой среде\n // бывает записываемым — тогда «bash» указывает не туда. Ищется он тем же способом, каким\n // ищут его хуки: в двух местах, где оболочка лежит на всякой машине.\n const shell: string = ['/bin/bash', '/usr/bin/bash'].find((one: string): boolean => existsSync(one)) ?? '/bin/bash';\n const out: string = execFileSync(shell, ['-c', script], {\n cwd: root,\n encoding: 'utf8',\n timeout: TIMEOUT_MS,\n stdio: ['ignore', 'pipe', 'ignore'],\n });\n\n return out\n .split('\\n')\n .map((line: string): string => line.trim())\n .filter((line: string): boolean => line.length > 0);\n } catch {\n return null;\n }\n}\n\n/**\n * Итоговый набор перед пушем и то, что печатало умолчание, а в итог не попало.\n *\n * Разница считается по вызовам, а не по признаку переопределения: дерево, переписавшее функцию\n * целиком и вернувшее тот же набор, из умолчания не убрало ничего, а дерево, дописавшее фильтр,\n * убрало — и по одному факту переопределения эти два случая неразличимы.\n *\n * Названа разница ровно тем, что она есть: строкой умолчания, которой в итоге нет. Снятием\n * охраны она НЕ называется — дерево могло заменить команду своим вариантом, зовущим ту же\n * проверку другим прогонщиком, и такая замена выглядит отсюда точно так же. Что из двух\n * случилось, видно только в надстройке, и читает её человек.\n */\nexport function pushGateLines(root: string, defaults: string): string[] {\n const final: readonly string[] | null = callProfile(root, defaults, PUSH_CHECKS);\n if (final === null) {\n return ['набор перед пушем: собрать не удалось — нет профиля дерева либо функции rt_push_checks'];\n }\n\n const base: readonly string[] | null = callProfile(root, defaults, PUSH_CHECKS_DEFAULT);\n const cut: readonly string[] = base === null ? [] : base.filter((one: string): boolean => !final.includes(one));\n\n return [\n `набор перед пушем: ${final.length}`,\n ...final.map((one: string): string => ` ${one}`),\n ...(cut.length\n ? [\n `умолчание печатало, а в наборе нет: ${cut.length}`,\n ...cut.map((one: string): string => ` ${one}`),\n ' это либо снятие охраны, либо замена своим вариантом той же проверки — различает их надстройка профиля',\n ]\n : []),\n ];\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rt-tools/agent-kit",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Переносимый слой правил для агента: законы, хуки, проверки и агенты, раскладываемые в репозиторий одной командой",
5
5
  "author": "RT Team",
6
6
  "license": "Apache-2.0",
Binary file
@@ -1,34 +0,0 @@
1
- # Закон о доступе
2
-
3
- Кто что может делать: вход владельца, права на действия, поведение публичных путей.
4
- Правила держат все процедуры контракта и все разделы админки.
5
-
6
- ## Терминология
7
-
8
- | Термин | Определение |
9
- | -------------- | ------------------------------------------------------------------------------------------- |
10
- | Право | Пара «ресурс и действие», например `bookings:manage`. Не роль: роль набирается из прав |
11
- | Пресет | Именованный набор прав, который выдаётся пользователю целиком |
12
- | Оверрайд | Точечная правка права поверх пресета для одного пользователя |
13
- | Публичный путь | Процедура, доступная гостю без входа. Публичность объявляется, а не получается по умолчанию |
14
-
15
- ## Статьи
16
-
17
- - **Каждая процедура объявляет свой доступ, и объявление ровно одно.** Процедура без
18
- объявления или с двумя объявлениями не даёт приложению подняться.
19
- - **Доступов четыре: по праву, любому вошедшему, публично и публично с чтением входа.**
20
- Последний отдаёт вошедшему больше, чем гостю, — так админ видит скрытые объекты в общем
21
- списке.
22
- - **Права пользователя — это права пресета, поверх которых применены его оверрайды.**
23
- - **Запрос без входа туда, где вход нужен, отбивается как неаутентифицированный, а вход
24
- без нужного права — как отказ в доступе.** Это разные ответы: первый лечится входом,
25
- второй — нет.
26
- - **Право проверяется до тела процедуры.** Обработчик не решает, пускать ли вызывающего.
27
- - **Публичность объявляется с причиной.** Причина живёт в коде рядом с объявлением и
28
- наружу не уходит.
29
- - **Админка не показывает разделы, на которые у владельца нет права.**
30
- - **Адрес раздела закрыт тем же правом, что и пункт меню.** Иначе скрытый пункт закрывает
31
- раздел лишь на вид: адрес открывается по прямой ссылке.
32
- - **Пока права не получены, админка ничего не прячет.** Пустая шапка после сетевого сбоя
33
- выглядит как сломанная админка и не оставляет выхода; запрос без права всё равно
34
- отобьётся на сервере.
@@ -1,33 +0,0 @@
1
- # Закон о локалях и переводах
2
-
3
- Как в системе устроены языки: адреса публичного сайта, подписи интерфейса, переводы
4
- контента и язык админки. Правила держат сайт, админку, письма и документ-подтверждение
5
- одновременно.
6
-
7
- ## Терминология
8
-
9
- | Термин | Определение |
10
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
11
- | Локаль сайта | Один из восьми языков, на которых существует публичный сайт. Не то же, что валюта показа: валюта у локали лишь начальная и меняется гостем |
12
- | Локаль ввода | Язык, на котором владелец написал текст объекта. Задаёт истину: остальные семь считаются производными |
13
- | Словарь | Набор подписей интерфейса для одной локали. Живёт в репозитории, а не в базе |
14
- | Перевод контента | Текст объекта на локали, отличной от локали ввода. Живёт в базе и заполняется системой |
15
-
16
- ## Статьи
17
-
18
- - **Локаль по умолчанию отдаётся из корня, остальные семь — из-под префикса пути.**
19
- - **Видимый человеку текст интерфейса берётся из словарей и заводится во всех восьми
20
- локалях.** Ключ, потерянный в одной локали, гость видит на кнопке как есть.
21
- - **Пустой перевод считается пропуском, а не переводом.** На экране он выглядит как
22
- задуманный: кнопка без подписи, заголовок без текста.
23
- - **Переводы контента производные: владелец пишет текст на своём языке, остальные семь
24
- локалей заполняет система при сохранении.** Табов локалей в формах нет — правка вручную
25
- всё равно была бы перезаписана.
26
- - **Отказ перевода сохранение не срывает.** Текст на локали ввода записывается, прежние
27
- переводы остаются, и владелец видит предупреждение, а не сообщение об успехе.
28
- - **Локаль, для которой перевода не пришло, сохраняет прежнее значение.** Половина ответа
29
- лучше пустого описания на витрине.
30
- - **Язык админки выбирает владелец, и выбор живёт в его профиле.** Он же уходит в локаль
31
- ввода при сохранении объекта.
32
- - **У локали есть начальная валюта показа.** Дальше валюту выбирает гость, и его выбор
33
- сильнее умолчания локали.
@@ -1,24 +0,0 @@
1
- # Закон о видимости в поиске
2
-
3
- Как публичный сайт выглядит для поисковика. Страница живёт в нескольких языках под одним
4
- доменом, и от разметки зависит, считает поисковик эти адреса одной страницей на разных
5
- языках или дублями друг друга. Ошибка здесь не видна ни в сборке, ни в браузере: она
6
- проявляется падением выдачи через недели.
7
-
8
- ## Статьи
9
-
10
- - **У страницы один канонический адрес — её собственный, на её языке.** Общий канонический
11
- адрес на все языки означает, что остальные версии объявлены копиями и в выдачу не попадут.
12
- - **Разметка страницы при переходе переписывается, а не накапливается.** Иначе на второй
13
- открытой странице остаётся описание первой, и два адреса объявляют один канонический.
14
- - **Языковая версия объявляется поисковику только тогда, когда её перевод готов.** Позвать
15
- поисковика на страницу, где текст остался на чужом языке, — значит отдать дубль под
16
- неверным языковым тегом.
17
- - **Страница объявляет себя структурированными данными.** Без них поисковик разбирает
18
- содержимое догадками.
19
- - **Карта сайта строится из живых данных, а не из файла.** Файл устаревает молча в тот же
20
- день, когда добавили страницу.
21
- - **Прежний адрес страницы ведёт на новый постоянным перенаправлением.** Смена адреса без
22
- перенаправления обнуляет накопленный вес страницы.
23
- - **Перенаправление с прежнего адреса живёт в кэше наравне со страницами.** Некэшируемое
24
- перенаправление не вытесняет из кэша старую страницу, и гость видит её ещё сутки.
@@ -1,71 +0,0 @@
1
- ---
2
- name: permissions-procedure
3
- kind: pattern
4
- rule: permissions
5
- description: Паттерн правила permissions. Брать при заведении процедуры Connect и при закрытии раздела админки — готовые декораторы доступа, отбивка без входа и без права, декларация пункта меню с правом и флагом. Не брать для устройства самого меню — это правило navigation.
6
- ---
7
-
8
- # Объявление доступа
9
-
10
- Паттерн правила `permissions`. Что при этом должно быть верно — закон
11
- `docs/constitution/application/access.md`.
12
-
13
- ## Когда брать
14
-
15
- - Заводится процедура Connect.
16
- - Раздел админки закрывается правом.
17
- - Процедура должна отвечать гостю.
18
-
19
- ## Декоратор на классе процедуры
20
-
21
- Объявление ровно одно; без него приложение не поднимается:
22
-
23
- ```typescript
24
- @Injectable()
25
- @ConnectProcedure()
26
- @RequiresPermission('chat:manage')
27
- export class LinkBookingProcedure implements IConnectProcedure<typeof ChatService.method.linkBooking> {
28
- public readonly method: typeof ChatService.method.linkBooking = ChatService.method.linkBooking;
29
- }
30
- ```
31
-
32
- | Декоратор | Кому доступно |
33
- | -------------------------------------------- | ------------------------------------------------- |
34
- | `@RequiresPermission('<ресурс>:<действие>')` | вошедшему с этим правом |
35
- | `@RequiresAuth('<причина>')` | любому вошедшему; так живут профиль и выбор языка |
36
- | `@PublicProcedure('<причина>')` | гостю без входа |
37
- | `@OptionalAuthProcedure('<причина>')` | гостю, но токен читается, если он есть |
38
-
39
- Аргумент — причина для читателя кода. Ни в ответ, ни в лог она не уходит.
40
-
41
- ## Отбивка
42
-
43
- Перехватчик отвечает до тела процедуры:
44
-
45
- - нет входа там, где вход нужен, — `Code.Unauthenticated`;
46
- - вход есть, права нет — `Code.PermissionDenied`;
47
- - процедура, о которой перехватчик ничего не знает, — тоже отказ в доступе, а не пропуск.
48
-
49
- Обработчик решения о допуске не принимает.
50
-
51
- ## Раздел админки
52
-
53
- Пункт меню и адрес закрываются одной декларацией в
54
- `libs/admin/common/container/util`: шапка берёт из неё подписи и адреса, гвард — права.
55
- Второго объявления этой связи не заводится.
56
-
57
- Гейтинг двухслойный: право пользователя и флаг раздела. Пункт с флагом объявляется без прав и
58
- без адреса — право открывает экран, а экрана нет. Появится экран — флаг снимается, права
59
- добавляются.
60
-
61
- ## Частые промахи
62
-
63
- - Два объявления доступа на одной процедуре: приложение не поднимется, и увидено это будет
64
- только при запуске.
65
- - Проверка права внутри `handle`: право проверяется до тела.
66
- - Своё объявление прав рядом с маршрутами: оно разойдётся с декларацией меню, и получится
67
- «пункта не видно, а страница открывается».
68
- - Гвард, повешенный на защищённую группу целиком: он отрабатывает один раз за загрузку
69
- страницы и переходов между разделами не видит.
70
- - Ожидание прав, которое роняется на отказе запроса: с неизвестными правами не закрывается
71
- ничего, и пустая шапка выхода владельцу не оставляет.
@@ -1,104 +0,0 @@
1
- ---
2
- name: seo-page
3
- kind: pattern
4
- rule: seo
5
- description: Паттерн правила seo. Брать, когда правится разметка страницы публичного сайта, заводится маршрут или страница должна попасть в карту сайта: готовый вызов службы мета-тегов, ветки локалей, строка карты. Проверка отданной разметки — паттерн seo-verify.
6
- ---
7
-
8
- # Разметка страницы публичного сайта
9
-
10
- Паттерн правила `seo`. Что при этом должно быть верно — закон
11
- `docs/constitution/application/search-visibility.md`.
12
-
13
- ## Когда брать
14
-
15
- - Правится шаблон страницы сайта, её заголовок, описание или картинка для соцсетей.
16
- - Заводится новый маршрут сайта.
17
- - Появилась страница, которая должна попасть в `sitemap.xml`.
18
-
19
- ## Разметку ставит сервис, а не шаблон
20
-
21
- Страница собирает данные и одним вызовом отдаёт их `PropertySeoService`. Своих `<meta>` в
22
- шаблоне не заводить: они не помечены `data-<префикс>-seo`, не переписываются при переходе и
23
- переживут смену страницы.
24
-
25
- Образец — `property-page.component.ts:#applySeo`:
26
-
27
- ```typescript
28
- readonly #seo: PropertySeoService = inject(PropertySeoService);
29
-
30
- constructor() {
31
- effect((): void => this.#applySeo());
32
- }
33
-
34
- #applySeo(): void {
35
- const property: IProperty.State | null = this.property();
36
- if (!property) {
37
- return;
38
- }
39
-
40
- const name: string = this.propertyName();
41
- const cover: IPhotoView | null = this.coverDesktop();
42
-
43
- this.#seo.apply({
44
- property,
45
- name,
46
- pageTitle: name ? `${name} — ${this.#titleSuffix()}` : this.#titleSuffix(),
47
- description: property.shortDescription || this.#descriptionFallback(),
48
- ogImageUrl: cover ? photoOgUrl(cover.baseUrl) : `${SITE_ORIGIN}/${BRAND_OG_IMAGE}`,
49
- ogImageAlt: cover ? cover.alt : name,
50
- ogImageWidth: OG_IMAGE_WIDTH,
51
- ogImageHeight: OG_IMAGE_HEIGHT,
52
- locale: this.#localeId,
53
- });
54
- }
55
- ```
56
-
57
- Вызов идёт из `effect`, а не из конструктора напрямую: объект приходит сигналом, и на первом
58
- кадре его ещё нет. Ранний выход по пустому объекту обязателен — без него разметка встала бы на
59
- пустых значениях и второй раз уже не переписалась бы.
60
-
61
- У картинки есть запасной вариант: объект без обложки отдаёт брендовое изображение того же формата,
62
- иначе превью в соцсети пустует.
63
-
64
- ## Новый маршрут заводится веткой на каждую локаль
65
-
66
- Ветки собирает `apps/site/src/app/app.routes.ts` из `LOCALE_CODES`. Локаль по умолчанию своей
67
- ветки не имеет — она отдаётся из корня.
68
-
69
- ```typescript
70
- export const appRoutes: Route[] = [
71
- ...LOCALE_CODES.filter((code: ELocale): boolean => code !== DEFAULT_LOCALE).map((code: ELocale): Route => ({
72
- path: code,
73
- children: propertyRoutes,
74
- })),
75
- ...propertyRoutes,
76
- ];
77
- ```
78
-
79
- Новый путь дописывается в `propertyRoutes` — тогда он появляется во всех восьми ветках сразу.
80
- Ветки перечислены явными кодами, а не `:locale`: иначе `/<адрес страницы>` был бы принят за
81
- язык, а не за адрес объекта.
82
-
83
- ## Страница попадает в карту сайта явно
84
-
85
- Карта строится из объектов, а не из маршрутов, и новая страница сама туда не попадёт. Записи
86
- собирает `buildSitemap` (слой `util` домена страницы объекта), отдаёт обработчик
87
- `/sitemap.xml` в `apps/site/src/server.ts`. Правка идёт вместе со спекой в
88
- `sitemap.util.spec.ts`.
89
-
90
- ## Тексты идут из словарей
91
-
92
- `pageTitle` и `description` собираются из переведённых значений и заводятся во всех восьми
93
- локалях. Без перевода они уезжают в выдачу по-английски, и заметно это только в чужой локали.
94
-
95
- ## Частые промахи
96
-
97
- - `<meta>` в шаблоне вместо вызова сервиса — тег не помечен `data-<префикс>-seo` и переживёт переход.
98
- - Вызов без раннего выхода по пустому объекту — разметка встаёт на пустых значениях.
99
- - Новый путь дописан мимо `propertyRoutes` — работает только в локали по умолчанию,
100
- `/de/<путь>` отдаёт 404.
101
- - Свой разбор адреса вместо `splitRequestUrl` — теряется всё после второго `?`, и источник
102
- заявки считается неверно.
103
- - Правка разметки без проверки на прод-сборке: в дев-сервере теги ставит не тот путь. Проверка
104
- описана паттерном `seo-verify`.
@@ -1,83 +0,0 @@
1
- ---
2
- name: seo-verify
3
- kind: pattern
4
- rule: seo
5
- description: Паттерн правила seo. Брать после любой правки, задевающей разметку публичного сайта, meta или маршруты — готовые команды сборки, поднятия SSR и проверки отданного HTML по локалям, карты сайта и кэшируемости перенаправления. Не брать для самой правки разметки — это паттерн seo-page.
6
- ---
7
-
8
- # Проверка разметки на прод-сборке
9
-
10
- Паттерн правила `seo`. Что при этом должно быть верно — закон
11
- `docs/constitution/application/search-visibility.md`.
12
-
13
- ## Когда брать
14
-
15
- После любой правки, задевающей разметку сайта, `meta`, маршруты, `server.ts`, `robots.txt`
16
- или `deploy/nginx.conf`.
17
-
18
- ## Дев-сервер здесь не показатель
19
-
20
- Теги ставятся при отдаче страницы сервером. В дев-режиме этот путь другой, поэтому проверка
21
- идёт на собранном приложении с поднятым SSR.
22
-
23
- ```bash
24
- npx nx build site
25
- PORT={{prodSitePort}} node dist/apps/site/server/server.mjs &
26
- ```
27
-
28
- Порт {{prodSitePort}} — тот же, что берёт стенд; дев-серверы владельца на {{sitePort}} и {{adminPort}} при этом не
29
- трогаются. Angular SSR отвечает `400` на чужой `Host`, поэтому запросы идут с
30
- `-H "Host: localhost"`.
31
-
32
- ## Что смотреть в отданном HTML
33
-
34
- ```bash
35
- for locale in "" <префиксы локалей>; do
36
- printf '%-10s ' "${locale:-en}"
37
- curl -s -H "Host: localhost" "http://localhost:{{prodSitePort}}/${locale}<адрес страницы>" \
38
- | grep -c -E '<title>|name="description"|property="og:|rel="canonical"|hreflang=|application/ld\+json'
39
- done
40
- ```
41
-
42
- В ответе каждой локали должны быть `<title>`, `description`, набор `og:*`, `canonical`,
43
- полный набор `hreflang` плюс `x-default` и блок `application/ld+json`.
44
-
45
- Отдельно проверяется, что `canonical` ведёт на **свой** язык, а не на локаль по умолчанию:
46
-
47
- ```bash
48
- curl -s -H "Host: localhost" http://localhost:{{prodSitePort}}/de/<адрес страницы> \
49
- | grep -o 'rel="canonical" href="[^"]*"'
50
- ```
51
-
52
- ## Карта сайта
53
-
54
- ```bash
55
- curl -s -H "Host: localhost" http://localhost:{{prodSitePort}}/sitemap.xml | head -20
56
- ```
57
-
58
- Карта строится из живых данных, а не из файла. Пустой ответ означает, что не поднялся запрос
59
- за объектами, — это отказ, а не «объектов нет».
60
-
61
- ## Кэшируемость перенаправления
62
-
63
- Проверяется только на стенде с настоящим `deploy/nginx.conf`: голый SSR отдаёт заголовки, но
64
- не показывает, попадёт ли ответ в кэш.
65
-
66
- ```bash
67
- curl -sI http://<стенд>/<прежний-slug> | grep -i -E 'HTTP/|location|cache-control|x-cache'
68
- ```
69
-
70
- Ответ обязан быть `301`, нести `Cache-Control` со сроком и попадать в кэш. Некэшируемое
71
- перенаправление не вытесняет старую запись, и гость видит прежнюю страницу до суток.
72
-
73
- ## Тексты
74
-
75
- `title` и `description` берутся из словарей во всех локалях перевода. Непереведённый ключ уезжает
76
- в выдачу по-английски — полноту словарей держит `npx nx test common-i18n`.
77
-
78
- ## Частые промахи
79
-
80
- - Проверка в дев-сервере: разметку там ставит другой путь, и отсутствие тега не видно.
81
- - Проверка одной локали: расходится обычно та, которую не смотрели.
82
- - Проверка кэша без nginx: заголовки видны, попадание в кэш — нет.
83
- - Вывод по коду ответа: `200` приходит и со страницы без разметки.
@@ -1,107 +0,0 @@
1
- ---
2
- name: translations-content
3
- kind: pattern
4
- rule: translations
5
- description: Паттерн правила translations. Брать при работе с переводами содержимого записи — производный перевод при сохранении, запись локали руками через язык админки, разбор отказа провайдера, сброс кэша отданных страниц. Не брать для подписей интерфейса — это паттерн translations-key.
6
- ---
7
-
8
- # Перевод содержимого записи
9
-
10
- Паттерн правила `translations`. Что при этом должно быть верно — закон
11
- `docs/constitution/application/locales.md`.
12
-
13
- ## Когда брать
14
-
15
- - Владелец правит тексты записи, и остальные локали должны догнать.
16
- - Админка ответила, что сохранила на языке ввода, а перевод на остальные не удался.
17
- - Локаль надо заполнить руками: провайдер недоступен, а страница на этом языке нужна сегодня.
18
- - Проверяется, что отданная страница показывает свежий текст.
19
-
20
- ## Перевод идёт сам, и просить его нечем
21
-
22
- Кнопки «перевести» нет. Владелец печатает на своём языке, а процедура сохранения зовёт перевод
23
- до транзакции и кладёт в хранилище уже все локали. Локаль ввода приезжает полем запроса и
24
- берётся из языка админки, а не из браузера гостя.
25
-
26
- В партию идёт не всё подряд: сборщик отбрасывает строку, чей исходный текст не менялся **и**
27
- которая переведена на все целевые локали. Отсюда два следствия:
28
-
29
- - сохранение записи без правки текстов провайдера не зовёт вовсе — перестановка изображения или
30
- правка числа ничего не стоит;
31
- - строка, у которой хоть одна локаль пуста, поедет на перевод при первом же сохранении, без
32
- правки самого текста.
33
-
34
- ## Локаль пишется руками через язык админки
35
-
36
- Табов локалей в форме нет, и это не упущение: поле одно, а какая локаль за ним стоит, решает
37
- язык интерфейса. Значит записать перевод руками — это переключить язык в шапке админки и
38
- заполнить те же поля.
39
-
40
- Путь этот не аварийный: пока у провайдера пуст счёт, он и есть обычный порядок, и каждый
41
- видимый текст заводится по разу на локаль. Считать его временным и ждать автоперевода не надо.
42
-
43
- Порядок такой:
44
-
45
- 1. Меню владельца в шапке → выбрать язык.
46
- 2. Открыть запись. Текстовые поля покажут текст **этой** локали; пустые они там, где локали у
47
- записи нет.
48
- 3. Заполнить и сохранить.
49
-
50
- Соседние локали при этом целы: черновик пишет в карту по коду локали, а остальные ключи уезжают
51
- в запрос как пришли. Отказ провайдера ничего не портит — в хранилище ложится ровно то, что
52
- прислала форма.
53
-
54
- Записанное руками потом не перетирается: у поля заполнены все локали, и сборщик партии больше
55
- не берёт его, пока владелец не изменит исходный текст.
56
-
57
- Собирать вызов сохранения скриптом для этого не надо, и правило это прямо запрещает: черновик
58
- перезаписывает запись целиком, а вложенная запись, не попавшая в список, удаляется вместе со
59
- своими связями.
60
-
61
- ## Отказ провайдера читается не по экрану
62
-
63
- Владельцу показывается одна фраза без причины. Причина живёт в логах строкой своего контекста:
64
-
65
- ```bash
66
- docker compose -f <состав прода> --env-file <файл окружения> logs api --since 30m \
67
- | grep -i translation
68
- ```
69
-
70
- Строка несёт код ответа, но не текст: клиент бросает отказ по коду, не читая тела. Что именно
71
- ответил провайдер, спрашивается у него напрямую тем же ключом; для разового вызова его берут
72
- руками — приложение эту переменную не читает, но значение там то же, что владелец завёл в
73
- админке. Отказ по деньгам означает пустой счёт, а не сломанный ключ: список моделей при этом
74
- отдаётся, и строка интеграции показывает «работает». Где лежит сам ключ и почему проба
75
- зелёная — паттерн `git-workflow-secrets`.
76
-
77
- ## Отданная страница показывает прежний текст, пока не сброшен кэш
78
-
79
- Записанный перевод виден в хранилище сразу, а гостю — нет: страницы лежат в кэше прокси. На
80
- проде он сбрасывается так же, как на стенде:
81
-
82
- ```bash
83
- docker exec <контейнер прокси> sh -c 'rm -rf /var/cache/nginx/site/*; nginx -s reload'
84
- ```
85
-
86
- Сверяется отданной разметкой по каждой правленой локали, а не взглядом на админку:
87
-
88
- ```bash
89
- for l in <локали>; do
90
- printf '%-8s ' "$l"
91
- curl -sL "https://<адрес сайта>/$l/" | grep -oE '<meta name="description" content="[^"]{0,80}'
92
- done
93
- ```
94
-
95
- ## Частые промахи
96
-
97
- - **Проверять результат в админке.** Форма показывает то, что в хранилище, и молчит о кэше: три
98
- локали были записаны и проверены, а сайт ещё полчаса отдавал прежний текст на всех.
99
- - **Считать, что перевода нет, раз пришло предупреждение.** Прежние локали остаются на месте;
100
- пустой окажется только та, которой не было раньше.
101
- - **Писать в хранилище напрямую.** Запись в боевое хранилище запрещена совсем, а колонка со
102
- свободной структурой выглядит безобидной целью — тексты правятся через админку, и другого
103
- пути у них нет.
104
- - **Переводить имя собственное.** Оно остаётся как есть во всех локалях: так велит словарь в
105
- задании провайдеру, и рукописный перевод обязан вести себя так же.
106
- - **Копировать одно письмо языка в другое.** Упрощённое и традиционное письмо — разные локали и
107
- разное письмо; посимвольная копия простояла на проде, и гость читал чужие иероглифы.