@nitra/cursor 1.8.193 → 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,34 @@
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
+
7
35
  ## [1.8.193] - 2026-05-07
8
36
 
9
37
  ### Fixed
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.193",
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
 
@@ -1,24 +1,8 @@
1
1
  /**
2
- * Перевіряє відповідність репозиторію правилу image.mdc для оптимізації зображень
3
- * через `@nitra/minify-image` 3.2.0 (локально — у CI лінт зображень не запускається).
2
+ * Перевіряє відповідність репозиторію правилу `image-avif.mdc`: AVIF-генерацію та
3
+ * ув'язування `.avif`-двійників з посиланнями у `.vue`/`.html`.
4
4
  *
5
- * Очікування:
6
- * - у кореневому `package.json` є скрипт `lint-image`, який викликає `npx @nitra/minify-image`
7
- * з обовʼязковими `--src=.` і `--write`. Прапорець `--avif` у `lint-image` заборонений —
8
- * AVIF-генерацію виконує `check image` (інакше `bun run lint` плодив би `.avif` для
9
- * зображень, що ніде не вживаються);
10
- * - якщо в `package.json` є агрегований скрипт `lint`, він викликає `bun run lint-image`
11
- * (симетрично до `lint-text`, `lint-js`, `lint-ga`);
12
- * - `@nitra/minify-image` не оголошений у `dependencies`/`devDependencies` —
13
- * CLI запускається лише через `npx` (як `markdownlint-cli2` у `text.mdc`);
14
- * - `.n-minify-image.tsv` (committed source of truth з sha1/originalSize/size) НЕ
15
- * в `.gitignore` — він має бути в git. Локальний mtime-кеш у
16
- * `node_modules/.cache/@nitra/minify-image/mtime.tsv` авто-gitignored через `node_modules/`,
17
- * окремої перевірки не вимагає;
18
- * - застарілий `.minify-image-cache.tsv` (з версій < 3.2) видалений з кореня — інакше
19
- * проєкт лишається у напівпереміщеному стані.
20
- *
21
- * Дії під час `check image` (на додачу до валідацій):
5
+ * Дії під час `check image-avif`:
22
6
  * 1. `npx @nitra/minify-image --src=. --write --avif` — генерує AVIF-двійники.
23
7
  * 2. У кожному workspace-пакеті переписує raster-посилання у `.vue`/`.html` на `.avif`
24
8
  * (де AVIF-двійник реально існує на диску). Pakety з `"@nitra/minify-image": {
@@ -28,7 +12,12 @@
28
12
  * там, де заміна вдалася»).
29
13
  *
30
14
  * Якщо raster-посилання у `.vue`/`.html` не вдалось переписати (наприклад, оригіналу
31
- * нема на диску → `.avif` теж не згенерувався) — фейл на конкретний файл, як раніше.
15
+ * нема на диску → `.avif` теж не згенерувався) — фейл на конкретний файл.
16
+ *
17
+ * Правило самостійне від `image-compress`: AVIF можна вмикати лише в адмінках (де AVIF
18
+ * підтримується сучасними браузерами) і не вмикати в публічних сайтах. Перевірка скрипта
19
+ * `lint-image` (заборона `--avif` у ньому) залишається у `image-compress` — тут вона не
20
+ * дублюється.
32
21
  */
33
22
  import { existsSync } from 'node:fs'
34
23
  import { readFile, unlink, writeFile } from 'node:fs/promises'
@@ -41,18 +30,20 @@ import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
41
30
  import { walkDir } from './utils/walkDir.mjs'
42
31
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
43
32
 
44
- /** Імʼя CLI-пакета: рядок у `lint-image` і заборонений у залежностях. */
33
+ /** Імʼя CLI-пакета, який генерує AVIF. */
45
34
  const MINIFY_PACKAGE_NAME = '@nitra/minify-image'
46
35
 
47
- /** Імʼя committed-кешу (sha1 + originalSize + size) у `@nitra/minify-image` ≥ 3.2.0. */
48
- const HASH_CACHE_FILENAME = '.n-minify-image.tsv'
49
-
50
- /** Імʼя застарілого 4-колонкового кешу (`@nitra/minify-image` < 3.2). Має бути видалений після міграції. */
51
- const LEGACY_CACHE_FILENAME = '.minify-image-cache.tsv'
52
-
53
36
  /** Поле в `package.json` для конфігу @nitra/minify-image (наприклад, `disable-avif`). */
54
37
  const PKG_CONFIG_FIELD = '@nitra/minify-image'
55
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
+
56
47
  /**
57
48
  * Регексп для імпортів raster-зображень у `.vue` файлах.
58
49
  * Захоплює `import name from '...ext'` (як default, так і type-only форми не потрібні —
@@ -78,147 +69,6 @@ const VUE_RASTER_STATIC_SRC_RE = /(?<![:\-_.])\bsrc\s*=\s*['"]([^'"\s]+\.(?:png|
78
69
  */
79
70
  const VUE_AVIF_REF_RE = /['"]([^'"\s]+\.(?:png|jpe?g|gif)\.avif)['"]/giu
80
71
 
