@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 +40 -0
- package/bin/auto-rules.md +3 -1
- package/mdc/image-avif.mdc +55 -0
- package/mdc/image-compress.mdc +56 -0
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +4 -2
- package/scripts/check-image-avif.mjs +394 -0
- package/scripts/check-image-compress.mjs +216 -0
- package/scripts/check-js-run.mjs +1 -1
- package/scripts/utils/depcheck-workflow.mjs +3 -3
- package/mdc/image.mdc +0 -96
- package/scripts/check-image.mjs +0 -500
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 - [
|
|
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
package/scripts/auto-rules.mjs
CHANGED
|
@@ -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
|
+
}
|