@nitra/cursor 1.8.192 → 1.8.197

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,46 @@
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.197] - 2026-05-07
8
+
9
+ ### Changed
10
+
11
+ - `image` правило розщеплене на два самостійні: **`image-compress`** (валідація `lint-image` / `.gitignore` / залежностей — стиснення raster/SVG через `@nitra/minify-image`) і **`image-avif`** (генерація AVIF-двійників, переписування raster-посилань у `.vue`/`.html` на `.avif`, прибирання AVIF-сиріт). Це дозволяє тримати компресію всюди, а AVIF — лише там, де його підтримка гарантована (адмінки), вимикаючи його для публічних сайтів через `disable-rules: ["image-avif"]` у `.n-cursor.json` чи опт-аут на рівні пакета (`"@nitra/minify-image": { "disable-avif": true }` у `package.json` сайту).
12
+ - `auto-rules.md` / `auto-rules.mjs`: автодетект `image-compress - [bun]` (всюди, де є `package.json`), `image-avif - [vue, image-compress]` (лише для проєктів з `.vue`-файлами і вже активним `image-compress`).
13
+ - Видалено `npm/scripts/check-image.mjs` і `npm/mdc/image.mdc` — їх замінили `check-image-compress.mjs` + `check-image-avif.mjs` і `image-compress.mdc` + `image-avif.mdc`.
14
+ - Канонічний `lint-image` залишається без `--avif` (його перевіряє `image-compress`); `npx @nitra/cursor check image-avif` тепер є самостійною командою для AVIF-pipeline.
15
+
16
+ ### Added
17
+
18
+ - `tests/auto-rules.test.mjs`: тест на `disable-rules: ["image-compress"]` → `image-avif` теж не додається (транзитивна залежність).
19
+
20
+ ## [1.8.194] - 2026-05-07
21
+
22
+ ### Fixed
23
+
24
+ - `check-image.mjs`: резолвер `resolveImagePath` був інлайн-наївний (`/path` → `<cwd>/<path>`, голий шлях → `null`), що в реальних Quasar/Vite-проєктах давало 0 rewrite-ів і помилковий ріст `failedRefs`. Замінено на `resolveImageCandidates`, який повертає **впорядкований список кандидатів**:
25
+ - `./x.png` / `../x.png` → відносно файла-джерела;
26
+ - `/x.png` → `<packageRoot>/public/x.png`, потім `<packageRoot>/x.png`, потім `<cwd>/x.png` (legacy fallback);
27
+ - голий шлях з принаймні одним `/` (`assets/img.png`, `start-page-ua/logo.png`) → відносно файла-джерела + `<packageRoot>/public/<path>` (Quasar-конвенція);
28
+ - bare без `/` → alias resolver невідомий, посилання тихо пропускаємо (без fail).
29
+ - `check-image.mjs`: `cleanupOrphanAvifs` тепер пропускає `.avif` у каталогах артефактів збірки (`build`, `android`, `ios`, `.output`, `.nuxt`, `.cache`) — раніше cleanup міг затирати продукт `bun run build` чи Capacitor sync.
30
+
31
+ ### Added
32
+
33
+ - `tests/check-image.test.mjs`: 4 нових кейси — Quasar-style `src="/api-page/1.png"` через `<pkg>/public/`; `<img src="assets/images/x.png">` у `.html` через relative-to-source; `src="start-page-ua/logo.png"` у `.vue` через `<pkg>/public/`; cleanup не чіпає AVIF у `build/`/`android/`/`ios/`/`.output/`/`.nuxt/`/`.cache/`.
34
+
35
+ ## [1.8.193] - 2026-05-07
36
+
37
+ ### Fixed
38
+
39
+ - `check-image.mjs`: cleanup AVIF-сиріт більше не зачіпає `.avif` файли всередині пакетів з опт-аутом (`"@nitra/minify-image": { "disable-avif": true }`). Раніше: пакет з опт-аутом не сканувався на refs → його `.avif` потрапляли у список «сиріт» і видалялись, навіть якщо насправді використовувалися через alias / runtime-обчислений шлях. Тепер `checkVueAvifImports` повертає список абсолютних коренів opt-out пакетів, а `cleanupOrphanAvifs` пропускає `.avif` під ними.
40
+ - `check-image.mjs`: запис у `.vue`/`.html` тепер строго послідовний з cleanup (write-then-cleanup): перший виконує `checkVueAvifImports` (per-file `writeFile` після обробки), і тільки після цього `cleanupOrphanAvifs` читає вже оновлені `usedAvifAbs` і видаляє лише дійсних сиріт.
41
+ - `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.
42
+
43
+ ### Added
44
+
45
+ - `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` на чистому стані.
46
+
7
47
  ## [1.8.192] - 2026-05-07
8
48
 
9
49
  ### Added
package/bin/auto-rules.md CHANGED
@@ -22,7 +22,9 @@ graphql - якщо хоч в одному js або vue файлі присут
22
22
 
23
23
  hasura - якщо в директорії присутній config.yaml, який містить рядок `metadata_directory: metadata`
24
24
 
25
- image - [vue]
25
+ image-compress - [bun]
26
+
27
+ image-avif - [vue, image-compress]
26
28
 
27
29
  js-lint - якщо присутній хоч один js файл
28
30
 
@@ -0,0 +1,55 @@
1
+ ---
2
+ description: AVIF-двійники для raster-зображень з ув'язуванням у .vue/.html
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ AVIF-двійники (`<name>.<ext>.avif`) генерує **виключно** `npx @nitra/cursor check image-avif` — у `lint-image` прапорець `--avif` заборонений (це валідує правило `image-compress`). Перевірка робить три кроки в порядку:
8
+
9
+ 1. Запускає `npx @nitra/minify-image --src=. --write --avif` — генерує `<name>.<ext>.avif` поряд з кожним PNG/JPEG/GIF.
10
+ 2. Сканує `.vue` (а також `.html`) файли в кожному workspace-пакеті (root + workspaces) і автоматично переписує raster-посилання на AVIF-двійник у двох формах:
11
+ - **Імпорт-пов'язані** — `import x from '...png|jpg|jpeg|gif'` (далі `:src="x"` у шаблоні);
12
+ - **Прямі статичні** — `<img src="...png" />` у `<template>` (Vite перетворює такий шлях на asset-імпорт на етапі збірки, тож вимога та сама).
13
+ 3. Видаляє AVIF-сироти: ходить по всіх `<...>.avif` у репозиторії; якщо на двійник не лишилось жодного посилання у `.vue`/`.html` — файл видаляється. **AVIF на диску лишається лише там, де заміна реально відбулась** — тому невикористані оригінали не накопичують `.avif`-«хвости».
14
+
15
+ ```vue title="App.vue (після check image-avif)"
16
+ <script setup>
17
+ import welcomeImage from './assets/welcome.png.avif'
18
+ </script>
19
+
20
+ <template>
21
+ <img :src="welcomeImage" alt="Welcome" />
22
+ </template>
23
+ ```
24
+
25
+ Реактивне `:src="..."` (з JS-виразом — змінною, тернарником, викликом тощо) **не сканується** — значення обчислюється у рантаймі й шлях туди потрапляє через імпорт або інший резолвинг, який ловить імпорт-перевірка вище. SVG не торкаємо (vector → AVIF безглуздо). Атрибути `data-src=`, `obj.src=` у `<script>` тощо також пропускаються.
26
+
27
+ Якщо raster-посилання у `.vue`/`.html` не вдалось переписати (наприклад, оригіналу немає на диску, тож і `.avif` не згенерувався) — `check image-avif` падає з помилкою на конкретний файл.
28
+
29
+ AVIF-двійники **зберігаємо в git** — це готові артефакти для віддачі браузеру (без них ефект від AVIF втрачається на чистому checkout-і).
30
+
31
+ ## Коли НЕ вмикати правило
32
+
33
+ AVIF ще не підтримується **усіма** браузерами: для публічного сайту, де серед користувачів можуть бути старі/нестандартні браузери, конвертація raster → AVIF як основного джерела ризикована. Для адмінок (де користувачі — співробітники з сучасними браузерами) AVIF безпечний.
34
+
35
+ У монорепо з адмінкою + публічним сайтом стандартна стратегія така: правило `image-avif` присутнє у `.n-cursor.json`, але для пакета-сайту вмикається опт-аут (нижче).
36
+
37
+ ## Опт-аут для конкретного пакета
38
+
39
+ У workspace-пакеті, де AVIF-імпорти небажані (наприклад, мобільний бандл або публічний сайт без гарантованої AVIF-підтримки), додай у `package.json` цього пакета:
40
+
41
+ ```json title="apps/site/package.json"
42
+ {
43
+ "@nitra/minify-image": {
44
+ "disable-avif": true
45
+ }
46
+ }
47
+ ```
48
+
49
+ Тоді перевірка пропускає `.vue` файли цього пакета і не видаляє наявні `.avif` всередині як «сироти». У root-`package.json` опт-аут діє лише для файлів кореня (вкладені workspaces перевіряються незалежно — вмикай прапор у кожному пакеті, де треба).
50
+
51
+ `image-compress` (раніший крок: lint-image, кеш, заборонені залежності) при цьому продовжує працювати — стиснення raster-зображень виконується незалежно від AVIF.
52
+
53
+ ## Перевірка
54
+
55
+ `npx @nitra/cursor check image-avif` (запуск AVIF-генерації + авто-заміна raster-посилань на `.avif` у `.vue`/`.html` кожного workspace-пакета + прибирання AVIF-сиріт; пакети з `"@nitra/minify-image": { "disable-avif": true }` пропускаються).
@@ -0,0 +1,56 @@
1
+ ---
2
+ description: Оптимізація raster/SVG через @nitra/minify-image у локальному lint
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ CLI [`@nitra/minify-image`](https://www.npmjs.com/package/@nitra/minify-image) (≥ **3.2.0**) запускається через `npx` (як `markdownlint-cli2` у text.mdc) і **не** додається в `dependencies` / `devDependencies`. Канонічний `lint-image` — авто-оптимізація з прапорцем `--write`: стискає raster/SVG на місці. **AVIF-генерація (`--avif`) у `lint-image` заборонена** — її виконує окреме правило `image-avif` (`npx @nitra/cursor check image-avif`), яке заодно прибирає AVIF-сироти. Split-cache робить повторні прогони дешевими — і локально, і після `git clone`.
8
+
9
+ Перевірка лише локальна — у CI `lint-image` не запускаємо (sharp/svgo тягнуть бінарні залежності, цінність на ubuntu-runner-ах нижча за час прогону). Окремий workflow `lint-image.yml` створювати не треба.
10
+
11
+ ## `package.json`
12
+
13
+ ```json title="package.json"
14
+ {
15
+ "scripts": {
16
+ "lint": "bun run lint-js && bun run lint-text && bun run lint-ga && bun run lint-image && oxfmt .",
17
+ "lint-image": "npx @nitra/minify-image --src=. --write"
18
+ }
19
+ }
20
+ ```
21
+
22
+ Якщо в `package.json` уже є агрегований `lint`, додай у його ланцюжок `bun run lint-image` (як `bun run lint-text`, `bun run lint-js`, `bun run lint-ga`). Так розробник, що локально гонить `bun run lint`, перед фіксацією одразу бачить, чи зросли зображення.
23
+
24
+ ## Split-cache
25
+
26
+ Починаючи з `@nitra/minify-image` **3.2.0** кеш розбитий на два файли з різною семантикою:
27
+
28
+ ### `.n-minify-image.tsv` — source of truth у git
29
+
30
+ У корені сканованого каталогу. Формат: `<rel-path>\t<sha1-hex>\t<originalSize>\t<size>`.
31
+
32
+ Slow-path і джерело даних для `Project lifetime savings`. **Має бути в git** — після `git clone` чи `git checkout` (mtime скидається на час checkout-у) CLI читає файл, рахує SHA-1 і порівнює зі збереженим у TSV хешем; на match локальний mtime-кеш зігрівається без reprocess. Рядки відсортовані алфавітно, hash і size змінюються лише при реальній зміні контенту — diff чистий.
33
+
34
+ ### `node_modules/.cache/@nitra/minify-image/mtime.tsv` — локальний fast-path
35
+
36
+ Формат: `<rel-path>\t<mtime>\t<size>`. При збігу `(size, mtime)` CLI пропускає файл без читання — константа per-file.
37
+
38
+ Лежить під `node_modules/`, тож **авто-gitignored** за конвенцією JS-tooling-у (так кешуються ESLint, Babel, webpack, Turbo). Окремий рядок у `.gitignore` не потрібен. `rm -rf node_modules` зносить — наступний запуск відновлює його через slow-path проти `.n-minify-image.tsv`, без reprocess.
39
+
40
+ ### Міграція з versions < 3.2
41
+
42
+ Старий єдиний `.minify-image-cache.tsv` (4 колонки `path\tmtime\toriginalSize\tsize`, зазвичай у `.gitignore`) автоматично читається при першому запуску для seed-у `originalSize` у `.n-minify-image.tsv` (lifetime savings не скидається). Після цього старий файл видаляють вручну:
43
+
44
+ ```bash
45
+ git rm --cached .minify-image-cache.tsv 2>/dev/null || true
46
+ rm -f .minify-image-cache.tsv
47
+ # прибери відповідний рядок з .gitignore, якщо був
48
+ ```
49
+
50
+ ## Заборонені залежності
51
+
52
+ `@nitra/minify-image` не повинен зʼявлятися ні в `dependencies`, ні в `devDependencies` кореневого `package.json` — CLI запускається лише через `npx` (так само, як `markdownlint-cli2` у text.mdc). Якщо потрібен явний пін — закладай діапазон версій npm у самому виклику (`npx @nitra/minify-image@^3 --src=. --write`).
53
+
54
+ ## Перевірка
55
+
56
+ `npx @nitra/cursor check image-compress` (охоплює `lint-image` з обовʼязковими `--src=.`, `--write` і **забороненим** `--avif`; агрегований `lint`; заборону `@nitra/minify-image` у залежностях; `.n-minify-image.tsv` НЕ в `.gitignore` — має бути в git; відсутність застарілого `.minify-image-cache.tsv` у корені).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.192",
3
+ "version": "1.8.197",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -31,7 +31,8 @@ export const AUTO_RULE_ORDER = Object.freeze([
31
31
  'ga',
32
32
  'graphql',
33
33
  'hasura',
34
- 'image',
34
+ 'image-avif',
35
+ 'image-compress',
35
36
  'js-lint',
36
37
  'js-mssql',
37
38
  'js-bun-db',
@@ -56,7 +57,8 @@ export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint'])
56
57
  export const AUTO_RULE_DEPENDENCIES = Object.freeze(
57
58
  /** @type {Record<string, readonly string[]>} */ ({
58
59
  changelog: Object.freeze(['bun']),
59
- image: Object.freeze(['vue'])
60
+ 'image-avif': Object.freeze(['vue', 'image-compress']),
61
+ 'image-compress': Object.freeze(['bun'])
60
62
  })
61
63
  )
62
64
 
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Перевіряє відповідність репозиторію правилу `image-avif.mdc`: AVIF-генерацію та
3
+ * ув'язування `.avif`-двійників з посиланнями у `.vue`/`.html`.
4
+ *
5
+ * Дії під час `check image-avif`:
6
+ * 1. `npx @nitra/minify-image --src=. --write --avif` — генерує AVIF-двійники.
7
+ * 2. У кожному workspace-пакеті переписує raster-посилання у `.vue`/`.html` на `.avif`
8
+ * (де AVIF-двійник реально існує на диску). Pakety з `"@nitra/minify-image": {
9
+ * "disable-avif": true }` у `package.json` пропускаються.
10
+ * 3. Прибирає AVIF-сироти — `<name>.<ext>.avif`, на які не лишилось жодного посилання
11
+ * у `.vue`/`.html` репозиторію, видаляються (умова правила: «AVIF лишається лише
12
+ * там, де заміна вдалася»).
13
+ *
14
+ * Якщо raster-посилання у `.vue`/`.html` не вдалось переписати (наприклад, оригіналу
15
+ * нема на диску → `.avif` теж не згенерувався) — фейл на конкретний файл.
16
+ *
17
+ * Правило самостійне від `image-compress`: AVIF можна вмикати лише в адмінках (де AVIF
18
+ * підтримується сучасними браузерами) і не вмикати в публічних сайтах. Перевірка скрипта
19
+ * `lint-image` (заборона `--avif` у ньому) залишається у `image-compress` — тут вона не
20
+ * дублюється.
21
+ */
22
+ import { existsSync } from 'node:fs'
23
+ import { readFile, unlink, writeFile } from 'node:fs/promises'
24
+ import { join, relative } from 'node:path'
25
+ import { spawnSync } from 'node:child_process'
26
+ import { env } from 'node:process'
27
+
28
+ import { createCheckReporter } from './utils/check-reporter.mjs'
29
+ import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
30
+ import { walkDir } from './utils/walkDir.mjs'
31
+ import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
32
+
33
+ /** Імʼя CLI-пакета, який генерує AVIF. */
34
+ const MINIFY_PACKAGE_NAME = '@nitra/minify-image'
35
+
36
+ /** Поле в `package.json` для конфігу @nitra/minify-image (наприклад, `disable-avif`). */
37
+ const PKG_CONFIG_FIELD = '@nitra/minify-image'
38
+
39
+ /**
40
+ * Імена каталогів, які `cleanupOrphanAvifs` не зачіпає, бо це артефакти збірки/нативні
41
+ * платформи — `.avif` всередині — це продукт попереднього `bun run build`/Capacitor sync,
42
+ * а не кандидати на видалення. `walkDir` уже скіпає `node_modules`, `.git`, `dist`,
43
+ * `coverage`, `.turbo`, `.next` — додатково для cleanup ігноруємо ще ці.
44
+ */
45
+ const CLEANUP_EXTRA_IGNORE_DIR_NAMES = new Set(['build', 'android', 'ios', '.output', '.nuxt', '.cache'])
46
+
47
+ /**
48
+ * Регексп для імпортів raster-зображень у `.vue` файлах.
49
+ * Захоплює `import name from '...ext'` (як default, так і type-only форми не потрібні —
50
+ * type-imports asset-ів не існує). Захоплюється повний шлях у групі 1.
51
+ */
52
+ const VUE_RASTER_IMPORT_RE = /import\s+\w[\w$]*\s+from\s+['"]([^'"\n]+\.(?:png|jpe?g|gif))['"]/giu
53
+
54
+ /**
55
+ * Регексп для прямих посилань на raster-зображення у HTML-атрибуті `src="..."` шаблона `.vue`
56
+ * (наприклад `<img src="./hero.png" />`). Vite перетворює такі шляхи на asset-імпорти на етапі
57
+ * збірки, тож для них теж діє вимога вживати AVIF-двійник.
58
+ *
59
+ * Лукбехайнд `(?<![:\-_.])` виключає реактивне `:src="..."` (там JS-вираз — змінна або виклик,
60
+ * перевіряється через імпорт), `data-src="..."` і `obj.src=...` у `<script>`.
61
+ */
62
+ const VUE_RASTER_STATIC_SRC_RE = /(?<![:\-_.])\bsrc\s*=\s*['"]([^'"\s]+\.(?:png|jpe?g|gif))['"]/giu
63
+
64
+ /**
65
+ * Регексп для готових AVIF-посилань у `.vue`/`.html` (як `import x from '...png.avif'`,
66
+ * так і `<img src="....png.avif" />`). Потрібен лише для збору множини «живих» AVIF —
67
+ * щоб після авто-заміни знати, які `<...>.avif` файли ще на щось посилаються, а які
68
+ * є сиротами і підлягають видаленню.
69
+ */
70
+ const VUE_AVIF_REF_RE = /['"]([^'"\s]+\.(?:png|jpe?g|gif)\.avif)['"]/giu
71
+
72
+ /**
73
+ * Чи у `package.json` пакета вимкнено avif-перевірку Vue-імпортів.
74
+ * Очікувана форма: `"@nitra/minify-image": { "disable-avif": true }`.
75
+ * @param {Record<string, unknown>} pkg розібраний package.json пакета
76
+ * @returns {boolean} true, якщо опт-аут активовано
77
+ */
78
+ function packageHasAvifDisabled(pkg) {
79
+ const cfg = pkg[PKG_CONFIG_FIELD]
80
+ return Boolean(
81
+ cfg && typeof cfg === 'object' && /** @type {Record<string, unknown>} */ (cfg)['disable-avif'] === true
82
+ )
83
+ }
84
+
85
+ /**
86
+ * Будує впорядкований список кандидатів-абсолютних шляхів, по яких треба перевіряти
87
+ * наявність зображення для даного посилання у `.vue`/`.html`. Caller перевіряє кожен
88
+ * кандидат на існування `<candidate>.avif` (для rewrite) або `<candidate>` (для збору
89
+ * вже-вживаного `.avif`) і обирає перший, що існує.
90
+ *
91
+ * Підтримувані форми:
92
+ * - `./x.png`, `../x.png` — відносно файла-джерела (ES-import / asset-relative).
93
+ * - `/x.png` — у Vite/Quasar-конвенції це `<packageRoot>/public/x.png`. Спочатку пробуємо
94
+ * `public/`, потім сам корінь пакета (на випадок mono-репо без `public/`), нарешті
95
+ * `<cwd>/x.png` як legacy fallback (щоб не зламати проєкти з кореневими ассетами).
96
+ * - голий шлях з принаймні одним `/` (`assets/img.png`, `start-page-ua/logo.png`) — у
97
+ * HTML/Vue браузер резолвить його відносно документа, тому повертаємо relative-to-source
98
+ * та `<packageRoot>/public/<path>` як другий кандидат (Quasar-проєкти кладуть public-assets
99
+ * саме туди).
100
+ * - bare без `/` (`foo`) — ймовірно alias resolver (Vite/Webpack), резолвити не вміємо,
101
+ * повертаємо порожній список → caller просто пропускає посилання, не звітує fail.
102
+ * @param {string} importPath шлях з `import x from '...'` або `src="..."`
103
+ * @param {string} sourceAbsPath абсолютний шлях файла-джерела
104
+ * @param {string|null} packageRootAbs абсолютний корінь workspace-пакета, у якому лежить
105
+ * `sourceAbsPath` (для резолвера `/path` як `<root>/public<path>`); `null`, якщо невідомо
106
+ * @returns {string[]} впорядкований список абсолютних шляхів-кандидатів
107
+ */
108
+ function resolveImageCandidates(importPath, sourceAbsPath, packageRootAbs) {
109
+ if (importPath.startsWith('.')) {
110
+ return [join(sourceAbsPath, '..', importPath)]
111
+ }
112
+ if (importPath.startsWith('/')) {
113
+ /** @type {string[]} */
114
+ const candidates = []
115
+ if (packageRootAbs) {
116
+ candidates.push(join(packageRootAbs, 'public', importPath))
117
+ candidates.push(join(packageRootAbs, importPath))
118
+ }
119
+ candidates.push(join(process.cwd(), importPath))
120
+ return candidates
121
+ }
122
+ if (importPath.includes('/')) {
123
+ /** @type {string[]} */
124
+ const candidates = [join(sourceAbsPath, '..', importPath)]
125
+ if (packageRootAbs) {
126
+ candidates.push(join(packageRootAbs, 'public', importPath))
127
+ }
128
+ return candidates
129
+ }
130
+ return []
131
+ }
132
+
133
+ /**
134
+ * Аґреговані лічильники по проходу `check image-avif`:
135
+ * - `rewrittenRefs` — скільки конкретних посилань (по одному на match) переписано на `.avif`;
136
+ * - `rewrittenFiles` — у скількох `.vue`/`.html` файлах хоч одне посилання змінилося;
137
+ * - `failedRefs` — скільки конкретних посилань не вдалося переписати (`.avif` не існував).
138
+ * @typedef {object} RewriteStats
139
+ * @property {number} rewrittenRefs скільки конкретних посилань переписано на `.avif`
140
+ * @property {number} rewrittenFiles у скількох `.vue`/`.html` файлах хоч одне посилання змінилося
141
+ * @property {number} failedRefs скільки конкретних посилань не вдалося переписати (`.avif` не існував)
142
+ */
143
+
144
+ /**
145
+ * Сканує `.vue` і `.html` файли одного workspace-пакета: де можемо, переписує raster-посилання
146
+ * на `<path>.avif`, де не можемо — фейлимо. Доповнює `usedAvifAbs` шляхами AVIF-двійників, на
147
+ * які лишилось живе посилання, і `stats` лічильниками rewrite/fail для глобального підсумку.
148
+ *
149
+ * Заміна виконується ТІЛЬКИ якщо AVIF-двійник реально існує на диску. Якщо AVIF немає
150
+ * (наприклад, оригіналу теж немає, тож `--avif` його не згенерував) — фейл, як раніше.
151
+ * Запис файла відбувається ОДРАЗУ після обробки одного файла (write-then-fail): провал на
152
+ * наступному файлі НЕ відкочує вже записані зміни попередніх.
153
+ *
154
+ * Файли, що належать іншим workspace-пакетам, ігноруються — кожен пакет перевіряється рівно
155
+ * один раз (інакше при обході кореня `.` ми б повторно зайшли в `demo/` і подвоїли звіти).
156
+ * @param {string} packageRoot відносний шлях до кореня пакета (наприклад `'.'` або `'demo'`)
157
+ * @param {string[]} otherRootsAbs абсолютні шляхи інших workspace-коренів — їх піддерева пропускаємо
158
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
159
+ * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`, що мають
160
+ * хоч одне посилання у `.vue`/`.html` (доповнюється у цій функції)
161
+ * @param {RewriteStats} stats глобальні лічильники, що мутуються тут
162
+ * @param {(msg: string) => void} fail callback при помилці
163
+ * @returns {Promise<void>} резолвиться по завершенню перевірки одного пакета
164
+ */
165
+ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail) {
166
+ const absRoot = join(process.cwd(), packageRoot)
167
+ const label = packageRoot === '.' ? 'корінь' : packageRoot
168
+ /** @type {string[]} */
169
+ const targetFiles = []
170
+ await walkDir(
171
+ absRoot,
172
+ absPath => {
173
+ if (!absPath.endsWith('.vue') && !absPath.endsWith('.html')) return
174
+ if (otherRootsAbs.some(other => absPath.startsWith(`${other}/`))) return
175
+ targetFiles.push(absPath)
176
+ },
177
+ ignorePaths
178
+ )
179
+ if (targetFiles.length === 0) return
180
+
181
+ for (const absPath of targetFiles) {
182
+ const rel = relative(process.cwd(), absPath).split('\\').join('/')
183
+ const original = await readFile(absPath, 'utf8')
184
+ let updated = original
185
+
186
+ /**
187
+ * @param {RegExp} regex з групою 1 = шлях до зображення
188
+ * @param {(srcPath: string) => string} renderFailure повідомлення помилки
189
+ */
190
+ const processMatches = (regex, renderFailure) => {
191
+ updated = updated.replaceAll(regex, (full, importPath) => {
192
+ const candidates = resolveImageCandidates(importPath, absPath, absRoot)
193
+ if (candidates.length === 0) {
194
+ // Bare alias (наприклад, '@/assets/x.png' без `/` — впізнаваний alias у Vite/WP);
195
+ // резолвера тут нема, тому посилання не чіпаємо і не звітуємо як fail.
196
+ return full
197
+ }
198
+ const newImportPath = `${importPath}.avif`
199
+ const replaced = full.replace(importPath, newImportPath)
200
+ const found = candidates.find(c => existsSync(`${c}.avif`))
201
+ if (found) {
202
+ stats.rewrittenRefs++
203
+ usedAvifAbs.add(`${found}.avif`)
204
+ return replaced
205
+ }
206
+ stats.failedRefs++
207
+ fail(renderFailure(importPath))
208
+ return full
209
+ })
210
+ }
211
+
212
+ processMatches(
213
+ VUE_RASTER_IMPORT_RE,
214
+ importPath =>
215
+ `[${label}] ${rel}: import з '${importPath}' має посилатись на AVIF-двійник '${importPath}.avif' ` +
216
+ `(\`npx @nitra/cursor check image-avif\` створює його поряд, якщо оригінал є на диску). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
217
+ )
218
+ processMatches(
219
+ VUE_RASTER_STATIC_SRC_RE,
220
+ srcPath =>
221
+ `[${label}] ${rel}: пряме \`src="${srcPath}"\` у шаблоні має використовувати AVIF-двійник \`src="${srcPath}.avif"\` ` +
222
+ `(або винеси у import + \`:src="..."\`). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
223
+ )
224
+
225
+ for (const match of updated.matchAll(VUE_AVIF_REF_RE)) {
226
+ const avifPath = match[1]
227
+ const candidates = resolveImageCandidates(avifPath, absPath, absRoot)
228
+ for (const cand of candidates) {
229
+ if (existsSync(cand)) usedAvifAbs.add(cand)
230
+ }
231
+ }
232
+
233
+ if (updated !== original) {
234
+ await writeFile(absPath, updated, 'utf8')
235
+ stats.rewrittenFiles++
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Сканує всі workspace-пакети: для кожного перевіряє opt-out і за потреби викликає
242
+ * перевірку Vue-imports. Перевірка пропускається, якщо в репозиторії немає workspaces
243
+ * або немає `.vue`-файлів — тоді `image-avif` правило не для цього проєкту.
244
+ *
245
+ * Повертає список абсолютних коренів пакетів, у яких ввімкнено opt-out (`disable-avif: true`).
246
+ * Це окремий результат, бо AVIF всередині такого пакета НЕ можна вважати «сиротою» лише
247
+ * на підставі відсутності посилань у його `.vue`/`.html` (ми взагалі не сканували його
248
+ * шаблони) — інакше cleanup помилково затирав би AVIF, що використовуються через alias /
249
+ * runtime-обчислений шлях / зовнішні посилання, які тут не видно.
250
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
251
+ * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`-двійників,
252
+ * на які лишилось хоча б одне посилання у `.vue`/`.html` (заповнюється у викликаних функціях)
253
+ * @param {RewriteStats} stats глобальні лічильники rewrite/fail (мутуються нижче)
254
+ * @param {(msg: string) => void} pass callback при успішній перевірці
255
+ * @param {(msg: string) => void} fail callback при помилці
256
+ * @returns {Promise<string[]>} абсолютні шляхи коренів пакетів з активним opt-out
257
+ */
258
+ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail) {
259
+ const roots = await getMonorepoPackageRootDirs()
260
+ const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
261
+ /** @type {string[]} */
262
+ const optedOutAbs = []
263
+ for (const root of roots) {
264
+ const pkgPath = join(root, 'package.json')
265
+ if (!existsSync(pkgPath)) continue
266
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
267
+ if (packageHasAvifDisabled(pkg)) {
268
+ pass(
269
+ `[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`
270
+ )
271
+ optedOutAbs.push(absRootsByRel.get(root) ?? join(process.cwd(), root))
272
+ continue
273
+ }
274
+ const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
275
+ await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail)
276
+ }
277
+ return optedOutAbs
278
+ }
279
+
280
+ /**
281
+ * Чи є в репозиторії хоч один raster-файл, який мав би сенс конвертувати у AVIF.
282
+ * Якщо немає — `npx @nitra/minify-image` нема що робити, тож зайвий запуск пропускаємо
283
+ * (важливо у тестах: фікстурні `.png`-імпорти посилаються на неіснуючі файли, тож
284
+ * minify-image все одно нічого не згенерує — а зайвий npx-спавн повільний і робить шум).
285
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
286
+ * @returns {Promise<boolean>} `true`, якщо знайдено принаймні один `.png/.jpe?g/.gif`
287
+ */
288
+ async function hasAnyRasterImage(ignorePaths) {
289
+ let found = false
290
+ await walkDir(
291
+ process.cwd(),
292
+ absPath => {
293
+ if (found) return
294
+ if (/\.(?:png|jpe?g|gif)$/iu.test(absPath)) found = true
295
+ },
296
+ ignorePaths
297
+ )
298
+ return found
299
+ }
300
+
301
+ /**
302
+ * Запускає `npx @nitra/minify-image --src=. --write --avif` для генерації AVIF-двійників.
303
+ *
304
+ * Виклик best-effort: якщо мережа/кеш недоступні чи бінарника нема — лог-варн без падіння
305
+ * перевірки (валідації package.json і vue-refs все одно прогоняться, vue-refs на
306
+ * відсутні `.avif` фейлять окремо). У тестах та інших ізольованих середовищах npx
307
+ * можна вимкнути через `NITRA_CURSOR_NO_AVIF_RUN=1` — тоді ця функція no-op.
308
+ * @returns {void}
309
+ */
310
+ function runAvifGeneration() {
311
+ if (env.NITRA_CURSOR_NO_AVIF_RUN === '1') return
312
+ const result = spawnSync('npx', [MINIFY_PACKAGE_NAME, '--src=.', '--write', '--avif'], {
313
+ stdio: 'inherit',
314
+ env
315
+ })
316
+ if (result.error) {
317
+ console.log(
318
+ ` ⚠️ не вдалося запустити \`npx ${MINIFY_PACKAGE_NAME} --avif\`: ${result.error.message} — vue/html-перевірка покаже файли, для яких не вистачає .avif`
319
+ )
320
+ return
321
+ }
322
+ if (typeof result.status === 'number' && result.status !== 0) {
323
+ console.log(
324
+ ` ⚠️ \`npx ${MINIFY_PACKAGE_NAME} --avif\` завершився з кодом ${result.status} — vue/html-перевірка покаже файли, для яких не вистачає .avif`
325
+ )
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Видаляє AVIF-сироти — `<...>.avif` файли, на які не лишилось жодного посилання
331
+ * у `.vue`/`.html` репозиторію. Реалізує умову правила: «AVIF лишається лише там,
332
+ * де заміна реально вдалася».
333
+ *
334
+ * AVIF файли всередині opt-out пакетів (`disable-avif: true`) пропускаються — ми не
335
+ * сканували їх шаблони, тож не маємо права вважати їх AVIF сиротами. Це гарантує
336
+ * ідемпотентність повторного `check image-avif` для пакетів, що навмисно вимкнули правило
337
+ * (наприклад, мобільний бандл, де AVIF підтримка не гарантована).
338
+ * @param {Set<string>} usedAvifAbs абсолютні шляхи `.avif`, що мають живі посилання
339
+ * @param {string[]} optedOutAbs абсолютні шляхи коренів пакетів з опт-аутом —
340
+ * `.avif` під ними не вважаємо сиротами і не видаляємо
341
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
342
+ * @returns {Promise<number>} кількість видалених сиріт
343
+ */
344
+ async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths) {
345
+ /** @type {string[]} */
346
+ const orphans = []
347
+ await walkDir(
348
+ process.cwd(),
349
+ absPath => {
350
+ if (!absPath.endsWith('.avif')) return
351
+ if (usedAvifAbs.has(absPath)) return
352
+ if (optedOutAbs.some(root => absPath === root || absPath.startsWith(`${root}/`))) return
353
+ const segments = absPath.split('/')
354
+ if (segments.some(seg => CLEANUP_EXTRA_IGNORE_DIR_NAMES.has(seg))) return
355
+ orphans.push(absPath)
356
+ },
357
+ ignorePaths
358
+ )
359
+ for (const absPath of orphans) {
360
+ await unlink(absPath)
361
+ }
362
+ return orphans.length
363
+ }
364
+
365
+ /**
366
+ * Виконує AVIF-етап: запуск AVIF-генерації, авто-заміна raster-посилань у `.vue`/`.html`,
367
+ * видалення AVIF-сиріт. Не валідує `package.json`/`lint-image` — це вже у `image-compress`.
368
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
369
+ */
370
+ export async function check() {
371
+ const reporter = createCheckReporter()
372
+ const { pass, fail } = reporter
373
+
374
+ const ignorePaths = await loadCursorIgnorePaths(process.cwd())
375
+
376
+ if (await hasAnyRasterImage(ignorePaths)) {
377
+ runAvifGeneration()
378
+ }
379
+
380
+ /** @type {Set<string>} */
381
+ const usedAvifAbs = new Set()
382
+ /** @type {RewriteStats} */
383
+ const stats = { rewrittenRefs: 0, rewrittenFiles: 0, failedRefs: 0 }
384
+ const optedOutAbs = await checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
385
+ const orphansDeleted = await cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths)
386
+
387
+ pass(
388
+ `image-avif: rewrote ${stats.rewrittenRefs} reference${stats.rewrittenRefs === 1 ? '' : 's'} in ${stats.rewrittenFiles} file${stats.rewrittenFiles === 1 ? '' : 's'}; ` +
389
+ `deleted ${orphansDeleted} orphan AVIF${orphansDeleted === 1 ? '' : 's'}; ` +
390
+ `failed to rewrite ${stats.failedRefs} reference${stats.failedRefs === 1 ? '' : 's'}`
391
+ )
392
+
393
+ return reporter.getExitCode()
394
+ }