81
- /**
82
- * Перевіряє скрипт `lint-image` у `package.json`.
83
- *
84
- * Має містити виклик `npx @nitra/minify-image` з обовʼязковими прапорцями `--src=.`
85
- * і `--write` (авто-оптимізація на місці). Прапорець `--avif` у `lint-image`
86
- * заборонений — AVIF-генерацію виконує `check image`, інакше `bun run lint` плодить
87
- * `.avif` для зображень, що ніде не вживаються.
88
- * @param {string|undefined} lintImage значення `scripts['lint-image']`
89
- * @param {(msg: string) => void} pass callback при успішній перевірці
90
- * @param {(msg: string) => void} fail callback при помилці
91
- * @returns {void}
92
- */
93
- function checkLintImageScript(lintImage, pass, fail) {
94
- const canonical = `npx ${MINIFY_PACKAGE_NAME} --src=. --write`
95
- if (typeof lintImage !== 'string' || !lintImage.trim()) {
96
- fail(`package.json: додай скрипт "lint-image" з \`${canonical}\` (image.mdc)`)
97
- return
98
- }
99
- if (!lintImage.includes(`npx ${MINIFY_PACKAGE_NAME}`)) {
100
- fail(`package.json: lint-image має викликати \`npx ${MINIFY_PACKAGE_NAME}\` (image.mdc)`)
101
- return
102
- }
103
- /** @type {{ flag: string, variants: string[], hint: string }[]} */
104
- const requiredFlags = [
105
- { flag: '--src=.', variants: ['--src=.', '--src .'], hint: '`--src=.`' },
106
- { flag: '--write', variants: ['--write'], hint: '`--write` (авто-оптимізація на місці)' }
107
- ]
108
- const missing = requiredFlags.filter(f => !f.variants.some(v => lintImage.includes(v)))
109
- if (missing.length > 0) {
110
- fail(
111
- `package.json: lint-image має містити ${missing.map(f => f.hint).join(', ')} — канонічний виклик: \`${canonical}\` (image.mdc)`
112
- )
113
- return
114
- }
115
- if (lintImage.includes('--avif')) {
116
- fail(
117
- `package.json: прибери \`--avif\` з lint-image — AVIF-генерацію виконує \`npx @nitra/cursor check image\` (image.mdc). Канонічний виклик: \`${canonical}\``
118
- )
119
- return
120
- }
121
- pass(`package.json: lint-image викликає \`${canonical}\``)
122
- }
123
-
124
- /**
125
- * Перевіряє, що агрегований `lint` (якщо є) кличе `bun run lint-image` —
126
- * симетрично до `lint-text`, `lint-js`, `lint-ga`.
127
- * @param {string|undefined} lintAggregate значення `scripts.lint`
128
- * @param {(msg: string) => void} pass callback при успішній перевірці
129
- * @param {(msg: string) => void} fail callback при помилці
130
- * @returns {void}
131
- */
132
- function checkLintAggregateIncludesImage(lintAggregate, pass, fail) {
133
- if (typeof lintAggregate !== 'string' || !lintAggregate.trim()) {
134
- return
135
- }
136
- if (lintAggregate.includes('bun run lint-image')) {
137
- pass('package.json: агрегований `lint` викликає `bun run lint-image`')
138
- } else {
139
- fail('package.json: у `lint` додай `bun run lint-image` (image.mdc, симетрично до lint-text / lint-js / lint-ga)')
140
- }
141
- }
142
-
143
- /**
144
- * Забороняє `@nitra/minify-image` у `dependencies` чи `devDependencies` —
145
- * CLI завжди запускається через `npx` (як `markdownlint-cli2` у `text.mdc`).
146
- * @param {{ dependencies?: Record<string, unknown>, devDependencies?: Record<string, unknown> }} pkg розібраний package.json
147
- * @param {(msg: string) => void} pass callback при успішній перевірці
148
- * @param {(msg: string) => void} fail callback при помилці
149
- * @returns {void}
150
- */
151
- function checkMinifyImageNotInDeps(pkg, pass, fail) {
152
- const inDeps = Boolean(pkg.dependencies && MINIFY_PACKAGE_NAME in pkg.dependencies)
153
- const inDevDeps = Boolean(pkg.devDependencies && MINIFY_PACKAGE_NAME in pkg.devDependencies)
154
- if (inDeps || inDevDeps) {
155
- fail(
156
- `package.json: ${MINIFY_PACKAGE_NAME} не додавай у dependencies/devDependencies — лише через \`npx\` (image.mdc)`
157
- )
158
- } else {
159
- pass(`package.json: ${MINIFY_PACKAGE_NAME} не оголошено в dependencies/devDependencies`)
160
- }
161
- }
162
-
163
- /**
164
- * Зчитує всі змістовні рядки `.gitignore` (без коментарів і порожніх). Якщо файла нема — `null`.
165
- * @returns {Promise<string[] | null>} список trim-нутих рядків або `null`
166
- */
167
- async function readGitignoreLines() {
168
- if (!existsSync('.gitignore')) return null
169
- const raw = await readFile('.gitignore', 'utf8')
170
- return raw
171
- .split('\n')
172
- .map(l => l.trim())
173
- .filter(l => l.length > 0 && !l.startsWith('#'))
174
- }
175
-
176
- /**
177
- * Перевіряє, що `.n-minify-image.tsv` НЕ в `.gitignore` — він має бути в git
178
- * (split-cache 3.2.0: source of truth для slow-path і lifetime savings).
179
- *
180
- * Сам факт існування файла НЕ вимагається — на свіжому проєкті без обробки
181
- * зображень його ще нема, це нормально.
182
- * @param {(msg: string) => void} pass callback при успішній перевірці
183
- * @param {(msg: string) => void} fail callback при помилці
184
- * @returns {Promise<void>}
185
- */
186
- async function checkHashCacheNotIgnored(pass, fail) {
187
- const lines = await readGitignoreLines()
188
- if (lines && lines.includes(HASH_CACHE_FILENAME)) {
189
- fail(
190
- `.gitignore: прибери рядок \`${HASH_CACHE_FILENAME}\` — це закомічений source of truth split-cache 3.2.0 (image.mdc)`
191
- )
192
- } else {
193
- pass(`${HASH_CACHE_FILENAME} не в .gitignore (має бути в git)`)
194
- }
195
- }
196
-
197
- /**
198
- * Перевіряє, що застарілий `.minify-image-cache.tsv` (з версій < 3.2) видалений
199
- * з кореня. Якщо лежить — користувач не завершив міграцію на split-cache, що
200
- * залишає файл як орфана у git-історії.
201
- * @param {(msg: string) => void} pass callback при успішній перевірці
202
- * @param {(msg: string) => void} fail callback при помилці
203
- * @returns {Promise<void>}
204
- */
205
- async function checkLegacyCacheRemoved(pass, fail) {
206
- if (existsSync(LEGACY_CACHE_FILENAME)) {
207
- fail(
208
- `${LEGACY_CACHE_FILENAME} застарілий (split-cache 3.2.0) — видали: ` +
209
- `\`git rm --cached ${LEGACY_CACHE_FILENAME} 2>/dev/null || true && rm -f ${LEGACY_CACHE_FILENAME}\` ` +
210
- '(також прибери відповідний рядок з .gitignore, якщо є)'
211
- )
212
- return
213
- }
214
- const lines = await readGitignoreLines()
215
- if (lines && lines.includes(LEGACY_CACHE_FILENAME)) {
216
- fail(`.gitignore: прибери застарілий рядок \`${LEGACY_CACHE_FILENAME}\` — split-cache 3.2.0 його не використовує`)
217
- return
218
- }
219
- pass(`${LEGACY_CACHE_FILENAME} відсутній (міграція на split-cache завершена)`)
220
- }
221
-
222
72
  /**
223
73
  * Чи у `package.json` пакета вимкнено avif-перевірку Vue-імпортів.
224
74
  * Очікувана форма: `"@nitra/minify-image": { "disable-avif": true }`.
@@ -233,32 +83,62 @@ function packageHasAvifDisabled(pkg) {
233
83
  }
234
84
 
235
85
  /**
236
- * Резолвить шлях зображення з імпорта/атрибуту відносно файла, що його містить, до абсолютного
237
- * шляху файла на диску. Шляхи, що не починаються з `.` чи `/`, не резолвимо (alias-resolver
238
- * Vite/тощо невідомий тут залишаємо такі посилання як є).
239
- * @param {string} importPath шлях у `import x from '...'` або `src="..."`
240
- * @param {string} sourceAbsPath абсолютний шлях файла з посиланням
241
- * @returns {string|null} абсолютний шлях зображення або `null`, якщо резолвити не можемо
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[]} впорядкований список абсолютних шляхів-кандидатів
242
107
  */
