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', }; }, });