@nitra/cursor 1.28.4 → 1.28.6

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.28.5] - 2026-05-28
8
+
9
+ ### Fixed
10
+
11
+ - **`scripts/utils/resolve-js-root.mjs`** — `resolveAllJsRoots()` тепер розгортає glob-патерни (`cf/*`, `packages/*` тощо) у списку `workspaces` через `node:fs/promises#glob`. Без цього `cf/*` як літерал не резолвився через `existsSync`, і `resolveJsRoot()` падав на fallback → `cwd`; `n-cursor coverage` із кореня запускав vitest у root-у проєкту без `vitest.config.js` (через `gt/tests/setup.mjs` не підвантажувався). `resolveJsRoot()` тепер тонкий wrapper над `resolveAllJsRoots()[0]`. Додано 2 тести: glob-розгортання `cf/*` і fallback на `[cwd]` при порожньому збігу.
12
+ - **`rules/js-lint/coverage/coverage.mjs#collect()`** — у monorepo тепер ітерує **всі** JS-roots з `resolveAllJsRoots()`, запускає vitest + Stryker у кожному окремо та сумує `lcov` (через локальний `addCoverage`) і `mutation` (через `addMutation`). Шляхи у `survived[].file` і `survived[].exampleTest.testFile` рібейзяться відносно `cwd` (`relative(cwd, jsRoot)`/`join(wsRel, file)`), щоб `coverage-fix.mjs#buildFixPrompt` коректно читав source-файли через `join(projectRoot, file)`. `detect()` повертає `true`, якщо vitest є хоча б в одному workspace АБО в кореневому `package.json`. Додано тест агрегування у monorepo з `cf/*`-glob.
13
+
7
14
  ## [1.28.4] - 2026-05-28
8
15
 
9
16
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.28.4",
3
+ "version": "1.28.6",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -46,6 +46,60 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
46
46
 
47
47
  Для multi-row `VALUES` у `MERGE` / `INSERT` з конкретними типами — паралельні масиви по колонках і `unnest($1::TYPE[], $2::TYPE[], ...) AS t(col1, col2, ...)`. Кожна колонка передається одним параметром-масивом; типи задаються кастом масиву (`::uuid[]`, `::bigint[]`, `::numeric[]`, `::text[]`, …).
48
48
 
49
+ #### Приклад: MERGE з UNNEST і динамічними колонками
50
+
51
+ ```javascript
52
+ // ❌ format + pgWrite.unsafe — N×7 окремих значень, план змінюється при кожному batch.length
53
+ const valuesSql = batch
54
+ .map(row => format('(%L::int, %L::date, %L::jsonb)', row.id, row.date, JSON.stringify(row.data)))
55
+ .join(',')
56
+ const sql = format(`MERGE INTO t USING (VALUES %s) AS s(id, date, data) ON ...`, valuesSql)
57
+ await pgWrite.unsafe(sql)
58
+
59
+ // ✅ UNNEST — 3 параметри незалежно від розміру batch; план стабільний і може кешуватись
60
+ const ids = batch.map(r => r.id)
61
+ const dates = batch.map(r => r.date)
62
+ const data = batch.map(r => JSON.stringify(r.data))
63
+
64
+ await pgWrite`
65
+ WITH s(id, date, data) AS (
66
+ SELECT * FROM unnest(
67
+ ${ids}::int[],
68
+ ${dates}::date[],
69
+ ${data}::jsonb[]
70
+ )
71
+ )
72
+ MERGE INTO my_table AS t
73
+ USING s ON t.id = s.id
74
+ WHEN MATCHED THEN
75
+ UPDATE SET date = s.date, data = s.data
76
+ WHEN NOT MATCHED THEN
77
+ INSERT (id, date, data) VALUES (s.id, s.date, s.data)
78
+ `
79
+ ```
80
+
81
+ Якщо частина колонок у SET/INSERT залежить від параметра (plan/fact, тип тощо) — динамічні імена колонок не можна параметризувати через `${value}`; використовуй умовні Bun SQL фрагменти:
82
+
83
+ ```javascript
84
+ // ✅ умовні фрагменти для динамічних ідентифікаторів колонок
85
+ const colFrag = isPlan ? pgWrite`plan_value` : pgWrite`fact_value`
86
+ const hashFrag = isPlan ? pgWrite`hash = s.hash,` : pgWrite``
87
+
88
+ await pgWrite`
89
+ ...
90
+ WHEN MATCHED THEN
91
+ UPDATE SET
92
+ ${colFrag} = s.value,
93
+ ${hashFrag}
94
+ updated_by = s.updated_by
95
+ WHEN NOT MATCHED THEN
96
+ INSERT (id, ${colFrag}, updated_by)
97
+ VALUES (s.id, s.value, s.updated_by)
98
+ `
99
+ ```
100
+
101
+ `UNION ALL`-цикл замість `unnest` підходить для малих динамічних запитів (2–5 рядків), де кожна гілка семантично різна. Для bulk upsert — завжди `unnest`.
102
+
49
103
  ### Заборонений «drop-in» шим
50
104
 
51
105
  ```javascript
@@ -285,6 +339,26 @@ const ids = [1, 2, 3]
285
339
  await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
286
340
  ```