243
- function resolveImagePath(importPath, sourceAbsPath) {
108
+ function resolveImageCandidates(importPath, sourceAbsPath, packageRootAbs) {
244
109
  if (importPath.startsWith('.')) {
245
- return join(sourceAbsPath, '..', importPath)
110
+ return [join(sourceAbsPath, '..', importPath)]
246
111
  }
247
112
  if (importPath.startsWith('/')) {
248
- return join(process.cwd(), importPath)
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
249
129
  }
250
- return null
130
+ return []
251
131
  }
252
132
 
253
133
  /**
254
- * Аґреговані лічильники по проходу `check image`:
134
+ * Аґреговані лічильники по проходу `check image-avif`:
255
135
  * - `rewrittenRefs` — скільки конкретних посилань (по одному на match) переписано на `.avif`;
256
136
  * - `rewrittenFiles` — у скількох `.vue`/`.html` файлах хоч одне посилання змінилося;
257
137
  * - `failedRefs` — скільки конкретних посилань не вдалося переписати (`.avif` не існував).
258
138
  * @typedef {object} RewriteStats
259
- * @property {number} rewrittenRefs
260
- * @property {number} rewrittenFiles
261
- * @property {number} failedRefs
139
+ * @property {number} rewrittenRefs скільки конкретних посилань переписано на `.avif`
140
+ * @property {number} rewrittenFiles у скількох `.vue`/`.html` файлах хоч одне посилання змінилося
141
+ * @property {number} failedRefs скільки конкретних посилань не вдалося переписати (`.avif` не існував)
262
142
  */
263
143
 
264
144
  /**
@@ -309,12 +189,18 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
309
189
  */
310
190
  const processMatches = (regex, renderFailure) => {
311
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
+ }
312
198
  const newImportPath = `${importPath}.avif`
313
199
  const replaced = full.replace(importPath, newImportPath)
314
- const imageAbs = resolveImagePath(importPath, absPath)
315
- if (imageAbs && existsSync(`${imageAbs}.avif`)) {
200
+ const found = candidates.find(c => existsSync(`${c}.avif`))
201
+ if (found) {
316
202
  stats.rewrittenRefs++
317
- usedAvifAbs.add(`${imageAbs}.avif`)
203
+ usedAvifAbs.add(`${found}.avif`)
318
204
  return replaced
319
205
  }
320
206
  stats.failedRefs++
@@ -327,7 +213,7 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
327
213
  VUE_RASTER_IMPORT_RE,
328
214
  importPath =>
329
215
  `[${label}] ${rel}: import з '${importPath}' має посилатись на AVIF-двійник '${importPath}.avif' ` +
330
- `(\`npx @nitra/cursor check image\` створює його поряд, якщо оригінал є на диску). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
216
+ `(\`npx @nitra/cursor check image-avif\` створює його поряд, якщо оригінал є на диску). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
331
217
  )
332
218
  processMatches(
333
219
  VUE_RASTER_STATIC_SRC_RE,
@@ -338,8 +224,10 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
338
224
 
339
225
  for (const match of updated.matchAll(VUE_AVIF_REF_RE)) {
340
226
  const avifPath = match[1]
341
- const avifAbs = resolveImagePath(avifPath, absPath)
342
- if (avifAbs) usedAvifAbs.add(avifAbs)
227
+ const candidates = resolveImageCandidates(avifPath, absPath, absRoot)
228
+ for (const cand of candidates) {
229
+ if (existsSync(cand)) usedAvifAbs.add(cand)
230
+ }
343
231
  }
344
232
 
345
233
  if (updated !== original) {
@@ -352,7 +240,7 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
352
240
  /**
353
241
  * Сканує всі workspace-пакети: для кожного перевіряє opt-out і за потреби викликає
354
242
  * перевірку Vue-imports. Перевірка пропускається, якщо в репозиторії немає workspaces
355
- * або немає `.vue`-файлів — тоді `image` правило не для цього проєкту.
243
+ * або немає `.vue`-файлів — тоді `image-avif` правило не для цього проєкту.
356
244
  *
357
245
  * Повертає список абсолютних коренів пакетів, у яких ввімкнено opt-out (`disable-avif: true`).
358
246
  * Це окремий результат, бо AVIF всередині такого пакета НЕ можна вважати «сиротою» лише
@@ -445,7 +333,7 @@ function runAvifGeneration() {
445
333
  *
446
334
  * AVIF файли всередині opt-out пакетів (`disable-avif: true`) пропускаються — ми не
447
335
  * сканували їх шаблони, тож не маємо права вважати їх AVIF сиротами. Це гарантує
448
- * ідемпотентність повторного `check image` для пакетів, що навмисно вимкнули правило
336
+ * ідемпотентність повторного `check image-avif` для пакетів, що навмисно вимкнули правило
449
337
  * (наприклад, мобільний бандл, де AVIF підтримка не гарантована).
450
338
  * @param {Set<string>} usedAvifAbs абсолютні шляхи `.avif`, що мають живі посилання
451
339
  * @param {string[]} optedOutAbs абсолютні шляхи коренів пакетів з опт-аутом —
@@ -462,6 +350,8 @@ async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths) {
462
350
  if (!absPath.endsWith('.avif')) return
463
351
  if (usedAvifAbs.has(absPath)) return
464
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
465
355
  orphans.push(absPath)
466
356
  },
467
357
  ignorePaths
@@ -473,42 +363,14 @@ async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths) {
473
363
  }
474
364
 
475
365
  /**
476
- * Перевіряє кореневий `package.json`: скрипти, заборонені залежності, агрегований `lint`.
477
- * @param {(msg: string) => void} pass callback при успішній перевірці
478
- * @param {(msg: string) => void} fail callback при помилці
479
- * @returns {Promise<boolean>} `true`, якщо `package.json` знайдено й оброблено; `false` — нема
480
- */
481
- async function checkPackageJsonImage(pass, fail) {
482
- if (!existsSync('package.json')) {
483
- fail('package.json не знайдено в корені — додай (image.mdc)')
484
- return false
485
- }
486
- const pkg = JSON.parse(await readFile('package.json', 'utf8'))
487
- const scripts = /** @type {Record<string, unknown>} */ (pkg.scripts || {})
488
- checkLintImageScript(typeof scripts['lint-image'] === 'string' ? scripts['lint-image'] : undefined, pass, fail)
489
- checkLintAggregateIncludesImage(typeof scripts.lint === 'string' ? scripts.lint : undefined, pass, fail)
490
- checkMinifyImageNotInDeps(pkg, pass, fail)
491
- return true
492
- }
493
-
494
- /**
495
- * Перевіряє відповідність проєкту правилам `image.mdc` (split-cache 3.2.0):
496
- * `lint-image` через `npx @nitra/minify-image --src=. --write` (без `--avif`!), агрегований `lint`,
497
- * `.n-minify-image.tsv` НЕ в `.gitignore` (committed source of truth), застарілий
498
- * `.minify-image-cache.tsv` видалений. Окремо виконуються дії: запуск AVIF-генерації,
499
- * авто-заміна raster-посилань у `.vue`/`.html`, видалення AVIF-сиріт. CI-workflow
500
- * для image не вимагається — лінт зображень виконується лише локально.
366
+ * Виконує AVIF-етап: запуск AVIF-генерації, авто-заміна raster-посилань у `.vue`/`.html`,
367
+ * видалення AVIF-сиріт. Не валідує `package.json`/`lint-image` це вже у `image-compress`.
501
368
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
502
369
  */
503
370
  export async function check() {
504
371
  const reporter = createCheckReporter()
505
372
  const { pass, fail } = reporter
506
373
 
507
- const pkgFound = await checkPackageJsonImage(pass, fail)
508
- if (pkgFound) {
509
- await checkHashCacheNotIgnored(pass, fail)
510
- await checkLegacyCacheRemoved(pass, fail)
511
- }
512
374
  const ignorePaths = await loadCursorIgnorePaths(process.cwd())
513
375
 
514
376
  if (await hasAnyRasterImage(ignorePaths)) {
@@ -523,7 +385,7 @@ export async function check() {
523
385
  const orphansDeleted = await cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths)
524
386
 
525
387
  pass(
526
- `image: rewrote ${stats.rewrittenRefs} reference${stats.rewrittenRefs === 1 ? '' : 's'} in ${stats.rewrittenFiles} file${stats.rewrittenFiles === 1 ? '' : 's'}; ` +
388
+ `image-avif: rewrote ${stats.rewrittenRefs} reference${stats.rewrittenRefs === 1 ? '' : 's'} in ${stats.rewrittenFiles} file${stats.rewrittenFiles === 1 ? '' : 's'}; ` +
527
389
  `deleted ${orphansDeleted} orphan AVIF${orphansDeleted === 1 ? '' : 's'}; ` +
528
390
  `failed to rewrite ${stats.failedRefs} reference${stats.failedRefs === 1 ? '' : 's'}`
529
391
  )
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Перевіряє відповідність репозиторію правилу `image-compress.mdc`: канонічний скрипт
3
+ * `lint-image` для оптимізації raster/SVG через `@nitra/minify-image` ≥ 3.2.0 (локально).
4
+ *
5
+ * Очікування:
6
+ * - у кореневому `package.json` є скрипт `lint-image`, який викликає `npx @nitra/minify-image`
7
+ * з обовʼязковими `--src=.` і `--write`. Прапорець `--avif` у `lint-image` заборонений —
8
+ * AVIF-генерацію виконує окреме правило `image-avif` (інакше `bun run lint` плодив би `.avif`
9
+ * для зображень, що ніде не вживаються);
10
+ * - якщо в `package.json` є агрегований скрипт `lint`, він викликає `bun run lint-image`
11
+ * (симетрично до `lint-text`, `lint-js`, `lint-ga`);
12
+ * - `@nitra/minify-image` не оголошений у `dependencies`/`devDependencies` —
13
+ * CLI запускається лише через `npx` (як `markdownlint-cli2` у `text.mdc`);
14
+ * - `.n-minify-image.tsv` (committed source of truth з sha1/originalSize/size) НЕ
15
+ * в `.gitignore` — він має бути в git. Локальний mtime-кеш у
16
+ * `node_modules/.cache/@nitra/minify-image/mtime.tsv` авто-gitignored через `node_modules/`,
17
+ * окремої перевірки не вимагає;
18
+ * - застарілий `.minify-image-cache.tsv` (з версій < 3.2) видалений з кореня — інакше
19
+ * проєкт лишається у напівпереміщеному стані.
20
+ */
21
+ import { existsSync } from 'node:fs'
22
+ import { readFile } from 'node:fs/promises'
23
+
24
+ import { createCheckReporter } from './utils/check-reporter.mjs'
25
+
26
+ /** Імʼя CLI-пакета: рядок у `lint-image` і заборонений у залежностях. */
27
+ const MINIFY_PACKAGE_NAME = '@nitra/minify-image'
28
+
29
+ /** Імʼя committed-кешу (sha1 + originalSize + size) у `@nitra/minify-image` ≥ 3.2.0. */
30
+ const HASH_CACHE_FILENAME = '.n-minify-image.tsv'
31
+
32
+ /** Імʼя застарілого 4-колонкового кешу (`@nitra/minify-image` < 3.2). Має бути видалений після міграції. */
33
+ const LEGACY_CACHE_FILENAME = '.minify-image-cache.tsv'
34
+
35
+ /**
36
+ * Перевіряє скрипт `lint-image` у `package.json`.
37
+ *
38
+ * Має містити виклик `npx @nitra/minify-image` з обовʼязковими прапорцями `--src=.`
39
+ * і `--write` (авто-оптимізація на місці). Прапорець `--avif` у `lint-image`
40
+ * заборонений — AVIF-генерацію виконує `check image-avif`, інакше `bun run lint` плодить
41
+ * `.avif` для зображень, що ніде не вживаються.
42
+ * @param {string|undefined} lintImage значення `scripts['lint-image']`
43
+ * @param {(msg: string) => void} pass callback при успішній перевірці
44
+ * @param {(msg: string) => void} fail callback при помилці
45
+ * @returns {void}
46
+ */
47
+ function checkLintImageScript(lintImage, pass, fail) {
48
+ const canonical = `npx ${MINIFY_PACKAGE_NAME} --src=. --write`
49
+ if (typeof lintImage !== 'string' || !lintImage.trim()) {
50
+ fail(`package.json: додай скрипт "lint-image" з \`${canonical}\` (image-compress.mdc)`)
51
+ return
52
+ }
53
+ if (!lintImage.includes(`npx ${MINIFY_PACKAGE_NAME}`)) {
54
+ fail(`package.json: lint-image має викликати \`npx ${MINIFY_PACKAGE_NAME}\` (image-compress.mdc)`)
55
+ return
56
+ }
57
+ /** @type {{ flag: string, variants: string[], hint: string }[]} */
58
+ const requiredFlags = [
59
+ { flag: '--src=.', variants: ['--src=.', '--src .'], hint: '`--src=.`' },
60
+ { flag: '--write', variants: ['--write'], hint: '`--write` (авто-оптимізація на місці)' }
61
+ ]
62
+ const missing = requiredFlags.filter(f => !f.variants.some(v => lintImage.includes(v)))
63
+ if (missing.length > 0) {
64
+ fail(
65
+ `package.json: lint-image має містити ${missing.map(f => f.hint).join(', ')} — канонічний виклик: \`${canonical}\` (image-compress.mdc)`
66
+ )
67
+ return
68
+ }
69
+ if (lintImage.includes('--avif')) {
70
+ fail(
71
+ `package.json: прибери \`--avif\` з lint-image — AVIF-генерацію виконує \`npx @nitra/cursor check image-avif\` (image-compress.mdc). Канонічний виклик: \`${canonical}\``
72
+ )
73
+ return
74
+ }
75
+ pass(`package.json: lint-image викликає \`${canonical}\``)
76
+ }
77
+
78
+ /**
79
+ * Перевіряє, що агрегований `lint` (якщо є) кличе `bun run lint-image` —
80
+ * симетрично до `lint-text`, `lint-js`, `lint-ga`.
81
+ * @param {string|undefined} lintAggregate значення `scripts.lint`
82
+ * @param {(msg: string) => void} pass callback при успішній перевірці
83
+ * @param {(msg: string) => void} fail callback при помилці
84
+ * @returns {void}
85
+ */
86
+ function checkLintAggregateIncludesImage(lintAggregate, pass, fail) {
87
+ if (typeof lintAggregate !== 'string' || !lintAggregate.trim()) {
88
+ return
89
+ }
90
+ if (lintAggregate.includes('bun run lint-image')) {
91
+ pass('package.json: агрегований `lint` викликає `bun run lint-image`')
92
+ } else {
93
+ fail(
94
+ 'package.json: у `lint` додай `bun run lint-image` (image-compress.mdc, симетрично до lint-text / lint-js / lint-ga)'
95
+ )
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Забороняє `@nitra/minify-image` у `dependencies` чи `devDependencies` —
101
+ * CLI завжди запускається через `npx` (як `markdownlint-cli2` у `text.mdc`).
102
+ * @param {{ dependencies?: Record<string, unknown>, devDependencies?: Record<string, unknown> }} pkg розібраний package.json
103
+ * @param {(msg: string) => void} pass callback при успішній перевірці
104
+ * @param {(msg: string) => void} fail callback при помилці
105
+ * @returns {void}
106
+ */
107
+ function checkMinifyImageNotInDeps(pkg, pass, fail) {
108
+ const inDeps = Boolean(pkg.dependencies && MINIFY_PACKAGE_NAME in pkg.dependencies)
109
+ const inDevDeps = Boolean(pkg.devDependencies && MINIFY_PACKAGE_NAME in pkg.devDependencies)
110
+ if (inDeps || inDevDeps) {
111
+ fail(
112
+ `package.json: ${MINIFY_PACKAGE_NAME} не додавай у dependencies/devDependencies — лише через \`npx\` (image-compress.mdc)`
113
+ )
114
+ } else {
115
+ pass(`package.json: ${MINIFY_PACKAGE_NAME} не оголошено в dependencies/devDependencies`)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Зчитує всі змістовні рядки `.gitignore` (без коментарів і порожніх). Якщо файла нема — `null`.
121
+ * @returns {Promise<string[] | null>} список trim-нутих рядків або `null`
122
+ */
123
+ async function readGitignoreLines() {
124
+ if (!existsSync('.gitignore')) return null
125
+ const raw = await readFile('.gitignore', 'utf8')
126
+ return raw
127
+ .split('\n')
128
+ .map(l => l.trim())
129
+ .filter(l => l.length > 0 && !l.startsWith('#'))
130
+ }
131
+
132
+ /**
133
+ * Перевіряє, що `.n-minify-image.tsv` НЕ в `.gitignore` — він має бути в git
134
+ * (split-cache 3.2.0: source of truth для slow-path і lifetime savings).
135
+ *
136
+ * Сам факт існування файла НЕ вимагається — на свіжому проєкті без обробки
137
+ * зображень його ще нема, це нормально.
138
+ * @param {(msg: string) => void} pass callback при успішній перевірці
139
+ * @param {(msg: string) => void} fail callback при помилці
140
+ * @returns {Promise<void>}
141
+ */
142
+ async function checkHashCacheNotIgnored(pass, fail) {
143
+ const lines = await readGitignoreLines()
144
+ if (lines && lines.includes(HASH_CACHE_FILENAME)) {
145
+ fail(
146
+ `.gitignore: прибери рядок \`${HASH_CACHE_FILENAME}\` — це закомічений source of truth split-cache 3.2.0 (image-compress.mdc)`
147
+ )
148
+ } else {
149
+ pass(`${HASH_CACHE_FILENAME} не в .gitignore (має бути в git)`)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Перевіряє, що застарілий `.minify-image-cache.tsv` (з версій < 3.2) видалений
155
+ * з кореня. Якщо лежить — користувач не завершив міграцію на split-cache, що
156
+ * залишає файл як орфана у git-історії.
157
+ * @param {(msg: string) => void} pass callback при успішній перевірці
158
+ * @param {(msg: string) => void} fail callback при помилці
159
+ * @returns {Promise<void>}
160
+ */
161
+ async function checkLegacyCacheRemoved(pass, fail) {
162
+ if (existsSync(LEGACY_CACHE_FILENAME)) {
163
+ fail(
164
+ `${LEGACY_CACHE_FILENAME} застарілий (split-cache 3.2.0) — видали: ` +
165
+ `\`git rm --cached ${LEGACY_CACHE_FILENAME} 2>/dev/null || true && rm -f ${LEGACY_CACHE_FILENAME}\` ` +
166
+ '(також прибери відповідний рядок з .gitignore, якщо є)'
167
+ )
168
+ return
169
+ }
170
+ const lines = await readGitignoreLines()
171
+ if (lines && lines.includes(LEGACY_CACHE_FILENAME)) {
172
+ fail(`.gitignore: прибери застарілий рядок \`${LEGACY_CACHE_FILENAME}\` — split-cache 3.2.0 його не використовує`)
173
+ return
174
+ }
175
+ pass(`${LEGACY_CACHE_FILENAME} відсутній (міграція на split-cache завершена)`)
176
+ }
177
+
178
+ /**
179
+ * Перевіряє кореневий `package.json`: скрипти, заборонені залежності, агрегований `lint`.
180
+ * @param {(msg: string) => void} pass callback при успішній перевірці
181
+ * @param {(msg: string) => void} fail callback при помилці
182
+ * @returns {Promise<boolean>} `true`, якщо `package.json` знайдено й оброблено; `false` — нема
183
+ */
184
+ async function checkPackageJsonImage(pass, fail) {
185
+ if (!existsSync('package.json')) {
186
+ fail('package.json не знайдено в корені — додай (image-compress.mdc)')
187
+ return false
188
+ }
189
+ const pkg = JSON.parse(await readFile('package.json', 'utf8'))
190
+ const scripts = /** @type {Record<string, unknown>} */ (pkg.scripts || {})
191
+ checkLintImageScript(typeof scripts['lint-image'] === 'string' ? scripts['lint-image'] : undefined, pass, fail)
192
+ checkLintAggregateIncludesImage(typeof scripts.lint === 'string' ? scripts.lint : undefined, pass, fail)
193
+ checkMinifyImageNotInDeps(pkg, pass, fail)
194
+ return true
195
+ }
196
+
197
+ /**
198
+ * Перевіряє відповідність проєкту правилу `image-compress.mdc`: канонічний `lint-image`
199
+ * (через `npx @nitra/minify-image --src=. --write`, без `--avif`!), агрегований `lint`,
200
+ * `@nitra/minify-image` не у залежностях, `.n-minify-image.tsv` НЕ в `.gitignore`,
201
+ * застарілий `.minify-image-cache.tsv` видалений. CI-workflow для image не вимагається —
202
+ * лінт зображень виконується лише локально.
203
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
204
+ */
205
+ export async function check() {
206
+ const reporter = createCheckReporter()
207
+ const { pass, fail } = reporter
208
+
209
+ const pkgFound = await checkPackageJsonImage(pass, fail)
210
+ if (pkgFound) {
211
+ await checkHashCacheNotIgnored(pass, fail)
212
+ await checkLegacyCacheRemoved(pass, fail)
213
+ }
214
+
215
+ return reporter.getExitCode()
216
+ }
@@ -358,7 +358,7 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
358
358
  */
359
359
  function checkDepcheckInWorkflows(rootDir, workflows, label, fail, passFn) {
360
360
  if (workflows.length === 0) return
361
- const violations = findDepcheckViolationsForPackage(workflows, rootDir.replace(/\\/g, '/'))
361
+ const violations = findDepcheckViolationsForPackage(workflows, rootDir.replaceAll('\\', '/'))
362
362
  if (violations.length === 0) {
363
363
  passFn(`${label}depcheck у path-scoped workflow налаштовано (або відсутній path-scoped workflow для пакета)`)
364
364
  return
@@ -37,7 +37,7 @@ const IGNORES_FLAG_RE = /--ignores\s*=?\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/u
37
37
  * @returns {boolean} `true`, якщо знайдено хоча б один підходящий glob
38
38
  */
39
39
  export function workflowHasPathsScopedToPackage(root, pkgRoot) {
40
- const prefix = `${pkgRoot.replace(/\\/g, '/').replace(/\/+$/, '')}/`
40
+ const prefix = `${pkgRoot.replaceAll('\\', '/').replace(/\/+$/, '')}/`
41
41
  const on = root?.on
42
42
  if (!on || typeof on !== 'object') return false
43
43
  for (const event of /** @type {const} */ (['push', 'pull_request'])) {
@@ -85,8 +85,8 @@ export function extractDepcheckArgs(runText) {
85
85
  export function stepWorkingDirectoryEquals(step, pkgRoot) {
86
86
  const wd = step['working-directory']
87
87
  if (typeof wd !== 'string') return false
88
- const norm = wd.replace(/\\/g, '/').replace(/\/+$/, '')
89
- const expected = pkgRoot.replace(/\\/g, '/').replace(/\/+$/, '')
88
+ const norm = wd.replaceAll('\\', '/').replace(/\/+$/, '')
89
+ const expected = pkgRoot.replaceAll('\\', '/').replace(/\/+$/, '')
90
90
  return norm === expected
91
91
  }
92
92
 
package/mdc/image.mdc DELETED
@@ -1,96 +0,0 @@
1
- ---
2
- description: Оптимізація зображень через @nitra/minify-image у локальному lint
3
- alwaysApply: true
4
- version: '1.5'
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` заборонена** — її виконує лише `npx @nitra/cursor check image`, який заодно прибирає AVIF-сироти (див. секцію «AVIF-імпорти у `.vue`»). 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
- AVIF-двійники (`<name>.<ext>.avif`) **зберігаємо в git** — це готові артефакти для віддачі браузеру (без них ефект від AVIF втрачається на чистому checkout-і).
51
-
52
- ## AVIF-імпорти у `.vue`
53
-
54
- AVIF-двійники (`<name>.<ext>.avif`) генерує **виключно** `npx @nitra/cursor check image` — у `lint-image` прапорець `--avif` заборонений (інакше `bun run lint` плодить непотрібні `.avif` для зображень, що ніде не використовуються). Перевірка робить три кроки в порядку:
55
-
56
- 1. Запускає `npx @nitra/minify-image --src=. --write --avif` — генерує `<name>.<ext>.avif` поряд з кожним PNG/JPEG/GIF.
57
- 2. Сканує `.vue` (а також `.html`) файли в кожному workspace-пакеті (root + workspaces) і автоматично переписує raster-посилання на AVIF-двійник у двох формах:
58
- - **Імпорт-пов'язані** — `import x from '...png|jpg|jpeg|gif'` (далі `:src="x"` у шаблоні);
59
- - **Прямі статичні** — `<img src="...png" />` у `<template>` (Vite перетворює такий шлях на asset-імпорт на етапі збірки, тож вимога та сама).
60
- 3. Видаляє AVIF-сироти: ходить по всіх `<...>.avif` у репозиторії; якщо на двійник не лишилось жодного посилання у `.vue`/`.html` — файл видаляється. **AVIF на диску лишається лише там, де заміна реально відбулась** — тому невикористані оригінали не накопичують `.avif`-«хвости».
61
-
62
- ```vue title="App.vue (після check image)"
63
- <script setup>
64
- import welcomeImage from './assets/welcome.png.avif'
65
- </script>
66
-
67
- <template>
68
- <img :src="welcomeImage" alt="Welcome" />
69
- </template>
70
- ```
71
-
72
- Реактивне `:src="..."` (з JS-виразом — змінною, тернарником, викликом тощо) **не сканується** — значення обчислюється у рантаймі й шлях туди потрапляє через імпорт або інший резолвинг, який ловить імпорт-перевірка вище. SVG не торкаємо (vector → AVIF безглуздо). Атрибути `data-src=`, `obj.src=` у `<script>` тощо також пропускаються.
73
-
74
- Якщо raster-посилання у `.vue`/`.html` не вдалось переписати (наприклад, оригіналу немає на диску, тож і `.avif` не згенерувався) — `check image` падає з помилкою на конкретний файл, як раніше.
75
-
76
- ### Опт-аут для конкретного пакета
77
-
78
- У workspace-пакеті, де AVIF-імпорти небажані (наприклад, мобільний бандл, де AVIF-підтримка не гарантована), додай у `package.json` цього пакета:
79
-
80
- ```json title="apps/mobile/package.json"
81
- {
82
- "@nitra/minify-image": {
83
- "disable-avif": true
84
- }
85
- }
86
- ```
87
-
88
- Тоді перевірка пропускає `.vue` файли цього пакета. У root-`package.json` опт-аут діє лише для файлів кореня (вкладені workspaces перевіряються незалежно — вмикай прапор у кожному пакеті, де треба).
89
-
90
- ## Заборонені залежності
91
-
92
- `@nitra/minify-image` не повинен зʼявлятися ні в `dependencies`, ні в `devDependencies` кореневого `package.json` — CLI запускається лише через `npx` (так само, як `markdownlint-cli2` у text.mdc). Якщо потрібен явний пін — закладай діапазон версій npm у самому виклику (`npx @nitra/minify-image@^3 --src=. --write`).
93
-
94
- ## Перевірка
95
-
96
- `npx @nitra/cursor check image` (охоплює `lint-image` з обовʼязковими `--src=.`, `--write` і **забороненим** `--avif`; агрегований `lint`; `.n-minify-image.tsv` НЕ в `.gitignore` — має бути в git; відсутність застарілого `.minify-image-cache.tsv` у корені; запуск AVIF-генерації + авто-заміна raster-посилань на `.avif` у `.vue`/`.html` кожного workspace-пакета + прибирання AVIF-сиріт).