alchemymvc
Version:
MVC framework for Node.js
1,525 lines (1,186 loc) • 34.5 kB
JavaScript
let publicDirs = alchemy.shared('public.directories', new Deck()),
scriptDirs = alchemy.shared('script.directories', new Deck()),
assetDirs = alchemy.shared('asset.directories', new Deck()),
styleDirs = alchemy.shared('stylesheet.directories', new Deck()),
imageDirs = alchemy.shared('images.directories', new Deck()),
rootDirs = alchemy.shared('root.directories', new Deck()),
fontDirs = alchemy.shared('font.directories', new Deck()),
asset_cache= alchemy.getCache('files.assets'),
fileCache = alchemy.shared('files.fileCache'),
minify_map = alchemy.shared('files.minifyMap', new Map()),
Nodent = alchemy.use('nodent-compiler'),
Terser = alchemy.use('terser'),
libpath = alchemy.use('path'),
required_stylesheets = new Set(),
nodent_compiler,
regenerator_runtime,
sass_functions,
babel_polyfill,
postcss_prune,
babel_preset,
babel_async,
autoprefixer,
postcss,
babel,
sass,
less,
fs = alchemy.use('fs');
if (alchemy.settings.frontend.stylesheet.enable_less !== false) {
less = alchemy.use('less');
}
if (alchemy.settings.frontend.stylesheet.enable_scss) {
// Try to use the embedded DART sass
sass = alchemy.use('sass-embedded');
if (!sass) {
sass = alchemy.use('sass');
}
sass_functions = {
'alchemy_settings($name)': function(name) {
if (!(name instanceof sass.types.String)) {
return sass.types.Null.NULL;
}
name = name.getValue();
let value = Object.path(alchemy.settings, name);
if (value == null) {
value = Object.path(alchemy.plugins, name);
}
let type = typeof value,
result;
switch (type) {
case 'string':
if (value) {
result = new sass.types.String(value);
} else {
result = sass.types.Null.NULL;
}
break;
case 'number':
result = new sass.types.Number(value);
break;
case 'boolean':
result = new sass.types.Boolean(value);
break;
default:
if (value == null) {
result = sass.types.Null.NULL;
} else {
result = new sass.types.String(''+value);
}
break;
}
return result;
},
'should_add_exports()': function() {
return sass.types.Boolean.FALSE;
},
};
}
if (alchemy.settings.frontend.stylesheet.enable_post !== false) {
postcss = alchemy.use('postcss');
autoprefixer = alchemy.use('autoprefixer');
postcss_prune = alchemy.use('postcss-prune-var')();
}
if (alchemy.settings.frontend.javascript.enable_babel) {
babel = alchemy.use('@babel/core');
babel_polyfill = alchemy.use('@babel/polyfill');
babel_preset = alchemy.use('@babel/preset-env');
babel_async = alchemy.use('@babel/plugin-transform-async-to-generator');
regenerator_runtime = alchemy.findModule('regenerator-runtime');
}
/**
* Get an array of paths, optionally replace text
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.3
* @version 0.2.3
*/
function getMiddlePaths(paths, ext, new_ext) {
if (Array.isArray(paths)) {
return paths.map(function eachEntry(entry) {
return getMiddlePaths(entry, ext, new_ext);
});
}
if (ext && new_ext) {
paths = paths.replace(ext, new_ext);
}
return paths;
}
/**
* Stylesheet middleware
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.0
*/
Alchemy.setMethod(function styleMiddleware(req, res, nextMiddleware) {
// Get the URL object
let url = req.conduit.url;
// Get the path, including the query
let path = url.path;
// If this file has already been found & compiled, serve it to the user
if (asset_cache.has(path)) {
return req.conduit.serveFile(asset_cache.get(path).path, {onError: function onError() {
// Unset asset
asset_cache.remove(path);
alchemy.styleMiddleware(req, res, nextMiddleware);
}});
}
let css_path = req.middlePath;
findStylesheet(css_path, {compile: true}).done((err, result) => {
if (err) {
return req.conduit.error(err);
}
let compiled_path = result?.compiled?.path;
if (!compiled_path) {
return nextMiddleware();
}
return req.conduit.serveFile(compiled_path, {
mimetype: 'text/css',
onError: function onError(err) {
if (fileCache[compiled_path]) {
fileCache[compiled_path] = null;
let source_path = result.source?.path;
if (stats && fileCache[source_path]) {
fileCache[source_path] = null;
}
alchemy.styleMiddleware(req, res, nextMiddleware);
}
}
});
});
});
/**
* Find a stylesheet
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.4.0
* @version 1.4.0
*
* @param {string} css_path Relative path to the CSS source file
* @param {Object} options
*
* @return {Pledge<Object>}
*/
function findStylesheet(css_path, options) {
let source_stats,
compiled,
warnings = [];
if (css_path[0] != '/') {
css_path = '/' + css_path;
}
if (!options) {
options = {};
}
let do_compile = options.compile === true;
if (!css_path.endsWith('.css')) {
return Pledge.reject(new Error('Not a .css file'));
}
return Function.series(function getCssPath(next) {
// Look for a regular .css file
alchemy.findAssetPath(css_path, styleDirs.getSorted(), function gotAssetPath(err, stats) {
if (err || !stats || !stats.path) {
return next();
}
source_stats = compiled = stats;
next();
});
}, function getLessPath(next) {
if (source_stats || !less) {
return next();
}
let less_path = getMiddlePaths(css_path, /\.css$/, '.less');
// Look through all the asset folders for the less style file
alchemy.findAssetPath(less_path, styleDirs.getSorted(), function gotAssetPath(err, stats) {
if (err || !stats || !stats.path) {
return next();
}
source_stats = stats;
if (!do_compile) {
return next();
}
// Compile this less file
alchemy.getCompiledLessPath(stats.path, options, function gotLessPath(err, cssInfo) {
if (err) {
warnings.push(err);
return next();
}
compiled = cssInfo;
next();
});
});
}, function getScssPath(next) {
if (source_stats || !sass) {
return next();
}
let scss_path = getMiddlePaths(css_path, /\.css$/, '.scss');
// Look through all the asset folders for the scss style file
alchemy.findAssetPath(scss_path, styleDirs.getSorted(), function gotAssetPath(err, stats) {
if (err || !stats || !stats.path) {
return next();
}
source_stats = stats;
if (!do_compile) {
return next();
}
// Compile this less file
alchemy.getCompiledSassPath(stats.path, options, function gotSassPath(err, cssInfo) {
if (err) {
warnings.push(err);
return next();
}
compiled = cssInfo;
next();
});
});
}, function done(err) {
if (err) {
throw err;
}
// If there are warnings, use the first one
if (warnings.length) {
throw warnings[0];
}
if (options?.post_css_only) {
return compiled;
}
if (do_compile && (!compiled || !compiled.path)) {
return false;
}
return {
compiled : compiled,
source : source_stats,
};
});
}
/**
* Sourcemap middleware:
* Serve the original source files
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.1.0
* @version 1.3.0
*/
Alchemy.setMethod(function sourcemapMiddleware(req, res, nextMiddleware) {
if (!alchemy.settings.debugging.debug) {
return nextMiddleware();
}
// Get the URL object
let url = req.conduit.url;
// Get the path, including the query
let path = url.path.after('_sourcemaps/');
if (path.indexOf('/stylesheets/') == -1) {
return nextMiddleware();
}
path = path.after('app/assets/stylesheets/');
if (!path) {
return nextMiddleware();
}
// Look for a regular .css file
alchemy.findAssetPath(path, styleDirs.getSorted(), function gotAssetPath(err, stats) {
if (err || !stats || !stats.path) {
return nextMiddleware();
}
req.conduit.serveFile(stats.path, {
mimetype: 'application/json',
});
});
});
/**
* Minify the given path, return a temp path
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.4.0
*
* @param {string} path
* @param {Object} options
* @param {Function} callback
*/
Alchemy.setMethod(function minifyScript(path, options, callback) {
if (typeof options == 'function') {
callback = options;
options = {};
}
// Leave early if minify_js is false en nodent is falsy
if (!alchemy.settings.frontend.javascript.minify && !options.add_async_support) {
return callback();
}
let cached = minify_map.get(path);
// @TODO: take options.add_async_support into account
if (cached) {
return callback(null, cached.path, cached);
}
fs.readFile(path, {encoding: 'utf8'}, function gotSource(err, data) {
if (err) {
return callback(err);
}
// Use nodent if wanted to add async/await support
if (options.add_async_support) {
// If babel is enabled, it means we need to support some really old browsers,
// so process it anyway!
if (babel) {
let plugins = [];
if (babel_polyfill) {
plugins.push(babel_polyfill);
}
if (babel_async) {
plugins.push(babel_async);
}
let babel_options = {
presets: [babel_preset],
plugins: plugins
};
// Babel sucks and its code keeps returning a regeneratorRuntime error
//data = applyNodent(data);
babel.transform(data, babel_options, function(err, result) {
if (err) {
return callback(err);
}
let code = result.code;
if (regenerator_runtime) {
let runtime_code = fs.readFileSync(regenerator_runtime.module_path, 'utf8');
code = runtime_code + code;
}
serveCode(code);
});
return;
} else if (Nodent && (data.indexOf('async function') > -1 || data.indexOf('await ') > -1)) {
data = applyNodent(data);
}
}
serveCode(data);
});
function applyNodent(data) {
if (nodent_compiler == null) {
nodent_compiler = new Nodent();
}
data = nodent_compiler.compile(data, '', {
sourcemap : false,
promises : true,
noRuntime : true
}).code;
return data;
}
async function serveCode(data) {
try {
await _serveCode(data);
} catch (err) {
return callback(err);
}
}
async function _serveCode(data) {
let result;
let should_minify = false;
if (alchemy.settings.frontend.javascript.minify) {
let index = data.indexOf('@alchemy.minify.false');
if (index == -1 || index > 50) {
should_minify = true;
}
}
if (should_minify && typeof Terser?.minify == 'function') {
// Force Blast.isNode & Blast.isBrowser to be replaced later
data = data.replaceAll('Blast.isServer', '__BLAST_IS_SERVER');
data = data.replaceAll('Blast.isNode', '__BLAST_IS_NODE');
data = data.replaceAll('Blast.isBrowser', '__BLAST_IS_BROWSER');
let env_info = Blast.parseEnvironmentName(alchemy.environment);
data = data.replaceAll('Blast.environment', '__BLAST_IS_ENVIRONMENT');
data = data.replaceAll('Blast.isProduction', '__BLAST_IS_PRODUCTION');
data = data.replaceAll('Blast.isDevelopment', '__BLAST_IS_DEVELOPMENT');
data = data.replaceAll('Blast.isStaging', '__BLAST_IS_STAGING');
let minify_options = {
compress: {
// Keep all function arguments
keep_fargs : true,
// Keep function names
keep_fnames : true,
// Keep classnames
keep_classnames : true,
// Do not hoist function declarations
hoist_funs : false,
// Only drop console calls when not debugging
drop_console : !alchemy.settings.debugging.debug,
// Remove dead code
dead_code : true,
// Remove symbol names
unsafe_symbols : true,
// Provide it some info on global definitions
global_defs : {
'__BLAST_IS_NODE' : false,
'__BLAST_IS_SERVER' : false,
'__BLAST_IS_BROWSER' : true,
'__BLAST_IS_ENVIRONMENT' : alchemy.environment,
'__BLAST_IS_PRODUCTION' : env_info.is_production,
'__BLAST_IS_DEVELOPMENT' : env_info.is_development,
'__BLAST_IS_STAGING' : env_info.is_staging,
}
},
mangle: {
keep_fnames : true,
keep_classnames : true,
// We have to prevent `Blast` variable names from being
// mangled, or our replacement trickery will break things
reserved : ['Blast'],
},
output: {
// Do not wrap functions that are arguments in parenthesis
wrap_func_args : false,
},
};
if (alchemy.settings.debugging.debug && alchemy.settings.debugging.create_source_map) {
console.warn('Source maps have been disabled because alchemy.settings.frontend.javascript.minify is true');
/*minify_options.sourceMap = {
url: 'inline'
};*/
}
result = await Terser.minify(data, minify_options);
if (!result) {
return callback(new Error('Unknown minifier error'));
}
if (result.error) {
return callback(result.error);
}
if (!result.code && data.length) {
return callback(new Error('Failed to minify javascript'));
}
if (result.code.indexOf('__BLAST_IS_') > -1) {
// Restore some instances in case these weren't removed by Terser
// (Maybe because they're part of another variable or a string)
result = result.code.replaceAll('__BLAST_IS_SERVER', 'Blast.isServer');
result = result.replaceAll('__BLAST_IS_NODE', 'Blast.isNode');
result = result.replaceAll('__BLAST_IS_BROWSER', 'Blast.isBrowser');
result = result.replaceAll('__BLAST_IS_ENVIRONMENT', 'Blast.environment');
result = result.replaceAll('__BLAST_IS_PRODUCTION', 'Blast.isProduction');
result = result.replaceAll('__BLAST_IS_DEVELOPMENT', 'Blast.isDevelopment');
result = result.replaceAll('__BLAST_IS_STAGING', 'Blast.isStaging');
} else {
result = result.code;
}
} else {
result = data;
}
// Open a temp js file
Blast.openTempFile({suffix: '.js'}, function getTempFile(err, info) {
var js_buffer;
if (err) {
return callback(err);
}
if (!info || !info.fd) {
return callback(new Error('Could not find file'));
}
js_buffer = Buffer.from(result);
// Write the css
fs.write(info.fd, js_buffer, 0, js_buffer.length, null, function afterWrite() {
// Close the file
fs.close(info.fd, function closedFile(err) {
if (err) {
return callback(err);
}
minify_map.set(path, info);
// Callback with the path and the full info object
// of the temporary file
callback(null, info.path, info);
});
});
});
}
});
/**
* Script middleware
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.0
*/
Alchemy.setMethod(function scriptMiddleware(req, res, nextMiddleware) {
var miniSource,
source;
Function.series(function getPath(next) {
if (fileCache[req.url]) {
miniSource = fileCache[req.url];
return next();
}
if (asset_cache.has(req.url)) {
source = asset_cache.get(req.url);
return next();
}
alchemy.findAssetPath(req.middlePath, scriptDirs.getSorted(), function gotAssetPath(err, stats) {
// @todo: error stuff, 404
asset_cache.set(req.url, stats);
source = stats;
next();
});
}, function minify(next) {
// If this has already been minified, continue to the serving phase
if (miniSource) {
source = miniSource;
return next();
}
// If no file was found, do nothing
if (!source || !source.path) {
return nextMiddleware();
}
let options = {};
if (req.conduit && req.conduit.supports('async') === false) {
options.add_async_support = true;
}
alchemy.minifyScript(source.path, options, function gotMinifiedFile(err, path, info) {
if (err || !info || !info.fd) {
return next();
}
fileCache[req.url] = info;
source = info;
next();
});
}, function done(err) {
if (err) {
return nextMiddleware();
}
req.conduit.serveFile(source.path, {
mimetype: 'text/javascript',
onError: function onError() {
// Unset asset
if (fileCache[req.url]) {
fileCache[req.url] = null;
}
if (asset_cache.has(req.url)) {
asset_cache.remove(req.url);
}
if (source && minify_map.has(source.path)) {
minify_map.delete(source.path);
}
alchemy.scriptMiddleware(req, res, nextMiddleware);
}
});
});
});
/**
* Font middleware
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.1.3
* @version 1.1.3
*/
Alchemy.setMethod(function fontMiddleware(req, res, nextMiddleware) {
let directories = fontDirs.getSorted();
return alchemy.assetInDirsMiddleware(req, res, directories, nextMiddleware);
});
/**
* Public files middleware
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.1.0
*/
Alchemy.setMethod(function publicMiddleware(req, res, nextMiddleware) {
let directories = publicDirs.getSorted();
return alchemy.assetInDirsMiddleware(req, res, directories, nextMiddleware);
});
/**
* Generic asset middleware
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.1.3
* @version 1.1.3
*/
Alchemy.setMethod(function assetInDirsMiddleware(req, res, directories, nextMiddleware) {
var source;
Function.series(function getPath(next) {
if (asset_cache.has(req.url)) {
source = asset_cache.get(req.url);
return next();
}
alchemy.findAssetPath(req.middlePath, directories, function gotAssetPath(err, stats) {
// @todo: error stuff, 404
asset_cache.set(req.url, stats);
source = stats;
next();
});
}, function done() {
if (!source || ! source.path) {
return nextMiddleware();
}
req.conduit.serveFile(source.path, {
filename: libpath.basename(source.path)
});
});
});
/**
* Root files middleware
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.0
*/
Alchemy.setMethod(function rootMiddleware(req, res, nextMiddleware) {
var source,
cached;
Function.series(function getPath(next) {
if (asset_cache.has(req.url)) {
source = asset_cache.get(req.url);
cached = true;
return next();
}
alchemy.findAssetPath(req.middlePath, rootDirs.getSorted(), function gotAssetPath(err, stats) {
// @todo: error stuff, 404
if (stats?.path) {
asset_cache.set(req.url, stats);
}
source = stats;
next();
});
}, function done() {
if (!source || ! source.path) {
return nextMiddleware();
}
req.conduit.serveFile(source.path, {
filename: libpath.basename(source.path),
onError: function onError() {
if (cached) {
asset_cache.remove(req.url);
alchemy.rootMiddleware(req, res, nextMiddleware);
return;
}
nextMiddleware();
}});
});
});
/**
* Look for assetFile in the given directories
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.3.0
*
* @param {string} image_path
* @param {Function} callback
*
* @return {Pledge}
*/
Alchemy.setMethod(function findImagePath(image_path, callback) {
let pledge = new Pledge();
pledge.done(callback);
if (!image_path) {
pledge.reject(new Error('Can not find image for empty path'));
} else {
let id = 'image-' + image_path;
if (alchemy.settings.performance.cache && asset_cache.has(id)) {
pledge.resolve(asset_cache.get(id).path);
} else {
alchemy.findAssetPath(image_path, imageDirs.getSorted(), function gotImagePath(err, stats) {
if (err) {
pledge.reject(err);
} else {
asset_cache.set(id, stats);
pledge.resolve(stats.path);
}
});
}
}
return pledge;
});
/**
* Look for assetFile in the given directories
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 0.4.2
*
* @param {string} assetFile The full filename to look for
* @param {Deck|string} directories The directories to look in (or subdir of "assets")
* @param {Function} callback
*/
Alchemy.setMethod(function findAssetPath(assetFile, directories, callback) {
let found_asset_dir,
sub_path,
found,
iter;
if (Array.isArray(assetFile)) {
Function.forEach(assetFile, function checkFile(file, index, next) {
alchemy.findAssetPath(file, directories, function checkedFile(err, found) {
if (err) {
return next(err);
}
if (found && found.path) {
return callback(null, found);
}
return next();
});
}, function done(err) {
if (err) {
return callback(err);
}
// If there was no err, but we got this far,
// then nothing was found.
callback(null, {});
});
return;
}
if (typeof assetFile !== 'string') {
return callback(new Error('File to look for is not a valid string'));
}
if (typeof directories == 'string') {
sub_path = directories;
directories = assetDirs.getSorted();
}
iter = new Iterator(directories);
// Normalize the assetFile:
// resolve .. now to prevent path traversal attacks
assetFile = libpath.normalize(assetFile);
// If the assetFile begins with a slash, remove it!
// (Multiple slashes will have already been removed by normalize)
if (assetFile[0] == '/') {
assetFile = assetFile.slice(1);
}
Function.while(function test() {
return !found && iter.hasNext();
}, function task(next) {
var dir = iter.next().value,
path;
if (sub_path) {
path = libpath.resolve(dir, sub_path, assetFile);
} else {
path = libpath.resolve(dir, assetFile);
}
// Remove query strings from the requested file
path = path.split('?')[0];
Function.series(function checkOriginal(nextc) {
// Look for the original file first
fs.stat(path, function gotFileStats(err, stats) {
if (found) {
return nextc();
}
if (err == null && stats.isFile()) {
stats.path = path;
found = stats;
found_asset_dir = dir;
}
nextc();
});
}, function checkCss(nextc) {
var cpath;
// If it's not a less file, don't look for a css file
if (found || !path.endsWith('.less')) {
return nextc();
}
cpath = path.replace(/\.less$/, '.css');
fs.stat(cpath, function gotFileStats(err, stats) {
if (err == null && stats.isFile && stats.isFile()) {
stats.servepath = cpath;
stats.path = cpath;
found = stats;
found_asset_dir = dir;
}
nextc();
});
}, function done() {
return next();
});
}, function lastly() {
// Return an empty object when nothing was found,
// so we'll cache that in the asset_cache
// Otherwise it'll keep checking for every request
if (found == null) {
found = {};
}
if (found_asset_dir) {
found.relative_path = libpath.relative(found_asset_dir, found.path);
}
callback(null, found);
});
});
/**
* Apply postCss
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.5.0
* @version 1.4.0
*
* @param {string} css
* @param {Object} options
* @param {Function} callback
*/
function doPostCss(css, options, callback) {
let postcss_config = [
autoprefixer
];
if (options.prune !== false) {
postcss_config.push(postcss_prune);
}
postcss(postcss_config).process(css, {from: undefined}).then(function gotCssResult(result) {
if (options?.post_css_only) {
return callback(null, result);
}
callback(null, result.css);
}).catch(function gotError(err) {
callback(err);
});
}
/**
* Callback with the compiled CSS filepath
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.0.0
*/
Alchemy.setMethod(function getCompiledLessPath(lessPath, options, callback) {
let check_cache = !options?.post_css_only && options?.cache !== false && alchemy.settings.performance.cache;
// If it has been compiled before, return that
// @todo: check timestamps on dev. Not-founds are not cached
if (check_cache && fileCache[lessPath]) {
return setImmediate(() => callback(null, fileCache[lessPath]));
}
if (!lessPath || typeof lessPath != 'string') {
return setImmediate(() => callback(new Error('Invalid path given')));
}
fs.readFile(lessPath, {encoding: 'utf8'}, function gotLessCode(err, source) {
var render_options,
import_paths;
if (err) {
return callback(err);
}
import_paths = ['.'].concat(styleDirs.getSorted()).concat(libpath.dirname(lessPath));
if (alchemy.settings.less_import_paths) {
import_paths = import_paths.concat(alchemy.settings.less_import_paths);
}
render_options = {
paths: import_paths,
compress: alchemy.settings.frontend.stylesheet.minify,
};
// @todo: source maps. Compression. Everything
less.render(source, render_options, function gotCss(err, cssTree) {
var css;
if (err != null) {
console.log(''+err, err);
return callback(err);
}
if (cssTree == null) {
return callback(new Error('No CSS tree was made for ' + lessPath));
}
// @todo: add minify options
css = cssTree.css;
if (options.post_css_only) {
return doPostCss(css, options, callback);
}
Blast.openTempFile({suffix: '.css'}, function getTempFile(err, info) {
var cssBuffer;
if (err) {
return callback(err);
}
if (!info || !info.fd) {
return callback(new Error('Problem opening target css file'));
}
if (!css && source.length) {
return callback(new Error('LESS file failed to compile'));
}
doPostCss(css, options, function gotResult(err, css) {
if (err) {
return callback(err);
}
cssBuffer = Buffer.from(css);
// Write the css
fs.write(info.fd, cssBuffer, 0, cssBuffer.length, null, function afterWrite() {
// Close the file
fs.close(info.fd, function closedFile(err) {
if (err) {
return callback(err);
}
fileCache[lessPath] = info;
callback(null, info);
});
});
});
});
});
});
});
/**
* Create the alchemy.scss content
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.4.0
* @version 1.4.0
*/
async function createAlchemyScss() {
let contents = '';
for (let css_name of required_stylesheets) {
if (!css_name.endsWith('.scss') && !css_name.endsWith('.css') && !css_name.endsWith('.less')) {
css_name += '.css';
}
let result = await findStylesheet(css_name);
if (!result?.source?.relative_path) {
throw new Error('Could not find ' + css_name);
}
contents += '@use "' + result.source.relative_path + '";\n';
}
return {contents};
}
/**
* Custom SCSS import logic
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.4.0
* @version 1.4.0
*
* @param {string} path_to_import The requested path to import
* @param {string} current_file_path The path to the current file
*/
async function customScssImporter(path_to_import, current_file_path) {
// The alchemy.scss file is a special case
if (path_to_import === 'alchemy.scss' || path_to_import === 'alchemy') {
return createAlchemyScss();
}
// Get the current directory this SCSS file is in
let current_dir = libpath.dirname(current_file_path);
// Get the dirname to import
let dir_to_import = libpath.dirname(path_to_import);
// Get the filename
let filename_to_import = libpath.basename(path_to_import);
// Get all the style dirs
let style_dirs = styleDirs.getSorted();
// Get the source style dir (the one this file is in)
let source_style_dir;
for (let style_dir of style_dirs) {
if (current_dir.startsWith(style_dir)) {
source_style_dir = style_dir;
break;
}
}
let include_paths = [
current_dir,
...style_dirs,
];
// If the dir this file was in is from one of the allowed style dirs,
// we have to add possible overrides to the include paths
if (source_style_dir) {
let extra_path = current_dir.after(source_style_dir);
if (extra_path.length > 2) {
extra_path = libpath.resolve('/', extra_path);
let new_path = libpath.join(PATH_APP, 'assets', 'stylesheets', extra_path);
include_paths.unshift(new_path);
}
}
for (let include_path of include_paths) {
let test_path = libpath.join(include_path, path_to_import);
let file = new Classes.Alchemy.Inode.File(test_path);
if (await file.exists()) {
return {file: test_path};
}
}
// Look for files starting with an underscore
if (filename_to_import[0] != '_') {
let dashed_filename_to_import = '_' + filename_to_import;
return customScssImporter(libpath.join(dir_to_import, dashed_filename_to_import), current_file_path);
}
let extension = libpath.extname(filename_to_import);
// If no extension was given, look for that too
if (!extension) {
filename_to_import += '.scss';
return customScssImporter(libpath.join(dir_to_import, filename_to_import), current_file_path);
}
if (path_to_import.startsWith('/overrides/')) {
return {contents: '// Failed to find ' + path_to_import};
}
throw new Error('SCSS file not found');
};
function logSassWarning(message, options) {
console.warn('Sass warning:', message, options);
}
function logSassDebug(message, options) {
console.log('Sass debug:', message, options);
}
/**
* Callback with the compiled CSS filepath
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 0.2.0
* @version 1.4.0
*/
Alchemy.setMethod(function getCompiledSassPath(sassPath, options, callback) {
let check_cache = !options?.post_css_only && options?.cache !== false && alchemy.settings.performance.cache;
// If it has been compiled before, return that
// @todo: check timestamps on dev. Not-founds are not cached
if (check_cache && fileCache[sassPath]) {
return setImmediate(function lessCache(){callback(null, fileCache[sassPath])});
}
if (!sassPath || typeof sassPath != 'string') {
return setImmediate(function errCb(){callback(new Error('Invalid path given'))});
}
fs.readFile(sassPath, {encoding: 'utf8'}, function gotLessCode(err, source) {
if (err) {
return callback(err);
}
if (!source) {
return writeToTemp(source);
}
let custom_functions = sass_functions;
if (options.add_exports) {
custom_functions = {
...custom_functions,
'should_add_exports()': function() {
return sass.types.Boolean.TRUE;
},
};
}
const render_options = {
includePaths : styleDirs.getSorted(),
functions : custom_functions,
importer : customScssImporter,
logger : {
warn : logSassWarning,
debug : logSassDebug,
},
};
if (alchemy.settings.debugging.debug) {
render_options.file = sassPath;
render_options.sourceMap = 'out.css.map';
render_options.sourceMapContents = true;
render_options.sourceMapEmbed = true;
render_options.sourceMapRoot = '/_sourcemaps/';
} else {
render_options.data = source;
}
sass.render(render_options, function gotCss(err, result) {
if (err) {
console.log(''+err, err);
return callback(err);
}
writeToTemp(result.css);
});
function writeToTemp(content) {
if (options?.post_css_only) {
return doPostCss(content, options, callback);
}
Blast.openTempFile({suffix: '.css'}, function getTempFile(err, info) {
if (err) {
return callback(err);
}
if (!info || !info.fd) {
return callback(new Error('Problem opening target css file'));
}
if (!content) {
return writeToFile(info, content);
}
doPostCss(content, options, function gotPostCss(err, css) {
if (err) {
return callback(err);
}
writeToFile(info, css);
});
});
}
function writeToFile(file, content) {
let buffer = Buffer.from(content);
// Write the css
fs.write(file.fd, buffer, 0, buffer.length, null, function afterWrite() {
// Close the file
fs.close(file.fd, function closedFile(err) {
if (err) {
return callback(err);
}
fileCache[sassPath] = file;
callback(null, file);
});
});
}
});
});
/**
* Register a style that should always be included in the output
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.4.0
* @version 1.4.0
*
* @param {string} css_path Could be a path, could be a name
*/
Alchemy.setMethod(function registerRequiredStylesheet(css_path) {
required_stylesheets.add(css_path);
});
/**
* Extract CSS exports from a PostCSS result object
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.4.0
* @version 1.4.0
*
* @param {Object} postcss_result
*/
function extractPostCSSExports(postcss_result) {
const icss_import_rx = /^:import\(("[^"]*"|'[^']*'|[^"']+)\)$/;
const icss_balanced_quotes = /^("[^"]*"|'[^']*'|[^"']+)$/;
const remove_rules = true;
const mode = 'auto';
const getDeclsObject = (rule) => {
const object = {};
rule.walkDecls((decl) => {
const before = decl.raws.before ? decl.raws.before.trim() : "";
object[before + decl.prop] = decl.value;
});
return object;
};
const icss_imports = {};
const icss_exports = {};
function addImports(node, path) {
const unquoted = path.replace(/'|"/g, "");
icss_imports[unquoted] = Object.assign(
icss_imports[unquoted] || {},
getDeclsObject(node)
);
if (remove_rules) {
node.remove();
}
}
function addExports(node) {
Object.assign(icss_exports, getDeclsObject(node));
if (remove_rules) {
node.remove();
}
}
postcss_result.root.each((node) => {
if (node.type === 'rule' && mode !== 'at-rule') {
if (node.selector.slice(0, 7) === ':import') {
const matches = icss_import_rx.exec(node.selector);
if (matches) {
addImports(node, matches[1]);
}
}
if (node.selector === ':export') {
addExports(node);
}
}
if (node.type === 'atrule' && mode !== 'rule') {
if (node.name === 'icss-import') {
const matches = icss_balanced_quotes.exec(node.params);
if (matches) {
addImports(node, matches[1]);
}
}
if (node.name === 'icss-export') {
addExports(node);
}
}
});
return {
imports : icss_imports,
exports : icss_exports,
};
}
/**
* Extract CSS exports from a CSS file path
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.4.0
* @version 1.4.0
*
* @param {string} css_path
*/
Alchemy.setMethod(async function extractCSSExports(css_path) {
let result = await findStylesheet(css_path, {
post_css_only: true,
add_exports: true,
prune: false,
compile: true
});
result = extractPostCSSExports(result);
return result?.exports;
});