diff --git a/packages/sass-importer/README.md b/packages/sass-importer/README.md new file mode 100644 index 0000000..cdfc36d --- /dev/null +++ b/packages/sass-importer/README.md @@ -0,0 +1,70 @@ +# @advdominion/sass-importer + +Кастомный импортёр для [sass-embedded](https://github.com/sass/dart-sass-embedded), который берёт резолв `@use`/`@forward`-импортов на себя и читает файлы через `node:fs`. + +## Зачем + +В проектах на **Yarn PnP** пакеты лежат не в `node_modules`, а внутри `.zip` по виртуальным путям: + +``` +.yarn/__virtual__/@example-example-virtual-.../cache/@example-example-....zip/node_modules/@example/example/... +``` + +`.zip`-путь может открыть только код, читающий файлы через `node:fs` (в нём есть PnP-хуки). А `sass-embedded` читает файлы своим отдельным кодом, `node:fs` не использует, поэтому импорты внутри пакетов не резолвятся. Этот импортёр обходит проблему: он сам превращает импорт в существующий файл (`canonicalize`) и отдаёт его содержимое через `readFileSync` (`load`). + +Работает и в `node-modules`-проектах — там `require` без PnP-хуков, но тот же резолв остаётся корректным. + +## Установка + +```bash +yarn add -D @advdominion/sass-importer +``` + +## Использование + +```js +import { createImporter } from '@advdominion/sass-importer'; + +const require = createRequire(import.meta.url); +const importer = createImporter(require); + +const { css } = compiler.compileString(source, { + importers: [importer], + loadPaths: ['src/styles'], +}); +``` + +Импортёр принимает `require` из `createRequire(import.meta.url)`: в PnP он несёт PnP-хуки, в `node-modules` — обычный `require`. Это позволяет резолвить и пакеты, и их внутренние относительные импорты одинаково надёжно. + +## API + +### `createImporter(require): Importer` + +- `require` — функция резолва (обычно `createRequire(import.meta.url)`), используется для ветки `pkg:`. +- Возвращает объект из двух колбэков для `sass-embedded`: `canonicalize` и `load`. + +`canonicalize(url, ctx)` превращает импорт в канонический `file://` URL или возвращает `null` («я не знаю такой файл» — тогда Sass перебирает следующие импортёры/`loadPaths`). + +`load(canonicalUrl)` отдаёт содержимое файла и его синтаксис (`syntax: 'scss'`). Если файла нет — бросает `Error: Stylesheet not found: <путь>`. + +## Правила резолва + +| Приоритет | Вариант | Пример | +| --------- | ------------------ | --------------------------------------- | +| 1 | Точное совпадение | `@use 'variables'` → `variables` (файл) | +| 2 | Файл с расширением | `@use 'variables'` → `variables.scss` | +| 3 | Каталог с `_index` | `@use './lib'` → `./lib/_index.scss` | +| 4 | Каталог с `index` | `@use './lib'` → `./lib/index.scss` | + +Для каталогов берутся `_index` и `index` как с `.scss`, так и с `.css`. Порядок проверок соответствует порядку перебора Sass, поэтому первая найденная запись — приоритетная. + +## Виды импортов + +- `pkg:@scope/pkg/path/to/file` → `require.resolve` + резолв кандидата. +- `file:///abs/path` → кандидат по файловому пути. +- относительные (`../../variables`) → склейка с каталогом содержащего файла (берётся из `ctx.containingUrl`), затем резолв кандидата. +- абсолютные (`/...`) и незнакомые схемы (`http:`, `data:`) → отдаёт `null`, резолв остаётся на Sass. + +## Лицензия + +MIT diff --git a/packages/sass-importer/index.js b/packages/sass-importer/index.js new file mode 100644 index 0000000..dc2675b --- /dev/null +++ b/packages/sass-importer/index.js @@ -0,0 +1,150 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +const isFile = (filePath) => existsSync(filePath) && statSync(filePath).isFile(); +const toFileUrl = (filePath) => new URL(`file://${encodeURI(path.normalize(filePath))}`); +const EXTENSIONS = ['.scss', '.css']; + +/* + resolveCandidate(lookupPath) — по пути импорта находит существующий SCSS-файл + и возвращает его file:// URL. + + Мы сами эмулируем правила резолва Sass, потому что (см. комментарий над importer) взяли резолв на себя, + а не отдали его Sass. Sass при обычной работе умеет подбирать: файл без расширения, файл с расширением, + _index.scss внутри папки, index.scss внутри папки. Всё это воспроизводим здесь. Порядок проверок + соответствует порядку, в котором Sass перебирает варианты, поэтому первая найденная запись — приоритетная. + + Возвращает null, если ни один вариант не существует: для импортёра это сигнал «я не знаю такой файл», + и Sass переберёт другие импортеры/loadPaths. +*/ +const resolveCandidate = (lookupPath) => { + /* 1. Точное совпадение: переданный путь уже указывает на существующий файл. */ + if (isFile(lookupPath)) { + return toFileUrl(lookupPath); + } + /* 2. «Файл с расширением»: path + .scss/.css. Покрывает @use 'variables' -> variables.scss. */ + for (const ext of EXTENSIONS) { + if (isFile(lookupPath + ext)) { + return toFileUrl(lookupPath + ext); + } + } + /* 3. «Директория с partial»: /_index.scss и т.п. — то, что Sass подставляет при импорте папки/фичи. */ + for (const ext of EXTENSIONS) { + const candidate = path.join(lookupPath, `_index${ext}`); + if (isFile(candidate)) { + return toFileUrl(candidate); + } + } + /* 4. «Директория с index»: /index.scss — обычный fallback, если _index нет. */ + for (const ext of EXTENSIONS) { + const candidate = path.join(lookupPath, `index${ext}`); + if (isFile(candidate)) { + return toFileUrl(candidate); + } + } + return null; // oxlint-disable-line unicorn/no-null +}; + +/* + Импортер для sass-embedded. + + Проект собирается через Yarn PnP, поэтому пакеты лежат не в node_modules, + а внутри .zip по виртуальным путям: + .yarn/__virtual__/@example-example-virtual-.../cache/@example-example-....zip/node_modules/@example/example/... + .zip-путь может открыть только код, читающий файлы через node:fs (в нём есть PnP-хуки). + А sass-embedded читает файлы своим отдельным кодом, node:fs не использует, поэтому мы + резолв импортов берём на себя через readFileSync. `require` передаётся извне: в PnP это + createRequire(import.meta.url) с PnP-хуками, в node-modules — обычный require. + + Сюда попадают импорты трёх видов: пути с префиксом pkg:, file:// и относительные + (в том числе вложенные @use '../../variables' внутри пакета). Все они проходят через + resolveCandidate, а содержимое читается через readFileSync, поэтому и пакеты, и их внутренние + относительные импорты открываются одинаково надёжно. +*/ +export const createImporter = (require) => ({ + /* + canonicalize(url, ctx) — Sass спрашивает «во что превратить этот импорт, чтобы я мог его прочитать?». + На вход приходит строка url (в оригинальном виде из @use/@forward) и контекст ctx с containingUrl — + файлом, из которого сделан импорт. Возвращаем канонический file:// URL или null («не знаю»). + */ + canonicalize(url, ctx) { + /* + Ветка 1: импорт пакета вида «pkg:@scope/pkg/path/to/file». + url.slice(4) убирает префикс «pkg:», остаётся «@example/example/...». require.resolve превращает + это имя в абсолютный путь внутри пакета (благодаря PnP-хукам). Затем resolveCandidate + подбирает реальный SCSS-файл и возвращает file:// URL. + */ + if (url.startsWith('pkg:')) { + try { + return resolveCandidate(require.resolve(url.slice(4))); + } catch { + /* + Пакет не резолвится (нет в зависимостях) — отдаём null, пусть Sass пробует дальше. + */ + return null; // oxlint-disable-line unicorn/no-null + } + } + + /* + Ветка 2: Sass уже сам превратил что-то в file:// и спрашивает нас «подтверди/уточни путь». + Извлекаем файловый путь через .pathname (декодируем процент-эскейпы) и подбираем кандидата. + */ + if (url.startsWith('file://')) { + return resolveCandidate(decodeURI(new URL(url).pathname)); + } + + /* + Ветка 3: абсолютный путь («/...») или URI с незнакомой схемой («http:», «data:» и т.п.). + Здесь мы в резолве не участвуем — отдаём null и позволяем Sass/loadPaths обработать самим. + */ + if (url.includes(':') || url.startsWith('/')) { + return null; // oxlint-disable-line unicorn/no-null + } + + /* + Ветка 4: относительный импорт («../../variables», «../link»). + Это именно тот случай, который ломает сборку в PnP: путь ведёт внутрь .zip. + Чтобы его открыть, нужен базовый файл, из которого сделан импорт, — его даёт ctx.containingUrl. + Склеиваем относительный путь с каталогом содержащего файла через path.resolve и резолвим через + resolveCandidate. + */ + const containingUrl = ctx?.containingUrl; + if (containingUrl) { + const abs = path.resolve(path.dirname(decodeURI(containingUrl.pathname)), url); + return resolveCandidate(abs); + } + + /* + Если контекста нет (чего в норме не бывает) — «не знаю», идём к следующему импортеру/loadPaths. + */ + return null; // oxlint-disable-line unicorn/no-null + }, + + /* + load(canonicalUrl) — Sass просит содержимое файла по каноническому URL, который мы вернули в canonicalize. + Возвращаем текст файла и его синтаксис, чтобы Sass не гадал. + */ + load(canonicalUrl) { + /* + file:// URL извлекаем как путь: .pathname уже декодирован, но для надёжности декодируем явно. + */ + const filePath = decodeURI(canonicalUrl.pathname); + if (!isFile(filePath)) { + /* + Файла нет или это каталог — кидаем понятную ошибку с путём, чтобы в логе было видно, чего не хватает. + */ + throw new Error(`Stylesheet not found: ${filePath}`); + } + return { + /* + readFileSync — синхронно читаем. Для PnP важен именно node:fs (см. док над importer): + только читаемый через него файл открывается внутри .zip. + */ + contents: readFileSync(filePath, 'utf8'), + /* + Говорим Sass, что это SCSS-файл (иначе он пытается угадать по расширению, что для .zip-пути ненадёжно). + */ + syntax: 'scss', + }; + }, +}); diff --git a/packages/sass-importer/package.json b/packages/sass-importer/package.json new file mode 100644 index 0000000..165f70a --- /dev/null +++ b/packages/sass-importer/package.json @@ -0,0 +1,14 @@ +{ + "name": "@advdominion/sass-importer", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://gitea.optiweb.ru/public/frontend.git" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +}