eyeglass
Version:
Sass modules for npm.
440 lines (403 loc) • 14.7 kB
text/typescript
;
import { IOptions as GlobOptions } from "glob";
import * as path from "path";
import { ModuleSpecifier } from "../modules/EyeglassModule";
import { Options as SassOptions, SassImplementation } from "./SassImplementation";
import { URI } from "./URI";
import merge = require("lodash.merge");
import type { Importer, FunctionDeclarations } from "node-sass";
export const DEFAULT_EYEGLASS_COMPAT = "^2.0";
export interface AssetSourceOptions {
/**
* The namespace of this asset source.
*/
name?: string;
/**
* The httpPrefix of this source relative to the httpPrefix of all assets.
* If not provided, the namespace of this source is used.
*/
httpPrefix?: string;
/**
* The directory of this asset source.
*/
directory: string;
/**
* Options for globbing files within the directory.
*/
globOpts?: GlobOptions;
/**
* Pattern for globbing files within the directory.
* Defaults to "**\/*"
*/
pattern?: string;
}
export interface AssetOptions {
/**
* A list of asset sources.
*/
sources?: Array<AssetSourceOptions>;
/**
* The httpPrefix for all assets relative to the project's httpRoot.
*/
httpPrefix?: string;
/**
* A http prefix directory that assets urls will be relative to.
* This would usually be set to the directory from which the CSS file being
* rendered is served.
*
*/
relativeTo?: string;
}
export interface Engines {
/**
* If not provided, eyeglass will require `node-sass` at the version it
* currently depends.
*/
sass?: SassImplementation;
[engine: string]: unknown;
}
export interface EyeglassConfig extends Required<EyeglassSpecificOptions<never>> {
engines: Required<Engines>;
}
export interface BuildCache {
get(key: string): number | string | undefined;
set(key: string, value: number | string): void;
}
export interface EyeglassSpecificOptions<ExtraSandboxTypes = true | string> {
/**
* Where to find assets for the eyeglass project.
*/
assets?: AssetOptions;
/**
* Implementations provided to eyeglass that can be used by eyeglass itself
* or by custom functions in an eyeglass module.
*/
engines?: Engines;
/**
* Manually declare an eyeglass modules for sass libraries that do not
* declare themselves to be eyeglass modules.
*/
modules?: Array<ModuleSpecifier>;
/**
* Whether to only import a sass file once per css output file.
* Defaults to true.
*/
enableImportOnce?: boolean;
/**
* Whether to install assets using symlinks or file copies.
* Setting this to true is good for performance.
* Defaults to false.
*/
installWithSymlinks?: boolean;
/**
* Whether to normalize paths on windows.
* Defaults to true or to the value of the environment variable
* `EYEGLASS_NORMALIZE_PATHS`.
*/
normalizePaths?: boolean;
/**
* Directory from which sass files are imported for this project.
*/
root?: string;
/**
* The directory in the URL from which css files are served for this project.
*/
httpRoot?: string;
/**
* Directory where eyeglass should store cache information during and across builds.
* This will be created if it does not exist.
*/
cacheDir?: string;
/**
* Directory where files are output once built.
*/
buildDir?: string;
/**
* Whether to raise an error if the same eyeglass module is a dependency
* more than once with incompatible semantic versions.
*/
strictModuleVersions?: boolean | "warn";
/**
* Default to false.
*
* Whether to disable the strict dependency check that ensures
* that Sass files in an eyeglass eyeglass module can only import sass files
* from the eyeglass modules that it depends on directly.
*
* When true, a Sass file will be able to import from any eyeglass
* module that is found in the module tree.
*
* This is not recommended, but may be necessary when working with manually
* declared modules which currently lack a well-defined mechanism for
* declaring dependencies on other manual modules.
*/
disableStrictDependencyCheck?: boolean;
/**
* When strictModuleVersions checking is enabled,
* this asserts that the modules installed are compatible with the
* version of eyeglass specified, in contradiction to those module's
* own declaration of the version of eyeglass that they say they need.
* This is helpful when eyeglass major releases occur and eyeglass modules
* that you depend on haven't yet been updated, but appear to work regardless.
*
* The value can be any semver dependency specifier. For instance, if
* eyeglass 3.0 is released, you can set this to "^3.0.0" and any eyeglass
* 3.x release will be assumed ok but a 4.0 release will cause things to
* break again.
*/
assertEyeglassCompatibility?: string;
/**
* Whether to cache eyeglass modules across the entire javascript process.
*/
useGlobalModuleCache?: boolean;
/**
* Ignore deprecations that started being issued at or below this version.
*/
ignoreDeprecations?: string;
/**
* Whether to allow filesystem reads and if so, from which directories.
* * `false` - allows reads from the entire filesystem (insecure).
* * `true` - only allows reads from the `root` directory.
* * `<string>` - a directory from which reads are allowed.
* * `Array<string>` - A list of directories from which to allow access.
* * An empty list disables filesystem access (default).
*/
fsSandbox?: ExtraSandboxTypes | false | Array<string>;
/**
* The buildCache is provided by the caller to allow eyeglass to cache
* information about files including file contents repeated disk access to
* common files. The cache can be a Map, or some memory-capped cache like
* `lru-cache`. This cache will only have strings or numbers placed into it.
*
* The cache should be cleared by the caller whenever file changes may have
* occurred (usually between builds of a long-running watcher).
*/
buildCache?: BuildCache;
}
export interface ForbiddenOptions {
/** @deprecated Since 0.8. */
root?: string;
/** @deprecated Since 0.8 */
httpRoot?: string;
/** @deprecated Since 0.8 */
cacheDir?: string;
/** @deprecated Since 0.8 */
buildDir?: string;
/** @deprecated Since 0.8 */
strictModuleVersions?: boolean;
}
export interface ForbiddenAssetOptions {
/** @deprecated Since 0.8 */
assetsHttpPrefix?: string;
/** @deprecated Since 0.8 */
assetsRelativeTo?: string;
}
export interface EyeglassOptions {
eyeglass?: EyeglassSpecificOptions;
assetsCache?: (cacheKey: string, lazyValue: () => string) => void;
}
export type Options = (SassOptions & EyeglassOptions);
export type Config = SassOptions & {
eyeglass: EyeglassConfig;
assetsCache?: (cacheKey: string, lazyValue: () => string) => string;
};
/* eslint-disable-next-line no-unused-vars */
export default class implements Config {
file?: string;
data?: string;
importer?: Importer | Array<Importer>;
functions?: FunctionDeclarations;
includePaths?: Array<string>;
indentedSyntax?: boolean;
indentType?: string;
indentWidth?: number;
linefeed?: string;
omitSourceMapUrl?: boolean;
outFile?: string;
outputStyle?: "compact" | "compressed" | "expanded" | "nested";
precision?: number;
sourceComments?: boolean;
sourceMap?: boolean | string;
sourceMapContents?: boolean;
sourceMapEmbed?: boolean;
sourceMapRoot?: string;
eyeglass: EyeglassConfig;
assetsCache?: (cacheKey: string, lazyValue: () => string) => string;
constructor(options: Options) {
let config = getSassOptions(options)
this.eyeglass = config.eyeglass; // this makes the compiler happy.
merge(this, config);
}
}
function includePathsFromEnv(): Array<string> {
return normalizeIncludePaths(process.env.SASS_PATH, process.cwd());
}
function forbidEyeglassOptionsFromSassOptions(sassOptions: ForbiddenOptions & SassOptions, eyeglassOptions: EyeglassSpecificOptions): void {
// migrates the following properties from sassOptions to eyeglassOptions
const forbiddenOptions: Array<keyof ForbiddenOptions> = [ "root", "cacheDir", "buildDir", "httpRoot", "strictModuleVersions" ];
forbiddenOptions.forEach(function(option) {
if (eyeglassOptions[option] === undefined && sassOptions[option] !== undefined) {
throw new Error(["`" + option + "` must be passed into the eyeglass options rather than the sass options:",
"var options = eyeglass({",
" /* sassOptions */",
" ...",
" eyeglass: {",
" " + option + ": ...",
" }",
"});"
].join("\n "));
}
});
}
function forbidAssetOption<FromOpt extends "assetsHttpPrefix" | "assetsRelativeTo">(
sassOptions: ForbiddenAssetOptions,
eyeglassOptions: EyeglassSpecificOptions,
fromOption: FromOpt,
toOption: typeof fromOption extends "assetsHttpPrefix" ? "httpPrefix" : "relativeTo"
): void {
if ((eyeglassOptions.assets === undefined ||
(eyeglassOptions.assets && eyeglassOptions.assets[toOption] === undefined)) &&
sassOptions[fromOption] !== undefined) {
throw new Error([`\`${fromOption }\` has been renamed to \`${toOption}\` and must be passed into the eyeglass asset options rather than the sass options:`,
"var options = eyeglass({",
" /* sassOptions */",
" ...",
" eyeglass: {",
" assets: {",
` ${toOption}: ...`,
" }",
" }",
"});"
].join("\n "));
}
}
function forbidAssetOptionsFromSassOptions(sassOptions: SassOptions & ForbiddenAssetOptions, eyeglassOptions: EyeglassSpecificOptions): void {
// errors on the following legacy properties if passed into sassOptions instead of eyeglassOptions
forbidAssetOption(sassOptions, eyeglassOptions, "assetsHttpPrefix", "httpPrefix");
forbidAssetOption(sassOptions, eyeglassOptions, "assetsRelativeTo", "relativeTo");
}
function defaultSassOptions(options: SassOptions): SassOptions {
defaultValue(options, "includePaths", () => includePathsFromEnv());
return options;
}
function requireSass(): SassImplementation {
let sass: SassImplementation;
try {
sass = require("node-sass")
} catch (e) {
try {
sass = require("sass");
} catch (e) {
throw new Error("A sass engine was not provided and neither `sass` nor `node-sass` were found in the current project.")
}
}
return sass;
}
export function resolveConfig(options: Partial<EyeglassSpecificOptions>): EyeglassConfig {
// default root dir
defaultValue(options, "root", () => process.cwd());
// default cache dir
defaultValue(options, "cacheDir", () => path.join(options.root!, ".eyeglass_cache"));
// default engines
defaultValue(options, "engines", () => {return {};});
defaultValue(options.engines!, "sass", () => requireSass());
// default assets
defaultValue(options, "assets", () => {return {};});
// default httpRoot
defaultValue(options, "httpRoot", () => "/");
// default enableImportOnce
defaultValue(options, "enableImportOnce", () => true);
// use global module caching by default
defaultValue(options, "useGlobalModuleCache", () => true);
// There's no eyeglass module API changes in eyeglass 2.x so we default to silencing these warnings.
defaultValue(options, "assertEyeglassCompatibility", () => DEFAULT_EYEGLASS_COMPAT);
// Use a simple cache that just lasts for this one file if no buildCache is provided.
defaultValue(options, "buildCache", () => new Map());
// Strict dependency checks are enabled by default.
defaultValue(options, "disableStrictDependencyCheck", () => false);
options.fsSandbox = normalizeFsSandbox(options.fsSandbox, options.root!);
return options as EyeglassConfig;
}
function normalizeFsSandbox(sandboxOption: Partial<EyeglassSpecificOptions>["fsSandbox"], root: string): EyeglassConfig["fsSandbox"] {
let fsSandbox: false | Array<string>;
if (typeof sandboxOption === "undefined") {
// default to no fs access
fsSandbox = [];
} else if (sandboxOption === true) {
// support simple enabling of the sandbox.
fsSandbox = [root];
} else if (typeof sandboxOption === "string") {
// support simple strings instead of requiring a list for even a single dir.
fsSandbox = [sandboxOption];
} else {
fsSandbox = sandboxOption;
}
return fsSandbox;
}
function normalizeIncludePaths(
includePaths: string | Array<string> | undefined,
baseDir: string
): Array<string> {
if (!includePaths) {
return [];
}
// in some cases includePaths is a path delimited string
if (typeof includePaths === "string") {
includePaths = includePaths.split(path.delimiter);
}
// filter out empty paths
includePaths = includePaths.filter((dir) => !!dir);
// make all relative include paths absolute
return includePaths.map((dir) => path.resolve(baseDir, URI.system(dir)));
}
function normalizeSassOptions(sassOptions: SassOptions, eyeglassOptions: EyeglassConfig): Config {
sassOptions.includePaths = normalizeIncludePaths(sassOptions.includePaths, eyeglassOptions.root);
return Object.assign(sassOptions, {eyeglass: eyeglassOptions});
}
const FORBIDDEN_OPTIONS = new Set<keyof (ForbiddenAssetOptions & ForbiddenOptions)>([
"root",
"httpRoot",
"cacheDir",
"buildDir",
"strictModuleVersions",
"assetsHttpPrefix",
"assetsRelativeTo",
]);
function hasForbiddenOptions(options: Options): options is ForbiddenAssetOptions & SassOptions {
for (let key in options) {
if ((FORBIDDEN_OPTIONS as Set<string>).has(key)) {
return true;
}
}
return false;
}
function getSassOptions(
options: Options | undefined,
): Config {
let sassOptions: Options = options || {};
let eyeglassOptions: EyeglassSpecificOptions = {};
merge(eyeglassOptions, sassOptions.eyeglass);
// determine whether or not we should normalize URI path separators
// @see URI
if (eyeglassOptions.normalizePaths !== undefined) {
// TODO: make the code read the config from options which is defaulted from the env var
process.env.EYEGLASS_NORMALIZE_PATHS = `${eyeglassOptions.normalizePaths}`;
}
if (hasForbiddenOptions(sassOptions)) {
// forbid legacy eyeglassOptions within sassOptions
forbidEyeglassOptionsFromSassOptions(sassOptions, eyeglassOptions);
forbidAssetOptionsFromSassOptions(sassOptions, eyeglassOptions);
}
defaultSassOptions(sassOptions);
resolveConfig(eyeglassOptions);
return normalizeSassOptions(sassOptions, resolveConfig(eyeglassOptions));
}
function defaultValue<
T extends object,
K extends keyof T,
V extends () => T[K]
>(obj: T, key: K, value: V): void {
if (obj[key] === undefined) {
obj[key] = value();
}
}