287
341
 
342
+ Multi-row INSERT з масиву об'єктів — `sql(rows)` генерує column list і VALUES автоматично:
343
+
344
+ ```javascript
345
+ // ❌ format + pgWrite.unsafe — ручне склеювання рядків, injection-вектор
346
+ const insertWfQry = `insert into approval.workflow (request_id, job_title_id, name, status)
347
+ values ${approverJobs.map(job => `('${request.id}', ${job.id}, '${job.short_name}', 'pending')`).join(', ')}`
348
+ await pgWrite.unsafe(insertWfQry)
349
+
350
+ // ✅ sql(rows) — один параметр-масив, bind через wire-protocol
351
+ const wfRows = approverJobs.map(job => ({
352
+ request_id: request.id,
353
+ job_title_id: job.id,
354
+ name: job.short_name,
355
+ status: job.id === nextJobId ? 'current' : 'pending'
356
+ }))
357
+ await sql`INSERT INTO approval.workflow ${sql(wfRows)}`
358
+ ```
359
+
360
+ Коли потрібен стабільний план для великих batch'ів (N > 20) або строгі типи колонок — використовуй `unnest` (деталі у `#### Приклад: MERGE з UNNEST і динамічними колонками`). Для невеликих INSERT'ів де колонки відомі — `sql(rows)` коротший і зрозуміліший.
361
+
288
362
  ## `IN (...)`: значення з template literal — тільки через змінну + guard на пустоту
289
363
 
290
364
  Якщо список для `IN (...)` підставляється через `${...}` у template literal, його **потрібно**:
@@ -10,9 +10,9 @@ import { spawnSync } from 'node:child_process'
10
10
  import { existsSync, readFileSync } from 'node:fs'
11
11
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
12
12
  import { tmpdir } from 'node:os'
13
- import { join } from 'node:path'
13
+ import { join, relative } from 'node:path'
14
14
 
15
- import { resolveJsRoot } from '../../../scripts/utils/resolve-js-root.mjs'
15
+ import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
16
16
 
