UNPKG

@stencil/sass

Version:

The Stencil Sass Plugin

446 lines (441 loc) 17.6 kB
import { render } from 'sass-embedded'; import path from 'node:path'; /** * Generates a diagnostic as a result of an error originating from Sass. * * This function mutates the provided context by pushing the generated diagnostic to the context's collection of * diagnostics. * * @param context the compilation context that the plugin has access to * @param sassError the Sass error to create a diagnostic from * @param filePath the path of the file that led to an error being raised * @returns the created diagnostic, or `null` if one could not be generated */ function loadDiagnostic(context, sassError, filePath) { if (sassError == null || context == null) { return null; } const diagnostic = { level: 'error', type: 'css', language: 'scss', header: 'sass error', code: formatCode(sassError.status), relFilePath: null, absFilePath: null, messageText: formatMessage(sassError.message), lines: [], }; if (typeof sassError.file === 'string' && sassError.file !== 'stdin') { filePath = sassError.file; } if (typeof filePath === 'string') { diagnostic.language = /(\.scss)$/i.test(filePath) ? 'scss' : 'sass'; diagnostic.absFilePath = filePath; diagnostic.relFilePath = formatFileName(context.config.rootDir, diagnostic.absFilePath); const errorLineNumber = sassError.line; const errorLineIndex = errorLineNumber - 1; diagnostic.lineNumber = errorLineNumber; diagnostic.columnNumber = sassError.column; if (errorLineIndex > -1) { try { const sourceText = context.fs.readFileSync(diagnostic.absFilePath); const srcLines = sourceText.split(/\r?\n/); const errorLine = { lineIndex: errorLineIndex, lineNumber: errorLineNumber, text: typeof srcLines[errorLineIndex] === 'string' ? srcLines[errorLineIndex] : '', errorCharStart: sassError.column, errorLength: 0, }; for (let i = errorLine.errorCharStart; i >= 0; i--) { if (STOP_CHARS.indexOf(errorLine.text.charAt(i)) > -1) { break; } errorLine.errorCharStart = i; } for (let j = errorLine.errorCharStart; j <= errorLine.text.length; j++) { if (STOP_CHARS.indexOf(errorLine.text.charAt(j)) > -1) { break; } errorLine.errorLength++; } if (errorLine.errorLength === 0 && errorLine.errorCharStart > 0) { errorLine.errorLength = 1; errorLine.errorCharStart--; } diagnostic.lines.push(errorLine); if (errorLine.lineIndex > 0) { const previousLine = { lineIndex: errorLine.lineIndex - 1, lineNumber: errorLine.lineNumber - 1, text: srcLines[errorLine.lineIndex - 1], errorCharStart: -1, errorLength: -1, }; diagnostic.lines.unshift(previousLine); } if (errorLine.lineIndex + 1 < srcLines.length) { const nextLine = { lineIndex: errorLine.lineIndex + 1, lineNumber: errorLine.lineNumber + 1, text: srcLines[errorLine.lineIndex + 1], errorCharStart: -1, errorLength: -1, }; diagnostic.lines.push(nextLine); } } catch (e) { console.error(`StyleSassPlugin loadDiagnostic, ${e}`); } } } context.diagnostics.push(diagnostic); return diagnostic; } /** * Helper function for converting a number error code to a string * @param input the numeric error code to convert * @returns the stringified error code */ function formatCode(input) { let output = ''; if (input != null) { output = String(input); } return output; } /** * Splits up a message from Sass, returning all input prior to the first '╷' character. * If no such character exists, the entire original message will be returned. * @param input the Sass message to split * @returns the split message */ function formatMessage(input) { let output = ''; if (typeof input === 'string') { output = input.split('╷')[0]; } return output; } /** * Formats the provided filename, by stripping the provided root directory out of the filename, and limiting the * display string to 80 characters * @param rootDir the root directory to strip out of the provided filename * @param fileName the filename to format for pretty printing * @returns the formatted filename */ function formatFileName(rootDir, fileName) { if (!rootDir || !fileName) return ''; fileName = fileName.replace(rootDir, ''); if (/\/|\\/.test(fileName.charAt(0))) { fileName = fileName.substring(1); } if (fileName.length > 80) { fileName = '...' + fileName.substring(fileName.length - 80); } return fileName; } const STOP_CHARS = [ '', '\n', '\r', '\t', ' ', ':', ';', ',', '{', '}', '.', '#', '@', '!', '[', ']', '(', ')', '&', '+', '~', '^', '*', '$', ]; /** * Determine if the Sass plugin should be applied, based on the provided `fileName` * * @param fileName the name of a file to potentially transform * @returns `true` if the name of the file ends with a sass extension (.scss, .sass), case insensitive. `false` * otherwise */ function usePlugin(fileName) { if (typeof fileName === 'string') { return /(\.scss|\.sass)$/i.test(fileName); } return false; } /** * Build a list of options to provide to Sass' `render` API. * @param opts the options provided to the plugin within a Stencil configuration file * @param sourceText the source text of the file to transform * @param fileName the name of the file to transform * @param context the runtime context being used by the plugin * @returns the generated/normalized plugin options */ function getRenderOptions(opts, sourceText, fileName, context) { var _a; // Create a copy of the original sass config, so we don't modify the one provided. // Explicitly add `data` (as it's a required field) to be the source text const renderOpts = Object.assign(Object.assign({}, opts), { data: sourceText }); // activate indented syntax if the file extension is .sass. // this needs to be set prior to injecting global sass (as the syntax affects the import terminator) renderOpts.indentedSyntax = /(\.sass)$/i.test(fileName); // create a copy of the original path config, so we don't modify the one provided renderOpts.includePaths = Array.isArray(opts.includePaths) ? opts.includePaths.slice() : []; // add the directory of the source file to includePaths renderOpts.includePaths.push(path.dirname(fileName)); // ensure each of the includePaths is an absolute path renderOpts.includePaths = renderOpts.includePaths.map((includePath) => { if (path.isAbsolute(includePath)) { return includePath; } // if it's a relative path then resolve it with the project's root directory return path.resolve(context.config.rootDir, includePath); }); // create a copy of the original global config of paths to inject, so we don't modify the one provided. // this is a Stencil-specific configuration, and not a part of the Sass API. const injectGlobalPaths = Array.isArray(opts.injectGlobalPaths) ? opts.injectGlobalPaths.slice() : []; if (injectGlobalPaths.length > 0) { // Automatically inject each of these paths into the source text. // This is accomplished by prepending the global stylesheets to the file being processed. const injectText = injectGlobalPaths .map((injectGlobalPath) => { if (!path.isAbsolute(injectGlobalPath)) { // convert any relative paths to absolute paths relative to the project root if (context.sys && typeof context.sys.normalizePath === 'function') { // context.sys.normalizePath added in stencil 1.11.0 injectGlobalPath = context.sys.normalizePath(path.join(context.config.rootDir, injectGlobalPath)); } else { // TODO, eventually remove normalizePath() from @stencil/sass injectGlobalPath = normalizePath(path.join(context.config.rootDir, injectGlobalPath)); } } const importTerminator = renderOpts.indentedSyntax ? '\n' : ';'; return `@use "${injectGlobalPath}" as *${importTerminator}`; }) .join(''); renderOpts.data = injectText + renderOpts.data; } // remove non-standard sass option delete renderOpts.injectGlobalPaths; // the "file" config option is not valid here delete renderOpts.file; if (context.sys && typeof context.sys.resolveModuleId === 'function') { const importers = []; if (typeof renderOpts.importer === 'function') { importers.push(renderOpts.importer); } else if (Array.isArray(renderOpts.importer)) { importers.push(...renderOpts.importer); } /** * Create a handler for loading files when a `@use` or `@import` rule is encountered for loading a path prefixed * with a tilde (~). Such imports indicate that the module should be resolved from the `node_modules` directory. * @param url the path to the module to load * @param _prev Unused - typically, this is a string identifying the stylesheet that contained the @use or @import. * @param done a callback to return the path to the resolved path */ const importer = (url, _prev, done) => { if (typeof url === 'string') { if (url.startsWith('~')) { try { const m = getModuleId(url); if (m.moduleId) { context.sys .resolveModuleId({ moduleId: m.moduleId, containingFile: m.filePath, }) .then((resolved) => { if (resolved.pkgDirPath) { const resolvedPath = path.join(resolved.pkgDirPath, m.filePath); done({ file: context.sys.normalizePath(resolvedPath), }); } else { done(null); } }) .catch((err) => { done(err); }); return; } } catch (e) { done(e); } } } done({ file: context.sys.normalizePath(url) }); }; importers.push(importer); renderOpts.importer = importers; } renderOpts.silenceDeprecations = [...((_a = renderOpts.silenceDeprecations) !== null && _a !== void 0 ? _a : []), 'legacy-js-api']; return renderOpts; } /** * Replaces the extension with the provided file name with 'css'. * * If the file does not have an extension, no transformation will be applied. * * @param fileName the name of the file whose extension should be replaced * @returns the updated filename, using 'css' as the file extension */ function createResultsId(fileName) { // create what the new path is post transform (.css) const pathParts = fileName.split('.'); pathParts[pathParts.length - 1] = 'css'; return pathParts.join('.'); } function normalizePath(str) { // Convert Windows backslash paths to slash paths: foo\\bar ➔ foo/bar // https://github.com/sindresorhus/slash MIT // By Sindre Sorhus if (typeof str !== 'string') { throw new Error(`invalid path to normalize`); } str = str.trim(); if (EXTENDED_PATH_REGEX.test(str) || NON_ASCII_REGEX.test(str)) { return str; } str = str.replace(SLASH_REGEX, '/'); // always remove the trailing / // this makes our file cache look ups consistent if (str.charAt(str.length - 1) === '/') { const colonIndex = str.indexOf(':'); if (colonIndex > -1) { if (colonIndex < str.length - 2) { str = str.substring(0, str.length - 1); } } else if (str.length > 1) { str = str.substring(0, str.length - 1); } } return str; } /** * Split an import path into a module ID and file path * @param orgImport the import path to split * @returns a module id and the filepath under that module id */ function getModuleId(orgImport) { if (orgImport.startsWith('~')) { orgImport = orgImport.substring(1); } const splt = orgImport.split('/'); const m = { moduleId: null, filePath: null, }; if (orgImport.startsWith('@') && splt.length > 1) { // we have a scoped package, it's module includes the word following the first slash m.moduleId = splt.slice(0, 2).join('/'); m.filePath = splt.slice(2).join('/'); } else { m.moduleId = splt[0]; m.filePath = splt.slice(1).join('/'); } return m; } const EXTENDED_PATH_REGEX = /^\\\\\?\\/; const NON_ASCII_REGEX = /[^\x00-\x80]+/; const SLASH_REGEX = /\\/g; /** * The entrypoint of the Stencil Sass plugin * * This function creates & configures the plugin to be used by consuming Stencil projects * * For configuration details, please see the [GitHub README](https://github.com/stenciljs/sass). * * @param opts options to configure the plugin * @return the configured plugin */ function sass(opts = {}) { return { name: 'sass', pluginType: 'css', /** * Performs the Sass file compilation * @param sourceText the contents of the Sass file to compile * @param fileName the name of the Sass file to compile * @param context a runtime context supplied by Stencil, providing access to the current configuration, an * in-memory FS, etc. * @returns the results of the Sass file compilation */ transform(sourceText, fileName, context) { if (!usePlugin(fileName)) { return null; } if (typeof sourceText !== 'string') { return null; } const renderOpts = getRenderOptions(opts, sourceText, fileName, context); const results = { id: createResultsId(fileName), dependencies: [], }; if (sourceText.trim() === '') { results.code = ''; return Promise.resolve(results); } return new Promise((resolve) => { try { // invoke sass' compiler at this point render(renderOpts, (err, sassResult) => { if (err) { loadDiagnostic(context, err, fileName); results.code = `/** sass error${err && err.message ? ': ' + err.message : ''} **/`; resolve(results); } else { results.dependencies = Array.from(sassResult.stats.includedFiles).map((dep) => context.sys.normalizePath(dep)); results.code = sassResult.css.toString(); // write this css content to memory only so it can be referenced // later by other plugins (autoprefixer) // but no need to actually write to disk context.fs.writeFile(results.id, results.code, { inMemoryOnly: true }).then(() => { resolve(results); }); } }); } catch (e) { // who knows, just good to play it safe here const diagnostic = { level: 'error', type: 'css', language: 'scss', header: 'sass error', relFilePath: null, absFilePath: null, messageText: e, lines: [], }; context.diagnostics.push(diagnostic); results.code = `/** sass error${e && e.message ? ': ' + e.message : ''} **/`; resolve(results); } }); }, }; } export { sass };