254 lines
6.8 KiB
JavaScript
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;
|