17
17
  const TEST_BLOCK_START = /^\s*(it|test)\(/
18
18
  const FILE_EXTENSION = /\.[^.]+$/
@@ -30,21 +30,24 @@ function hasVitestDep(pkg) {
30
30
 
31
31
  /**
32
32
  * Чи провайдер застосовний у поточному cwd. Активується, коли `vitest`
33
- * декларовано у JS-root АБО у кореневому `package.json` (workspace-проєкт із
34
- * hoisted node_modules — типовий патерн bun monorepo, де npm-module rule
35
- * забороняє devDeps у published workspace-у, тож вони живуть у корені).
36
- * Інакше silent skip із hint у stderr (одноразово).
33
+ * декларовано хоча б в одному JS-root АБО у кореневому `package.json`
34
+ * (workspace-проєкт із hoisted node_modules — типовий патерн bun monorepo,
35
+ * де npm-module rule забороняє devDeps у published workspace-у, тож вони
36
+ * живуть у корені). Інакше silent skip із hint у stderr (одноразово).
37
37
  * @param {string} cwd корінь проєкту
38
38
  * @returns {Promise<boolean>} true, якщо проєкт сумісний з vitest-based coverage
39
39
  */
40
40
  export async function detect(cwd) {
41
- const jsRoot = await resolveJsRoot(cwd)
42
- if (jsRoot === null) return false
43
- const pkgPath = join(jsRoot, 'package.json')
44
- if (!existsSync(pkgPath)) return false
45
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
46
- if (hasVitestDep(pkg)) return true
47
- if (jsRoot !== cwd) {
41
+ const jsRoots = await resolveAllJsRoots(cwd)
42
+ if (jsRoots.length === 0) return false
43
+ for (const jsRoot of jsRoots) {
44
+ const pkgPath = join(jsRoot, 'package.json')
45
+ if (!existsSync(pkgPath)) continue
46
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
47
+ if (hasVitestDep(pkg)) return true
48
+ }
49
+ const rootInJsRoots = jsRoots.includes(cwd)
50
+ if (!rootInJsRoots) {
48
51
  const rootPkgPath = join(cwd, 'package.json')
49
52
  if (existsSync(rootPkgPath)) {
50
53
  const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
@@ -224,40 +227,91 @@ const defaultRunner = {
224
227
  }
225
228
 
226
229
  /**
227
- * Збирає JS-метрики покриття + мутаційного тестування.
230
+ * Збирає JS-метрики покриття + мутаційного тестування. У monorepo ітерує кожен
231
+ * JS-root з `resolveAllJsRoots()` (включно з glob-патернами на кшталт `cf/*`),
232
+ * запускає vitest+Stryker у кожному та сумує lcov/mutation. Шляхи файлів у
233
+ * `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs` знаходив джерела.
228
234
  * @param {string} cwd корінь проєкту
229
235
  * @param {{runner?: typeof defaultRunner}} [opts] runner-ін'єкція для тестів
230
236
  * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>} рядки для COVERAGE.md
231
237
  */
232
238
  export async function collect(cwd, opts = {}) {
233
239
  const runner = opts.runner ?? defaultRunner
234
- const jsRoot = await resolveJsRoot(cwd)
235
- if (jsRoot === null) throw new Error('js-lint coverage: package.json не знайдено')
240
+ const jsRoots = await resolveAllJsRoots(cwd)
241
+ if (jsRoots.length === 0) throw new Error('js-lint coverage: package.json не знайдено')
242
+
243
+ let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
244
+ let mutation = { caught: 0, total: 0 }
245
+ const survived = []
246
+
247
+ for (const jsRoot of jsRoots) {
248
+ const wsRel = relative(cwd, jsRoot)
249
+
250
+ // 1. Coverage через vitest run --coverage (v8 provider пише lcov.info у lcovDir)
251
+ const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
252
+ try {
253
+ const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
254
+ if (code !== 0) throw new Error(`JS coverage exit ${code}`)
255
+ coverage = addCoverage(coverage, parseLcov(await readFile(join(lcovDir, 'lcov.info'), 'utf8')))
256
+ } finally {
257
+ await rm(lcovDir, { recursive: true, force: true })
258
+ }
259
+
260
+ // 2. Mutation через Stryker
261
+ await runner.runStryker({ cwd: jsRoot })
262
+ let mutationReport
263
+ try {
264
+ mutationReport = JSON.parse(await readFile(join(jsRoot, 'reports', 'stryker', 'mutation.json'), 'utf8'))
265
+ } catch {
266
+ throw new Error(
267
+ 'js-lint coverage: stryker не залишив mutation.json — ' +
268
+ 'запусти `npx @nitra/cursor fix test` для встановлення canonical stryker.config.mjs, ' +
269
+ 'або налаштуй його вручну'
270
+ )
271
+ }
272
+ const parsed = parseStrykerReport(mutationReport, jsRoot)
273
+ mutation = addMutation(mutation, { caught: parsed.caught, total: parsed.total })
236
274
 
237
- // 1. Coverage через vitest run --coverage (v8 provider пише lcov.info у lcovDir)
238
- const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
239
- let coverage
240
- try {
241
- const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
242
- if (code !== 0) throw new Error(`JS coverage exit ${code}`)
243
- coverage = parseLcov(await readFile(join(lcovDir, 'lcov.info'), 'utf8'))
244
- } finally {
245
- await rm(lcovDir, { recursive: true, force: true })
275
+ for (const group of parsed.survived) {
276
+ survived.push({
277
+ ...group,
278
+ file: wsRel === '' ? group.file : join(wsRel, group.file),
279
+ exampleTest: group.exampleTest
280
+ ? {
281
+ ...group.exampleTest,
282
+ testFile: wsRel === '' ? group.exampleTest.testFile : join(wsRel, group.exampleTest.testFile)
283
+ }
284
+ : null
285
+ })
286
+ }
246
287
  }
247
288
 
248
- // 2. Mutation через Stryker
249
- await runner.runStryker({ cwd: jsRoot })
250
- let mutationReport
251
- try {
252
- mutationReport = JSON.parse(await readFile(join(jsRoot, 'reports', 'stryker', 'mutation.json'), 'utf8'))
253
- } catch {
254
- throw new Error(
255
- 'js-lint coverage: stryker не залишив mutation.json — ' +
256
- 'запусти `npx @nitra/cursor fix test` для встановлення canonical stryker.config.mjs, ' +
257
- 'або налаштуй його вручну'
258
- )
289
+ return [{ area: 'JS', coverage, mutation, survived }]
290
+ }
291
+
292
+ /**
293
+ * Сумування coverage-totals імпортується з оркестратора при потребі; тут
294
+ * локальна копія, щоб уникнути циклу test/coverage → js-lint/coverage.
295
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший
296
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий
297
+ * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сума
298
+ */
299
+ function addCoverage(a, b) {
300
+ return {
301
+ lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
302
+ functions: {
303
+ covered: a.functions.covered + b.functions.covered,
304
+ total: a.functions.total + b.functions.total
305
+ }
259
306
  }
260
- const { caught, total, survived } = parseStrykerReport(mutationReport, jsRoot)
307
+ }
261
308
 
262
- return [{ area: 'JS', coverage, mutation: { caught, total }, survived }]
309
+ /**
310
+ * Сумування mutation-counts.
311
+ * @param {{caught:number,total:number}} a перший
312
+ * @param {{caught:number,total:number}} b другий
313
+ * @returns {{caught:number,total:number}} сума
314
+ */
315
+ function addMutation(a, b) {
316
+ return { caught: a.caught + b.caught, total: a.total + b.total }
263
317
  }
@@ -5,6 +5,92 @@ globs: "**/*.{css,scss,vue}"
5
5
  alwaysApply: false
6
6
  ---
7
7
 
8
+ ## Quasar як стильова основа
9
+
10
+ У Vue-проектах використовуй **Quasar** як базову стильову систему:
11
+
12
+ - **Класи компонентів:** `q-pa-*`, `q-ma-*`, `q-mt-*`, `q-mb-*`, `row`, `col`, `col-*`, `items-center`, `justify-between`, `text-*`, `bg-*`, `shadow-*` тощо — стандартні утиліти Quasar.
13
+ - **Кольори:** використовуй Quasar CSS-змінні через класи (`text-primary`, `bg-accent`, `text-negative` тощо) або через SCSS-змінні (`$primary`, `$accent`) у `.scss`-файлах.
14
+ - **Не пиши власні утиліти**, якщо є відповідний Quasar-клас.
15
+
16
+ ## Кольори: quasar-variables.scss і app.scss
17
+
18
+ Основні кольори визначай виключно через стандартні Quasar-змінні: `$primary`, `$secondary`, `$accent`, `$positive`, `$negative`, `$warning`.
19
+
20
+ Якщо потрібен додатковий колір — визнач його в `quasar-variables.scss`, і **обов'язково** додай до `app.scss` відповідні `.text-*` / `.bg-*` класи:
21
+
22
+ ```scss
23
+ /* quasar-variables.scss */
24
+ $white-a1: color.adjust(white, $alpha: -0.85);
25
+ ```
26
+
27
+ ```scss
28
+ /* app.scss */
29
+ .text-white-a1 {
30
+ color: $white-a1 !important;
31
+ }
32
+
33
+ .bg-white-a1 {
34
+ background-color: $white-a1 !important;
35
+ }
36
+ ```
37
+
38
+ ## Gap між flex/grid-елементами — .n-gap-*
39
+
40
+ Для відступів між плаваючими елементами (flex, grid) використовуй класи `.n-gap-*` замість `q-gutter-*`. Якщо класів немає в `app.scss` — додай:
41
+
42
+ ```scss
43
+ // GAP
44
+ .n-gap-xs {
45
+ gap: 4px;
46
+ }
47
+
48
+ .n-gap-sm {
49
+ gap: 8px;
50
+ }
51
+
52
+ .n-gap-md {
53
+ gap: 16px;
54
+ }
55
+
56
+ .n-gap-lg {
57
+ gap: 24px;
58
+ }
59
+ ```
60
+
61
+ ## Фікси компонентів Quasar
62
+
63
+ При використанні компонента `q-scroll-area` або `q-tooltip` додай до `app.scss` відповідні фікси. Аналогічно — фікс масштабування на iOS.
64
+
65
+ ```scss
66
+ // FIX стилі для прокручуваної області по висоті екрана
67
+ .q-scrollarea {
68
+ display: flex;
69
+ flex-direction: column;
70
+ height: auto;
71
+ min-height: 0;
72
+ max-height: 100%;
73
+
74
+ .scroll {
75
+ flex: 10000 1 0%;
76
+ }
77
+ }
78
+
79
+ // FIX tooltip
80
+ .q-tooltip {
81
+ font-size: 1em;
82
+ }
83
+
84
+ // FIX не зумувати на iOS
85
+ @media (width <= 600px) {
86
+ input,
87
+ textarea,
88
+ select {
89
+ font-size: 16px;
90
+ }
91
+ }
92
+ ```
93
+
8
94
  ## Генерація та редагування стилів (Cursor і інші агенти)
9
95
 
10
96
  - **Джерело правил:** перед тим як писати або суттєво змінювати **`.css`**, **`.scss`** або стилі в **`.vue`**, переглянь у корені проєкту (і в релевантних пакетах монорепо, якщо є) поле **`stylelint`** у **`package.json`** (зокрема `extends`), наявні **`.stylelintrc.*`**, **`stylelint.config.*`** та **`.stylelintignore`**. Не покладайся на «типові» правила stylelint з пам’яті — дотримуйся **проєктного** **`@nitra/stylelint-config`** і будь-яких локальних доповнень у репозиторії.
@@ -94,3 +180,92 @@ jobs:
94
180
  ```text title=".stylelintignore"
95
181
  dist/
96
182
  ```
183
+
184
+ ## Адмінські таблиці — n-admin-table
185
+
186
+ Для таблиць, що мають займати всю висоту сторінки (або блоку), додавай клас `n-admin-table` і атрибути `hide-no-data`, `dense`, `flat`. Таблиця автоматично розтягнеться на весь доступний простір. У блоках меншої висоти — задай їм явний `height`.
187
+
188
+ ```vue
189
+ <!-- ТАБЛИЦЯ -->
190
+ <q-table class="n-admin-table" hide-no-data flat dense />
191
+ ```
192
+
193
+ Якщо клас `.n-admin-table` не визначено в `app.scss` — додай:
194
+
195
+ ```scss
196
+ // ADMIN TABLE
197
+ .n-admin-table {
198
+ height: calc(100vh - 106px);
199
+
200
+ thead {
201
+ position: sticky;
202
+ z-index: 3;
203
+ top: 0;
204
+
205
+ tr th {
206
+ background-color: $grey-3;
207
+ height: 36px !important;
208
+ }
209
+ }
210
+
211
+ // tbody td {
212
+ // font-size: 14px;
213
+ // }
214
+
215
+ tbody tr:last-child td {
216
+ border-bottom: 1px solid rgb(0 0 0 / 12%) !important;
217
+ }
218
+
219
+ th:first-child,
220
+ td:first-child,
221
+ th:last-child,
222
+ td:last-child {
223
+ width: 1px;
224
+ white-space: nowrap;
225
+ }
226
+
227
+ .q-table__bottom {
228
+ background-color: $grey-3;
229
+ }
230
+
231
+ .q-table__progress th {
232
+ height: 1px !important;
233
+ }
234
+
235
+ .text-wrap {
236
+ max-width: 250px;
237
+ white-space: normal;
238
+ }
239
+
240
+ // sticky columns
241
+ td.sticky-left {
242
+ position: sticky;
243
+ left: 0;
244
+ z-index: 2;
245
+ background-color: white;
246
+ box-shadow: 2px 0 5px #0002;
247
+ }
248
+
249
+ td.sticky-right {
250
+ position: sticky;
251
+ right: 0;
252
+ z-index: 2;
253
+ background-color: white;
254
+ box-shadow: -2px 0 5px #0002;
255
+ }
256
+
257
+ th.sticky-left {
258
+ position: sticky;
259
+ left: 0;
260
+ z-index: 3;
261
+ box-shadow: 2px 0 5px #0002;
262
+ }
263
+
264
+ th.sticky-right {
265
+ position: sticky;
266
+ right: 0;
267
+ z-index: 3;
268
+ box-shadow: -2px 0 5px #0002;
269
+ }
270
+ }
271
+ ```
package/rules/vue/vue.mdc CHANGED
@@ -12,18 +12,30 @@ alwaysApply: false
12
12
  ```javascript
13
13
  const vue3CompositionApiBestPractices = [
14
14
  'Використовуй функцію setup() для логіки компонента',
15
- 'Реалізуй computed properties через $computed()',
16
- 'Використовуй watch і watchEffect для side effects',
15
+ 'Реалізуй computed змінні через $computed()',
16
+ 'Реалізуй ref змінні через $ref',
17
+ 'Використовуй watch і watchEffect для побічних ефектів',
17
18
  'Підключай lifecycle hooks: onMounted, onUpdated тощо',
18
- 'не використовуй provide/inject для dependency injection'
19
+ 'Для глибоко вкладених залежностей використовуй composables, props/emits або store'
20
+ 'не використовуй provide/inject для залежностей'
19
21
  ]
20
22
  ```
21
23
 
24
+ ## Quasar як UI-основа
25
+
26
+ У Vue-проектах використовуй **Quasar** як базовий UI-фреймворк:
27
+
28
+ - **Компоненти:** `q-btn`, `q-input`, `q-select`, `q-table`, `q-dialog`, `q-card`, `q-layout`, `q-page`, `q-drawer` тощо — основа UI.
29
+ - **Плагіни:** `Notify`, `Dialog`, `Loading` та інші Quasar-плагіни.
30
+ - **Кольори:** використовуй Quasar CSS-змінні (`primary`, `secondary`, `accent`, `positive`, `negative`, `warning`, `info`, `dark`) і утиліти (`text-primary`, `bg-accent` тощо).
31
+ - **Утиліти:** flex-layout (`row`, `col`, `items-center`), spacing (`q-pa-md`, `q-mt-sm`), shadow (`shadow-2`) — зі стандартної бібліотеки Quasar.
32
+ - **Кастомні компоненти** `@nitra/components` — **надбудова** над Quasar, а не заміна; їх слід надавати перевагу лише там, де вони є (див. розділ `@nitra/components` нижче).
33
+
22
34
  ## Структура папок
23
35
 
24
36
  ```javascript
25
37
  const folderStructure = `
26
- src/
38
+ src/
27
39
  components/
28
40
  composables/
29
41
  views/
@@ -76,7 +88,7 @@ const additionalInstructions = `
76
88
 
77
89
  ### Патерни та антипатерни
78
90
 
79
- - **provide/inject** — для глибоко вкладених залежностей; **renderless**-компоненти / **slots** — коли логіку відділяєш від розмітки.
91
+ - Для глибоко вкладених залежностей використовуй **composables**, **props/emits** або **store**; **renderless**-компоненти / **slots** — коли логіку відділяєш від розмітки.
80
92
  - **HTTP:** окремі модулі **services** або **composables** для API; **async/await**.
81
93
  - **Події:** батько–дитина через **emits**; для не пов’язаних гілок — **store**.
82
94
  - Не мутуй **props** напряму — оновлення через подію вгору або v-model.
@@ -343,6 +355,292 @@ import path from 'node:path'
343
355
  Правило стосується саме `.vue` файлів. Допоміжні `.ts`/`.js` модулі, які споживаються лише server-side
344
356
  (наприклад, окремий пакет утіліт), можуть імпортувати Node-built-ins без обмежень.
345
357
 
358
+ ## @nitra/components — надавай перевагу перед Quasar-компонентами
359
+
360
+ При створенні нової функціональності використовуй компоненти `@nitra/components`, якщо логіка компонента дозволяє отримати потрібний функціонал. Заміни:
361
+
362
+ | Завдання | `@nitra/components` | Замість Quasar |
363
+ | --- | --- | --- |
364
+ | Діалоги | `NDialog` | `q-dialog` |
365
+ | Multi-вибір | `NSelectMulti` | `q-select` (multiple) |
366
+ | Текстовий редактор | `NEditor` | `q-editor` |
367
+ | Вибір дати | `NDate` | — |
368
+ | Вибір місяця/року | `NDateMonthYear` | — |
369
+ | Діапазон дат | `NDateRange` | — |
370
+ | Дата і час | `NDateTime` | — |
371
+ | Drag&drop список | `NDraggableList` | — |
372
+ | Редаговане значення | `NEditableString` | — |
373
+ | Повідомлення | `NCallout` | — |
374
+ | Хедер проекту | `NHeader` | — |
375
+ | Перемикання мов | `NLang` | — (якщо `NHeader` не використовується) |
376
+ | Меню проекту | `NMenu` | — |
377
+ | Зображення (масив / одиночне) | `NImages` | — |
378
+ | Завантаження файлів | `NUploader` | — |
379
+ | Вибір колонок `q-table` | `NTableColumns` | — |
380
+
381
+ ## NHeader — телепорт-слоти на сторінках
382
+
383
+ У проектах з `NHeader` використовуй `<teleport>` для вбудовування контенту сторінки в шапку:
384
+
385
+ | Слот | Призначення |
386
+ | --- | --- |
387
+ | `#header-subtitle` | Заголовок сторінки (якщо потрібно перевизначити subtitle) |
388
+ | `#header-center` | Важливі повідомлення та елементи управління |
389
+ | `#header-filters` | Фільтри, кнопки та інші елементи управління сторінки |
390
+
391
+ Оскільки `NHeader` за замовчуванням темний, додавай до полів і селектів у телепортах: `dark`, `standout="bg-white text-primary"`, `:options-dark="false"`.
392
+
393
+ Завжди обгортай `<teleport>` в `v-if="mounted"` (де `mounted` — `$ref(false)`, що встановлюється в `onMounted`), щоб уникнути помилки відсутності target-елемента при SSR / першому рендері.
394
+
395
+ ```vue
396
+ <template>
397
+ <q-page>
398
+ <!-- ФІЛЬТРИ В ШАПЦІ -->
399
+ <teleport v-if="mounted" to="#header-filters">
400
+ <div class="col row items-center n-gap-sm q-pa-sm">
401
+ <q-input
402
+ v-model="pageStore.filterName"
403
+ :label="t`Поиск по названию`"
404
+ debounce="200"
405
+ standout="bg-white text-primary"
406
+ clearable
407
+ dense
408
+ dark
409
+ class="col"
410
+ style="min-width: 120px">
411
+ <template #prepend>
412
+ <q-icon name="search" />
413
+ </template>
414
+ </q-input>
415
+
416
+ <n-select-multi
417
+ v-model="pageStore.filterRequestTypes"
418
+ :options="requestTypeOptions"
419
+ :label="t`Тип запроса`"
420
+ dark
421
+ standout="bg-white text-primary"
422
+ :options-dark="false"
423
+ style="min-width: 180px; max-width: 400px"
424
+ dense
425
+ emit-value
426
+ map-options
427
+ clearable
428
+ searchable
429
+ :stack-label="false" />
430
+
431
+ <q-btn
432
+ @click="addItem"
433
+ icon="add"
434
+ :label="t`Добавить`"
435
+ color="primary"
436
+ no-caps
437
+ padding="8px 12px"
438
+ unelevated />
439
+ </div>
440
+ </teleport>
441
+
442
+ <!-- ОСНОВНИЙ КОНТЕНТ -->
443
+ ...
444
+ </q-page>
445
+ </template>
446
+ <script setup>
447
+ const mounted = $ref(false)
448
+ onMounted(() => { mounted = true })
449
+ </script>
450
+ ```
451
+
452
+ ## @nitra/tfm — переклади
453
+
454
+ Використовуй `@nitra/tfm` для всіх текстів. Переклади для всіх мов проекту оголошуй наприкінці `<script setup>` у функції `getTr()`. Змінна `lang` із `@nitra/tfm` — для визначення або зміни поточної мови застосунку.
455
+
456
+ ```vue
457
+ <template>
458
+ {{ lang }}
459
+ {{ t`Анкеты` }}
460
+ {{ subtitle }}
461
+ </template>
462
+ <script setup>
463
+ import { lang, tf as tfm } from '@nitra/tfm'
464
+ const t = tfm.bind({ tr: getTr() })
465
+ const subtitle = $computed(() => t`Анкеты`)
466
+
467
+ /**
468
+ * LOCALIZATION
469
+ * @returns {object} translations
470
+ */
471
+ function getTr() {
472
+ return {
473
+ Анкеты: { en: 'Surveys', ro: 'Sondaje', tr: 'Anketler' }
474
+ }
475
+ }
476
+ </script>
477
+ ```
478
+
479
+ ## Layout з NHeader
480
+
481
+ Для нових layout-ів використовуй `NHeader` з вбудованими `NLang` і `NMenu`.
482
+
483
+ ```vue
484
+ <template>
485
+ <q-layout view="hHh Lpr lFf">
486
+ <n-header
487
+ v-model="leftSideOpened"
488
+ :logo="baseUrl + 'logo.png'"
489
+ :logo-url="homeUrl"
490
+ title="My App"
491
+ :subtitle="subtitle"
492
+ :username="userName"
493
+ toolbar-dark>
494
+ <template #top-toolbar>
495
+ <div class="platform-ios-only q-py-lg" />
496
+ </template>
497
+ <div>default slot content</div>
498
+ </n-header>
499
+
500
+ <!-- ЛЕВАЯ КОЛОНКА -->
501
+ <q-drawer v-model="leftSideOpened" :breakpoint="700" :width="300" overlay class="col column shadow-5 bg-grey-1">
502
+ <div class="platform-ios-only q-py-lg" />
503
+ <div v-if="!$q.screen.gt.xs" class="q-pa-sm row items-center">
504
+ <q-icon name="account_circle" color="primary" size="32px" class="q-mr-sm" />
505
+ <div class="text-subtitle1">{{ user.name }}</div>
506
+ </div>
507
+ <n-menu v-model="activeMenu" :menu="menu" />
508
+ <q-space />
509
+ <n-menu :menu="homeMenu" />
510
+ <div class="platform-ios-only q-py-md" />
511
+ </q-drawer>
512
+
513
+ <q-page-container>
514
+ <router-view />
515
+ </q-page-container>
516
+ </q-layout>
517
+ </template>
518
+ <script setup>
519
+ import { lang, tf as tfm } from '@nitra/tfm'
520
+ const t = tfm.bind({ tr: getTr() })
521
+
522
+ const baseUrl = import.meta.env.BASE_URL
523
+ const homeUrl = String.raw`https:\\` + import.meta.env.VITE_DOMAIN
524
+
525
+ // Ліва колонка
526
+ const leftSideOpened = $ref(false)
527
+ const activeMenu = $ref(null)
528
+
529
+ // Заголовок у шапці з поточного пункту меню
530
+ const subtitle = computed(() => (activeMenu ? getTr()[activeMenu.labelKey]?.[lang.value] || activeMenu?.labelKey : ''))
531
+
532
+ // Меню
533
+ const menu = computed(() =>
534
+ [
535
+ {
536
+ icon: 'sym_o_store',
537
+ label: t`Клиенты`,
538
+ labelKey: t`Клиенты`,
539
+ routeName: 'customer'
540
+ },
541
+ {
542
+ icon: 'sym_o_route',
543
+ label: t`Маршруты и визиты`,
544
+ items: [
545
+ {
546
+ icon: 'sym_o_route',
547
+ label: t`Маршруты`,
548
+ labelKey: t`Маршруты`,
549
+ routeName: 'route'
550
+ },
551
+ {
552
+ icon: 'sym_o_event_upcoming',
553
+ label: t`Переносы маршрутов`,
554
+ labelKey: t`Переносы маршрутов`,
555
+ routeName: 'route_postpone'
556
+ }
557
+ ]
558
+ }
559
+ ].filter(item => {
560
+ if (item.items?.length) {
561
+ item.items = item.items.filter(i => can[i.permissionRoute || i.routeName])
562
+ return item.items.length > 0
563
+ }
564
+ return can[item.routeName]
565
+ })
566
+ )
567
+
568
+ /**
569
+ * LOCALIZATION
570
+ * @returns {object} translations
571
+ */
572
+ function getTr() {
573
+ return {
574
+ Клиенты: { en: 'Customers', ro: 'Clienți', tr: 'Müşteriler' }
575
+ }
576
+ }
577
+ </script>
578
+ ```
579
+
580
+ ## Pinia store для стану сторінки
581
+
582
+ Зберігай у Pinia store:
583
+
584
+ - вибрані значення фільтрів
585
+ - вибрані для відображення колонки (`NTableColumns`)
586
+ - кількість записів на сторінці (pagination)
587
+
588
+ Називай store за назвою сторінки або компонента — `customerPageStore`, `routePageStore` тощо. На сторінці звертайся до нього через змінну `pageStore`.
589
+
590
+ ```javascript
591
+ // store/customerPage.js
592
+ export const useCustomerPageStore = defineStore('customerPage', {
593
+ state: () => ({
594
+ filterName: '',
595
+ filterStatus: [],
596
+ columns: [],
597
+ rowsPerPage: 20
598
+ })
599
+ })
600
+ ```
601
+
602
+ ```vue
603
+ <script setup>
604
+ const pageStore = useCustomerPageStore()
605
+ </script>
606
+ ```
607
+
608
+ ## Коментарі в `<template>`
609
+
610
+ Додавай коментарі в `<template>` відповідно до логічного призначення блоку. Коментарі допомагають швидко орієнтуватися в розмітці.
611
+
612
+ ```vue
613
+ <template>
614
+ <q-page>
615
+ <!-- ФІЛЬТРИ В ШАПЦІ -->
616
+ <teleport v-if="mounted" to="#header-filters">...</teleport>
617
+
618
+ <!-- ТАБЛИЦЯ -->
619
+ <q-table ... />
620
+
621
+ <!-- ДІАЛОГ РЕДАГУВАННЯ -->
622
+ <n-dialog v-model="editDialog">...</n-dialog>
623
+ </q-page>
624
+ </template>
625
+ ```
626
+
627
+ ## Статичні файли — BASE_URL
628
+
629
+ Для підключення статичних файлів не використовуй відносні шляхи. Завжди будуй URL через `import.meta.env.BASE_URL`:
630
+
631
+ ```vue
632
+ <!-- Погано -->
633
+ <img src="/logo.png" />
634
+ <img src="./assets/logo.png" />
635
+
636
+ <!-- Добре -->
637
+ <img :src="baseUrl + 'logo.png'" />
638
+ ```
639
+
640
+ ```javascript
641
+ const baseUrl = import.meta.env.BASE_URL
642
+ ```
643
+
346
644
  ## Перевірка
347
645
 
348
646
  `npx @nitra/cursor fix vue` — перевіряє залежності, `vite.config`, наявність **`src/vite-env.d.ts`** з `/// <reference types="vite/client" />` та **`jsconfig.json`** у корені Vue-пакета; обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/rules/vue/js/packages/vue-forbidden-imports.mjs`).
@@ -1,33 +1,48 @@
1
1
  /**
2
2
  * Резолвить корінь JS-коду в проєкті: для workspace-projects — перший workspace
3
- * (наприклад `app/` у mail app), для single-package — корінь cwd. Спільна утиліта
4
- * для coverage-провайдера js-lint і test-концерну stryker_config (DRY).
3
+ * (з підтримкою glob-патернів типу `cf/*`), для single-package — корінь cwd.
4
+ * Спільна утиліта для coverage-провайдера js-lint і test-концерну stryker_config (DRY).
5
5
  */
6
6
  import { existsSync } from 'node:fs'
7
- import { readFile } from 'node:fs/promises'
7
+ import { glob, readFile } from 'node:fs/promises'
8
8
  import { join } from 'node:path'
9
9
 
10
+ const WORKSPACE_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**']
11
+
12
+ /**
13
+ * Розгортає один workspace-патерн у список абсолютних шляхів каталогів з package.json.
14
+ * Літеральні патерни перевіряються через existsSync; glob-патерни — через node:fs/promises#glob.
15
+ * @param {string} cwd корінь проєкту
16
+ * @param {string} pattern workspace-патерн з package.json (наприклад, `app` або `cf/*`)
17
+ * @returns {Promise<string[]>} абсолютні шляхи до workspace-каталогів
18
+ */
19
+ async function expandWorkspacePattern(cwd, pattern) {
20
+ if (!pattern.includes('*')) {
21
+ const wsPath = join(cwd, pattern)
22
+ return existsSync(join(wsPath, 'package.json')) ? [wsPath] : []
23
+ }
24
+ const results = []
25
+ for await (const rel of glob(`${pattern}/package.json`, { cwd, exclude: WORKSPACE_GLOB_IGNORE })) {
26
+ const wsRel = rel.replace(/[/\\]package\.json$/, '')
27
+ results.push(join(cwd, wsRel))
28
+ }
29
+ return results.sort()
30
+ }
31
+
10
32
  /**
11
33
  * @param {string} cwd корінь проєкту (де `.n-cursor.json` і кореневий package.json)
12
34
  * @returns {Promise<string|null>} абсолютний шлях до JS-root або null без кореневого package.json
13
35
  */
14
36
  export async function resolveJsRoot(cwd) {
15
- const rootPkgPath = join(cwd, 'package.json')
16
- if (!existsSync(rootPkgPath)) return null
17
- const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
18
- const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
19
- if (workspaces.length > 0) {
20
- const wsPath = join(cwd, workspaces[0])
21
- if (existsSync(join(wsPath, 'package.json'))) return wsPath
22
- }
23
- return cwd
37
+ const roots = await resolveAllJsRoots(cwd)
38
+ if (roots.length === 0) return null
39
+ return roots[0]
24
40
  }
25
41
 
26
42
  /**
27
43
  * Plural-варіант: повертає всі JS-roots проєкту. Для workspace-projects — кожен
28
- * workspace з власним `package.json`; для single-package `[cwd]`. Порожній
29
- * масив без кореневого package.json. Використовується test-концерном
30
- * `stryker_config` для per-workspace baseline-копіювання.
44
+ * workspace з власним `package.json` розгортанням glob-патернів); для
45
+ * single-package — `[cwd]`. Порожній масив без кореневого package.json.
31
46
  * @param {string} cwd корінь проєкту
32
47
  * @returns {Promise<string[]>} абсолютні шляхи до всіх JS-roots
33
48
  */
@@ -35,12 +50,11 @@ export async function resolveAllJsRoots(cwd) {
35
50
  const rootPkgPath = join(cwd, 'package.json')
36
51
  if (!existsSync(rootPkgPath)) return []
37
52
  const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
38
- const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
39
- if (workspaces.length === 0) return [cwd]
53
+ const patterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
54
+ if (patterns.length === 0) return [cwd]
40
55
  const roots = []
41
- for (const ws of workspaces) {
42
- const wsPath = join(cwd, ws)
43
- if (existsSync(join(wsPath, 'package.json'))) roots.push(wsPath)
56
+ for (const pattern of patterns) {
57
+ roots.push(...(await expandWorkspacePattern(cwd, pattern)))
44
58
  }
45
59
  return roots.length > 0 ? roots : [cwd]
46
60
  }