@nitra/cursor 1.8.192 → 1.8.193

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,18 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.193] - 2026-05-07
8
+
9
+ ### Fixed
10
+
11
+ - `check-image.mjs`: cleanup AVIF-сиріт більше не зачіпає `.avif` файли всередині пакетів з опт-аутом (`"@nitra/minify-image": { "disable-avif": true }`). Раніше: пакет з опт-аутом не сканувався на refs → його `.avif` потрапляли у список «сиріт» і видалялись, навіть якщо насправді використовувалися через alias / runtime-обчислений шлях. Тепер `checkVueAvifImports` повертає список абсолютних коренів opt-out пакетів, а `cleanupOrphanAvifs` пропускає `.avif` під ними.
12
+ - `check-image.mjs`: запис у `.vue`/`.html` тепер строго послідовний з cleanup (write-then-cleanup): перший виконує `checkVueAvifImports` (per-file `writeFile` після обробки), і тільки після цього `cleanupOrphanAvifs` читає вже оновлені `usedAvifAbs` і видаляє лише дійсних сиріт.
13
+ - `check-image.mjs`: введено агреговані лічильники `RewriteStats` (`rewrittenRefs` / `rewrittenFiles` / `failedRefs`) і єдиний фінальний рядок-підсумок `image: rewrote N references in M files; deleted K orphan AVIFs; failed to rewrite L references` — раніше підсумок дублювався per-package і не виокремлював orphan-cleanup vs failed-rewrites.
14
+
15
+ ### Added
16
+
17
+ - `tests/check-image.test.mjs`: 5 нових кейсів — статичний `<img src="a.png">` авто-переписується (за наявності `a.png` і `a.png.avif`); реактивне `:src="dyn"` залишається незмінним і orphan AVIF видаляється; змішані форми у одному файлі (статичний + import + реактивний + `data-src=`) — переписуються лише покривані; opt-out пакет — AVIF всередині не вважається сиротою; ідемпотентність повторного `check image` на чистому стані.
18
+
7
19
  ## [1.8.192] - 2026-05-07
8
20
 
9
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.192",
3
+ "version": "1.8.193",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -250,13 +250,26 @@ function resolveImagePath(importPath, sourceAbsPath) {
250
250
  return null
251
251
  }
252
252
 
253
+ /**
254
+ * Аґреговані лічильники по проходу `check image`:
255
+ * - `rewrittenRefs` — скільки конкретних посилань (по одному на match) переписано на `.avif`;
256
+ * - `rewrittenFiles` — у скількох `.vue`/`.html` файлах хоч одне посилання змінилося;
257
+ * - `failedRefs` — скільки конкретних посилань не вдалося переписати (`.avif` не існував).
258
+ * @typedef {object} RewriteStats
259
+ * @property {number} rewrittenRefs
260
+ * @property {number} rewrittenFiles
261
+ * @property {number} failedRefs
262
+ */
263
+
253
264
  /**
254
265
  * Сканує `.vue` і `.html` файли одного workspace-пакета: де можемо, переписує raster-посилання
255
- * на `<path>.avif`, де не можемо — фейлимо. Повертає множину `.avif`-двійників, на які
256
- * лишилось живе посилання після проходу потрібно для подальшого прибирання сиріт.
266
+ * на `<path>.avif`, де не можемо — фейлимо. Доповнює `usedAvifAbs` шляхами AVIF-двійників, на
267
+ * які лишилось живе посилання, і `stats` лічильниками rewrite/fail для глобального підсумку.
257
268
  *
258
269
  * Заміна виконується ТІЛЬКИ якщо AVIF-двійник реально існує на диску. Якщо AVIF немає
259
270
  * (наприклад, оригіналу теж немає, тож `--avif` його не згенерував) — фейл, як раніше.
271
+ * Запис файла відбувається ОДРАЗУ після обробки одного файла (write-then-fail): провал на
272
+ * наступному файлі НЕ відкочує вже записані зміни попередніх.
260
273
  *
261
274
  * Файли, що належать іншим workspace-пакетам, ігноруються — кожен пакет перевіряється рівно
262
275
  * один раз (інакше при обході кореня `.` ми б повторно зайшли в `demo/` і подвоїли звіти).
@@ -265,11 +278,11 @@ function resolveImagePath(importPath, sourceAbsPath) {
265
278
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
266
279
  * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`, що мають
267
280
  * хоч одне посилання у `.vue`/`.html` (доповнюється у цій функції)
268
- * @param {(msg: string) => void} pass callback при успішній перевірці
281
+ * @param {RewriteStats} stats глобальні лічильники, що мутуються тут
269
282
  * @param {(msg: string) => void} fail callback при помилці
270
283
  * @returns {Promise<void>} резолвиться по завершенню перевірки одного пакета
271
284
  */
