@swell/cli
Version:
Swell's command line interface/utility
298 lines (297 loc) • 9.83 kB
JavaScript
import { getEncoding } from 'istextorbinary';
import isEmpty from 'lodash/isEmpty.js';
import { detectFilenameMime } from 'mime-detect';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { bundleFunction } from '../bundle.js';
import { ConfigPaths, ConfigType, filePathExists, hashFile, } from './index.js';
export class IgnoringFileError extends Error {
constructor(message) {
super(message);
this.name = 'IgnoringFileError';
}
}
export class FunctionProcessingError extends Error {
originalError;
constructor(message, error) {
super(message);
this.name = 'FunctionProcessingError';
this.originalError = error;
}
}
/**
* An app configuration as defined by the Swell API. This implies that the
* app & the configuration are saved to the API.
*/
export class AppConfig {
// Used when pushing a theme and the app config is considered
appConfigId;
// Full local path of remote AppConfig
appPath;
// Size of local file in bytes and mb
byteSize;
date_created;
date_updated;
file;
file_path;
// UTF8 content of local file
fileContent;
// Buffer of local file data
fileData;
fileHash;
// Relative path of remote AppConfig, same as file_path if defined
filePath;
hash;
// Server-side props
id;
mbSize;
name;
parent_id;
slug;
values = {};
version;
constructor(attrs = {}) {
Object.assign(this, attrs);
let { appPath, file_path, filePath, name, type } = attrs;
this.appPath = appPath || '';
this.filePath = filePath || '';
if (!type || !name) {
throw new Error('Missing app config type or name.');
}
if (!appPath) {
throw new Error('Missing app path to initialize config.');
}
// Make sure file path is set from server prop or type/name
if (!filePath) {
if (file_path) {
filePath = file_path;
}
else {
// Note this shouldn't be necessary unless the config was created before file_path was added
const typePath = ConfigPaths[String(type).toUpperCase()];
filePath = `${typePath}/${name}`;
if (!typePath) {
throw new Error(`Missing path for app config type: ${type}`);
}
}
this.filePath = filePath;
}
if (!appPath.endsWith(filePath)) {
this.appPath = path.join(appPath, filePath);
}
if (filePathExists(this.appPath)) {
this.fileHash = hashFile(this.appPath, undefined, filePath);
this.hash = this.hash || this.fileHash;
this.fileData = fs.readFileSync(this.appPath);
this.fileContent = this.fileData.toString('utf8');
this.byteSize = Buffer.byteLength(this.fileContent);
this.mbSize = this.byteSize / 1024 / 1024;
}
}
static create(attrs = {}) {
switch (attrs.type) {
case ConfigType.ASSET: {
return new AppConfigAsset(attrs);
}
case ConfigType.FUNCTION: {
return new AppConfigFunction(attrs);
}
case ConfigType.MODEL: {
return new AppConfigModel(attrs);
}
case ConfigType.CONTENT: {
return new AppConfigContent(attrs);
}
case ConfigType.NOTIFICATION: {
return new AppConfigNotification(attrs);
}
case ConfigType.SETTING: {
return new AppConfigSetting(attrs);
}
case ConfigType.WEBHOOK: {
return new AppConfigWebhook(attrs);
}
case ConfigType.FRONTEND: {
return new AppConfigFrontend(attrs);
}
case ConfigType.THEME: {
return new AppConfigTheme(attrs);
}
default: {
// the default type is file
const defaultConfig = new AppConfigDefault(attrs);
defaultConfig.type = attrs.type;
return defaultConfig;
}
}
}
isRootConfig(configDir) {
return !this.filePath
?.replace(new RegExp(`^${configDir}/`), '')
.includes('/');
}
async postData() {
if (this.fileContent === undefined) {
return;
}
if (this.hasValues && this.filePath.endsWith('.json')) {
try {
this.values = JSON.parse(this.fileContent);
}
catch {
throw new IgnoringFileError('Invalid JSON');
}
if (isEmpty(this.values)) {
throw new IgnoringFileError('Empty JSON');
}
}
// Detect file content type
const contentType = this.filePath.endsWith('.liquid') || this.filePath.endsWith('.liquidx')
? 'application/liquid'
: detectFilenameMime(this.filePath);
const postData = {
file: {
content_type: contentType,
data: this.prepareFileData(),
},
file_path: this.filePath,
hash: this.hash,
// Name is either configured or the last part of the path without extension
name: this.values?.name || this.name,
type: this.type,
};
if (this.hasValues) {
postData.values = this.values || {};
}
return this.preparePostData(postData);
}
prepareFileData() {
if (!this.fileData) {
return null;
}
return getEncoding(this.fileData) === 'binary'
? { $base64: this.fileData.toString('base64') }
: this.fileData?.toString('utf8');
}
}
class AppConfigDefault extends AppConfig {
hasValues = false;
type = ConfigType.FILE;
preparePostData(postData) {
return postData;
}
}
class AppConfigModel extends AppConfig {
hasValues = true;
type = ConfigType.MODEL;
preparePostData(postData) {
postData.values.collection =
postData.values.collection || postData.values.model || this.name;
return postData;
}
}
class AppConfigContent extends AppConfig {
hasValues = true;
type = ConfigType.CONTENT;
preparePostData(postData) {
postData.values.collection = postData.values.collection || this.name;
return postData;
}
}
class AppConfigNotification extends AppConfig {
hasValues = true;
type = ConfigType.NOTIFICATION;
preparePostData(postData) {
const isTemplate = this.isRootConfig('notifications') &&
(this.filePath.endsWith('.tpl') || this.filePath.endsWith('.liquid'));
if (isTemplate) {
// Get json config to match collection/model name
let jsonData;
try {
const jsonPath = this.filePath
.replace('.tpl', '.json')
.replace('.liquid', '.json');
jsonData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
postData.values.collection = jsonData?.collection || jsonData?.model;
}
catch {
// noop
}
postData.file = {
content_type: 'text/plain',
data: this.fileContent?.replaceAll('\r\n', '\n'),
};
}
return postData;
}
}
class AppConfigSetting extends AppConfig {
hasValues = true;
type = ConfigType.SETTING;
preparePostData(postData) {
return postData;
}
}
class AppConfigWebhook extends AppConfig {
hasValues = true;
type = ConfigType.WEBHOOK;
preparePostData(postData) {
return postData;
}
}
class AppConfigFunction extends AppConfig {
hasValues = true;
type = ConfigType.FUNCTION;
async preparePostData(postData) {
const isFunction = this.isRootConfig('functions') &&
(this.filePath.endsWith('.js') || this.filePath.endsWith('.ts'));
if (isFunction) {
try {
const { code, config } = await bundleFunction(this.filePath);
if (!config) {
throw new IgnoringFileError('Function must export a `config` object.');
}
// Save the original file and the bundled version
postData.file = {
data: this.prepareFileData(),
};
postData.build_file = {
content_type: 'text/javascript',
data: code,
};
postData.values = config;
}
catch (error) {
throw new FunctionProcessingError(`Unable to compile function ${this.name}`, error);
}
}
return postData;
}
}
// Assets do not get installed but saved as plain files
class AppConfigAsset extends AppConfigDefault {
hasValues = false;
type = ConfigType.ASSET;
}
class AppConfigFrontend extends AppConfigDefault {
hasValues = false;
type = ConfigType.FRONTEND;
}
class AppConfigTheme extends AppConfigDefault {
hasValues = false;
type = ConfigType.THEME;
preparePostData(postData) {
const isConfigJson = this.filePath?.match(/^theme\/config\/[^./]+\.json$/);
// Files in theme/config/*.json are treated as settings with values
if (isConfigJson) {
try {
const jsonData = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
postData.values = jsonData;
}
catch {
// noop
}
}
return postData;
}
}