UNPKG

@swell/cli

Version:

Swell's command line interface/utility

298 lines (297 loc) 9.83 kB
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; } }