272
- async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, pass, fail) {
285
+ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail) {
273
286
  const absRoot = join(process.cwd(), packageRoot)
274
287
  const label = packageRoot === '.' ? 'корінь' : packageRoot
275
288
  /** @type {string[]} */
@@ -285,8 +298,6 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
285
298
  )
286
299
  if (targetFiles.length === 0) return
287
300
 
288
- let violations = 0
289
- let replacements = 0
290
301
  for (const absPath of targetFiles) {
291
302
  const rel = relative(process.cwd(), absPath).split('\\').join('/')
292
303
  const original = await readFile(absPath, 'utf8')
@@ -302,11 +313,11 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
302
313
  const replaced = full.replace(importPath, newImportPath)
303
314
  const imageAbs = resolveImagePath(importPath, absPath)
304
315
  if (imageAbs && existsSync(`${imageAbs}.avif`)) {
305
- replacements++
316
+ stats.rewrittenRefs++
306
317
  usedAvifAbs.add(`${imageAbs}.avif`)
307
318
  return replaced
308
319
  }
309
- violations++
320
+ stats.failedRefs++
310
321
  fail(renderFailure(importPath))
311
322
  return full
312
323
  })
@@ -333,28 +344,34 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
333
344
 
334
345
  if (updated !== original) {
335
346
  await writeFile(absPath, updated, 'utf8')
347
+ stats.rewrittenFiles++
336
348
  }
337
349
  }
338
- if (violations === 0) {
339
- const summary = replacements > 0 ? `усі raster-посилання у .vue/.html переписано на .avif (замін: ${replacements})` : 'усі raster-посилання у .vue/.html вже на .avif (або відсутні)'
340
- pass(`[${label}] ${summary}`)
341
- }
342
350
  }
343
351
 
344
352
  /**
345
353
  * Сканує всі workspace-пакети: для кожного перевіряє opt-out і за потреби викликає
346
354
  * перевірку Vue-imports. Перевірка пропускається, якщо в репозиторії немає workspaces
347
355
  * або немає `.vue`-файлів — тоді `image` правило не для цього проєкту.
356
+ *
357
+ * Повертає список абсолютних коренів пакетів, у яких ввімкнено opt-out (`disable-avif: true`).
358
+ * Це окремий результат, бо AVIF всередині такого пакета НЕ можна вважати «сиротою» лише
359
+ * на підставі відсутності посилань у його `.vue`/`.html` (ми взагалі не сканували його
360
+ * шаблони) — інакше cleanup помилково затирав би AVIF, що використовуються через alias /
361
+ * runtime-обчислений шлях / зовнішні посилання, які тут не видно.
348
362
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
349
363
  * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`-двійників,
350
364
  * на які лишилось хоча б одне посилання у `.vue`/`.html` (заповнюється у викликаних функціях)
365
+ * @param {RewriteStats} stats глобальні лічильники rewrite/fail (мутуються нижче)
351
366
  * @param {(msg: string) => void} pass callback при успішній перевірці
352
367
  * @param {(msg: string) => void} fail callback при помилці
353
- * @returns {Promise<void>} резолвиться по завершенню перевірки всіх workspace-пакетів
368
+ * @returns {Promise<string[]>} абсолютні шляхи коренів пакетів з активним opt-out
354
369
  */
