275 lines
8.8 KiB
JavaScript
275 lines
8.8 KiB
JavaScript
const fs = require('fs-extra');
|
|
const _ = require('lodash');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const ASTLinter = require('./ast-linter');
|
|
const {normalizePath} = require('./utils');
|
|
|
|
const ignore = [
|
|
'node_modules',
|
|
'bower_components',
|
|
'.DS_Store',
|
|
'.git',
|
|
'.svn',
|
|
'Thumbs.db',
|
|
'.yarn-cache'
|
|
];
|
|
|
|
const linter = new ASTLinter({
|
|
partials: [],
|
|
helpers: []
|
|
});
|
|
|
|
const readThemeStructure = function readThemeFiles(themePath, subPath, arr) {
|
|
themePath = path.join(themePath, '.');
|
|
subPath = subPath || '';
|
|
|
|
var tmpPath = os.tmpdir(),
|
|
inTmp = themePath.substr(0, tmpPath.length) === tmpPath;
|
|
|
|
arr = arr || [];
|
|
|
|
var makeResult = function makeResult(result, subFilePath, ext, symlink) {
|
|
result.push({
|
|
file: subFilePath,
|
|
normalizedFile: normalizePath(subFilePath),
|
|
ext,
|
|
symlink
|
|
});
|
|
return result;
|
|
};
|
|
|
|
return fs.readdir(themePath, {withFileTypes: true}).then(function (files) {
|
|
let result = arr;
|
|
|
|
return files.reduce(function (promise, dirent) {
|
|
return promise.then(function () {
|
|
const file = dirent.name;
|
|
const extMatch = file.match(/.*?(\.[0-9a-z]+$)/i);
|
|
const subFilePath = path.join(subPath, file);
|
|
const newPath = path.join(themePath, file);
|
|
|
|
/**
|
|
* don't process ignored paths, remove target file
|
|
*
|
|
* @TODO:
|
|
* - gscan extracts the target zip into a tmp directory
|
|
* - if you use gscan with `keepExtractedDir` the caller (Ghost) will use the tmp folder with the deleted ignore files
|
|
* - what we don't support right now is to delete the ignore files from the zip
|
|
*/
|
|
if (ignore.indexOf(file) > -1) {
|
|
return inTmp
|
|
? fs.remove(newPath)
|
|
.then(function () {
|
|
return result;
|
|
})
|
|
: Promise.resolve(result);
|
|
}
|
|
|
|
if (dirent.isDirectory()) {
|
|
return readThemeStructure(newPath, subFilePath, result)
|
|
.then((updatedResult) => {
|
|
result = updatedResult;
|
|
});
|
|
} else {
|
|
result = makeResult(result, subFilePath, extMatch !== null ? extMatch[1] : undefined, dirent.isSymbolicLink());
|
|
return Promise.resolve();
|
|
}
|
|
});
|
|
}, Promise.resolve()).then(() => result);
|
|
});
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @param {Theme} theme
|
|
* @returns {Promise<Theme>}
|
|
*/
|
|
const readFiles = function readFiles(theme) {
|
|
const themeFilesContent = _.filter(theme.files, function (themeFile) {
|
|
if (themeFile && themeFile.ext) {
|
|
return themeFile.ext.match(/\.hbs|\.css|\.js/ig) || themeFile.file.match(/package.json/i);
|
|
}
|
|
});
|
|
|
|
// Setup a partials array
|
|
theme.partials = [];
|
|
|
|
// Setup the helper object
|
|
theme.helpers = {};
|
|
|
|
// CASE: we need the actual content of all css, hbs files, and package.json for our checks
|
|
return Promise.all(themeFilesContent.map((themeFile) => {
|
|
return fs.readFile(path.join(theme.path, themeFile.file), 'utf8').then(function (content) {
|
|
themeFile.content = content;
|
|
|
|
if (!theme.customSettings) {
|
|
theme.customSettings = {};
|
|
}
|
|
|
|
const packageJsonMatch = themeFile.file === 'package.json';
|
|
if (packageJsonMatch) {
|
|
try {
|
|
const packageJson = JSON.parse(themeFile.content);
|
|
if (packageJson.config && packageJson.config.custom) {
|
|
theme.customSettings = packageJson.config.custom;
|
|
}
|
|
} catch (e) {
|
|
// Ignore error as they will be caught in 010-package-json.js
|
|
}
|
|
}
|
|
|
|
const partialMatch = themeFile.file.match(/^partials[/\\]+(.*)\.hbs$/);
|
|
if (partialMatch) {
|
|
theme.partials.push(partialMatch[1]);
|
|
}
|
|
|
|
const handlebarsMatch = themeFile.file.match(/\.hbs$/);
|
|
if (handlebarsMatch) {
|
|
themeFile.parsed = ASTLinter.parse(themeFile.content, themeFile.file);
|
|
processHelpers(theme, themeFile);
|
|
}
|
|
});
|
|
})).then(() => theme);
|
|
};
|
|
|
|
const processHelpers = function (theme, themeFile) {
|
|
linter.verify({
|
|
parsed: themeFile.parsed,
|
|
rules: [
|
|
require('./ast-linter/rules/mark-used-helpers')
|
|
],
|
|
source: themeFile.content,
|
|
moduleId: themeFile.file
|
|
});
|
|
for (const helper of linter.helpers) {
|
|
if (!theme.helpers[helper.name]) {
|
|
theme.helpers[helper.name] = [];
|
|
}
|
|
theme.helpers[helper.name].push(themeFile.file);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Works only for posts, pages and custom templates at the moment.
|
|
*
|
|
* @TODO:
|
|
* This fn was added for the custom post template feature https://github.com/TryGhost/Ghost/issues/9060.
|
|
* We've decided to extract custom templates in GScan for now, because the read-theme helper already knows which
|
|
* hbs files are part of a theme.
|
|
*
|
|
* As soon as we have another use case e.g. we would like to allow to parse a custom template header with frontmatter,
|
|
* then we need to know which template is custom, which is not. Also, it could be that
|
|
* this function is outsourced in the future, so it can be used by GScan and Ghost. But for now, we don't pre-optimise.
|
|
*/
|
|
const extractCustomTemplates = function extractCustomTemplates(allTemplates) {
|
|
var toReturn = [],
|
|
generateName = function generateName(templateName) {
|
|
var name = templateName;
|
|
|
|
name = name.replace(/^(post-|page-|custom-)/, '');
|
|
name = name.replace(/-/g, ' ');
|
|
name = name.replace(/\b\w/g, function (letter) {
|
|
return letter.toUpperCase();
|
|
});
|
|
|
|
return name.trim();
|
|
},
|
|
generateFor = function (templateName) {
|
|
if (templateName.match(/^page-/)) {
|
|
return ['page'];
|
|
}
|
|
|
|
if (templateName.match(/^post-/)) {
|
|
return ['post'];
|
|
}
|
|
|
|
return ['page', 'post'];
|
|
},
|
|
generateSlug = function (templateName) {
|
|
if (templateName.match(/^custom-/)) {
|
|
return null;
|
|
}
|
|
|
|
return templateName.match(/^(page-|post-)(.*)/)[2];
|
|
};
|
|
|
|
_.each(allTemplates, function (templateName) {
|
|
if (templateName.match(/^(post-|page-|custom-)/) && !templateName.match(/\//)) {
|
|
toReturn.push({
|
|
filename: templateName,
|
|
name: generateName(templateName),
|
|
for: generateFor(templateName),
|
|
slug: generateSlug(templateName)
|
|
});
|
|
}
|
|
});
|
|
|
|
return toReturn;
|
|
};
|
|
|
|
/**
|
|
* Extracts from all theme files the .hbs files.
|
|
*/
|
|
const extractTemplates = function extractTemplates(allFiles) {
|
|
return _.reduce(allFiles, function (templates, entry) {
|
|
// CASE: partials are added to `theme.partials`
|
|
if (entry.file.match(/^partials[/\\]+(.*)\.hbs$/)) {
|
|
return templates;
|
|
}
|
|
|
|
// CASE: we ignore any hbs files in assets/
|
|
if (entry.file.match(/^assets[/\\]+(.*)\.hbs$/)) {
|
|
return templates;
|
|
}
|
|
|
|
var tplMatch = entry.file.match(/(.*)\.hbs$/);
|
|
if (tplMatch) {
|
|
templates.push(tplMatch[1]);
|
|
}
|
|
return templates;
|
|
}, []);
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @param {string} themePath - path to the validated theme
|
|
* @returns {Promise<Theme>}
|
|
*/
|
|
module.exports = function readTheme(themePath) {
|
|
return readThemeStructure(themePath)
|
|
.then(function (themeFiles) {
|
|
var allTemplates = extractTemplates(themeFiles);
|
|
|
|
return readFiles({
|
|
path: themePath,
|
|
files: themeFiles,
|
|
templates: {
|
|
all: allTemplates,
|
|
custom: extractCustomTemplates(allTemplates)
|
|
},
|
|
// @TODO: there's no good reason to mix Object and Array formats.
|
|
// They should be unified and use the one that suits best.
|
|
results: {
|
|
pass: [],
|
|
fail: {}
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @typedef {Object} Theme
|
|
* @param {string} path
|
|
* @param {string[]} files
|
|
* @param {Object} templates
|
|
* @param {Object[]} templates.all
|
|
* @param {Object[]} templates.custom
|
|
* @param {string[]} [partials]
|
|
* @param {Object} helpers
|
|
* @param {Object} results
|
|
* @param {Object[]} results.pass
|
|
* @param {Object} results.fail
|
|
* @param {Object=} customSettings
|
|
*/
|