Добавлен sass-importer
This commit is contained in:
70
packages/sass-importer/README.md
Normal file
70
packages/sass-importer/README.md
Normal file
@@ -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
|
||||
150
packages/sass-importer/index.js
Normal file
150
packages/sass-importer/index.js
Normal file
@@ -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»: <lookupPath>/_index.scss и т.п. — то, что Sass подставляет при импорте папки/фичи. */
|
||||
for (const ext of EXTENSIONS) {
|
||||
const candidate = path.join(lookupPath, `_index${ext}`);
|
||||
if (isFile(candidate)) {
|
||||
return toFileUrl(candidate);
|
||||
}
|
||||
}
|
||||
/* 4. «Директория с index»: <lookupPath>/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',
|
||||
};
|
||||
},
|
||||
});
|
||||
14
packages/sass-importer/package.json
Normal file
14
packages/sass-importer/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user