355
- async function checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail) {
370
+ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail) {
356
371
  const roots = await getMonorepoPackageRootDirs()
357
372
  const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
373
+ /** @type {string[]} */
374
+ const optedOutAbs = []
358
375
  for (const root of roots) {
359
376
  const pkgPath = join(root, 'package.json')
360
377
  if (!existsSync(pkgPath)) continue
@@ -363,11 +380,13 @@ async function checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail) {
363
380
  pass(
364
381
  `[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`
365
382
  )
383
+ optedOutAbs.push(absRootsByRel.get(root) ?? join(process.cwd(), root))
366
384
  continue
367
385
  }
368
386
  const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
369
- await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, pass, fail)
387
+ await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail)
370
388
  }
389
+ return optedOutAbs
371
390
  }
372
391
 
373
392
  /**
@@ -423,12 +442,18 @@ function runAvifGeneration() {
423
442
  * Видаляє AVIF-сироти — `<...>.avif` файли, на які не лишилось жодного посилання
424
443
  * у `.vue`/`.html` репозиторію. Реалізує умову правила: «AVIF лишається лише там,
425
444
  * де заміна реально вдалася».
445
+ *
446
+ * AVIF файли всередині opt-out пакетів (`disable-avif: true`) пропускаються — ми не
447
+ * сканували їх шаблони, тож не маємо права вважати їх AVIF сиротами. Це гарантує
448
+ * ідемпотентність повторного `check image` для пакетів, що навмисно вимкнули правило
449
+ * (наприклад, мобільний бандл, де AVIF підтримка не гарантована).
426
450
  * @param {Set<string>} usedAvifAbs абсолютні шляхи `.avif`, що мають живі посилання
451
+ * @param {string[]} optedOutAbs абсолютні шляхи коренів пакетів з опт-аутом —
452
+ * `.avif` під ними не вважаємо сиротами і не видаляємо
427
453
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
428
- * @param {(msg: string) => void} pass callback при успішній перевірці
429
- * @returns {Promise<void>} резолвиться після видалення всіх сиріт
454
+ * @returns {Promise<number>} кількість видалених сиріт
430
455
  */
431
- async function cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass) {
456
+ async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths) {
432
457
  /** @type {string[]} */
433
458
  const orphans = []
434
459
  await walkDir(
@@ -436,6 +461,7 @@ async function cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass) {
436
461
  absPath => {
437
462
  if (!absPath.endsWith('.avif')) return
438
463
  if (usedAvifAbs.has(absPath)) return
464
+ if (optedOutAbs.some(root => absPath === root || absPath.startsWith(`${root}/`))) return
439
465
  orphans.push(absPath)
440
466
  },
441
467
  ignorePaths
@@ -443,9 +469,7 @@ async function cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass) {
443
469
  for (const absPath of orphans) {
444
470
  await unlink(absPath)
445
471
  }
446
- if (orphans.length > 0) {
447
- pass(`видалено AVIF-сиріт без посилань у .vue/.html: ${orphans.length}`)
448
- }
472
+ return orphans.length
449
473
  }
450
474
 
451
475
  /**
@@ -493,8 +517,16 @@ export async function check() {
493
517
 
494
518
  /** @type {Set<string>} */
495
519
  const usedAvifAbs = new Set()
496
- await checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail)
497
- await cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass)
520
+ /** @type {RewriteStats} */
521
+ const stats = { rewrittenRefs: 0, rewrittenFiles: 0, failedRefs: 0 }
522
+ const optedOutAbs = await checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
523
+ const orphansDeleted = await cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths)
524
+
525
+ pass(
526
+ `image: rewrote ${stats.rewrittenRefs} reference${stats.rewrittenRefs === 1 ? '' : 's'} in ${stats.rewrittenFiles} file${stats.rewrittenFiles === 1 ? '' : 's'}; ` +
527
+ `deleted ${orphansDeleted} orphan AVIF${orphansDeleted === 1 ? '' : 's'}; ` +
528
+ `failed to rewrite ${stats.failedRefs} reference${stats.failedRefs === 1 ? '' : 's'}`
529
+ )
498
530
 
499
531
  return reporter.getExitCode()
500
532
  }