bifocal/node_modules/gscan/lib/ast-linter/rules/internal/scope.js

254 lines
6.8 KiB
JavaScript

const {getNodeName} = require('../../helpers');
const _ = require('lodash');
// TODO: the allowlist should include all properties of these top-level globals
const ghostGlobals = {
site: true,
member: true,
setting: true, // TODO: we should remove this but the Journal theme is using it atm
config: true,
labs: true,
custom: true,
page: true
};
// unless we update AST to check that we're within a foreach block, we need to allowlist all of these as they look the same as globals
const dataVars = {
index: true,
number: true,
key: true,
first: true,
last: true,
odd: true,
even: true,
rowStart: true,
rowEnd: true
};
// unless we move from lodash to a glob match, we just need to handle all custom since we can't allowlist those
function isOnAllowlist(parts) {
const variable = parts && parts[0];
return ghostGlobals[variable] || dataVars[variable] || false;
}
// true = property exists
// 'context' = has context's shape
// ['context] = array of context's shape
// TODO: use JSON schema?
const contexts = {
post: {
// attrs
id: true,
primary_author: 'author',
authors: ['author'],
primary_tag: 'tag',
tags: ['tag']
// data helpers
},
page: {},
author: {},
tag: {},
pagination: {}
};
const helpers = { // eslint-disable-line no-unused-vars
// {{get}} can return multiple different contexts depending on params
get(node) {
const type = node.params[0].value.replace(/s$/, '');
// ignore unknown types - the invalid usage should be picked up by a rule
if (!contexts[type]) {
return;
}
const isSingular = node.hash.pairs.any(pair => ['id', 'slug'].includes(pair.key));
const typeContext = isSingular ? contexts[type] : [contexts[type]];
return {
locals: {
pagination: contexts.pagination
},
blockParams: [
typeContext,
contexts.pagination
]
};
},
next_post() {
return {
context: 'post',
locals: contexts.post,
// TODO, do we need to handle `{{#next_post as |myPost|}}`?
blockParams: []
};
},
prev_post() {
return {
context: 'post',
locals: contexts.post,
// TODO, do we need to handle `{{#prev_post as |myPost|}}`?
blockParams: []
};
}
};
function getTemplateContext(fileName) {
if (fileName.match(/^post(-.*)?\.hbs/)) {
return {post: contexts.post};
}
if (fileName.match(/^page(-.*)?\.hbs/)) {
return {page: contexts.page};
}
if (fileName.match(/^custom-.*\.hbs/)) {
// we can't know for sure if this is a post or page so attach both
return {
post: contexts.post,
page: contexts.page
};
}
// default
// could be anything? dynamic routing allows any template to be specified
// along with custom data names
// TODO: how to handle this case?
}
// TODO: use our knowledge of Ghost's helpers and JSON schemas to fully populate
// the locals array so we can detect incorrect usage
function getContext(node) {
if (node.type !== 'BlockStatement') {
throw new Error(`${node.type} cannot be used to generate a context`);
}
return {
context: 'str',
locals: {},
blockParams: {}
};
}
// NOTE:
// need to determine if the BlockStatement is referencing a helper or a local
// if it's a local then we need to create a frame using the local's contents
class Frame {
constructor(node, options = {}) {
this.node = node;
this.nodeName = getNodeName(node);
// Program statements are only used to create frames for template-level
// contexts, otherwise we always create frames with BlockStatements
if (node.type === 'Program') {
if (!options.fileName) {
throw new Error('fileName must be passed as an option when constructing a template-level Frame');
}
const {context, locals} = getTemplateContext(options.fileName);
this.context = context;
this.locals = locals; // eg. {post: {...postContext}}
return;
}
if (node.type !== 'BlockStatement') {
throw new Error(`${node.type} cannot be used to construct a Frame`);
}
// block-level context
const {context, locals, blockParams} = getContext(node);
this.context = context;
this.locals = locals;
// TODO: are blockParams just locals in this case?
this.blockParams = blockParams;
}
isLocal(name) {
return _.get(this.context, name);
}
}
class Scope {
constructor({templateFileName} = {}) {
this.frames = [];
if (templateFileName) {
this.frames.push(new Frame);
}
}
get currentFrame() {
return this.frames[this.frames.length - 1];
}
pushTemplateFrame(fileName, node) {
this.frames.push(new Frame(node, {fileName}));
}
pushFrame(node) {
this.frames.push(new Frame(node));
}
popFrame() {
this.frames.pop();
}
isContext(context) {
return this.currentFrame && this.currentFrame.context === context || false;
}
hasParentContext(context) {
let found = false;
if (this.frames && this.frames.length) {
this.frames.forEach((frame) => {
if (frame.nodeName === context) {
found = true;
}
});
}
return found;
}
getParentContextNode(context) {
let matchedFrame = null;
if (this.frames && this.frames.length) {
matchedFrame = this.frames.find((frame) => {
if (frame.nodeName === context) {
return true;
}
});
}
return matchedFrame && matchedFrame.node;
}
isKnownVariable(node) {
// @foo statements are referencing globals rather than locals
// and can be detected with the data: true attribute (???)
// they can be direct [Mustache] statements {{@foo}}...
if (node.type === 'MustacheStatement' && node.path.data) {
return isOnAllowlist(node.path.parts);
}
// ... or indirect using helpers, e.g. {{#match @foo.bar}}{{/match}}
if (node.type === 'PathExpression') {
return isOnAllowlist(node.parts);
}
let name = getNodeName(node);
// use node.path.depth to skip leaf frames, equates to {{../foo}}
for (let i = this.frames.length - 1 - node.path.depth; i >= 0; i--) {
if (this.frames[i].isLocal(name)) {
return true;
}
}
}
}
module.exports = Scope;