UNPKG

@swell/cli

Version:

Swell's command line interface/utility

384 lines (383 loc) 13.2 kB
import { getEncoding } from 'istextorbinary'; import isEmpty from 'lodash/isEmpty.js'; import { detectFilenameMime } from 'mime-detect'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { bundleFunction, bundleComponent, bundleWorkflow } from '../bundle.js'; import { analyzeFunctionSource, hasKindDiagnostic, workflowStaticConfigError, } from '../function-source-analysis.js'; import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, } from './index.js'; export class IgnoringFileError extends Error { constructor(message) { super(message); this.name = 'IgnoringFileError'; } } export class FunctionProcessingError extends Error { cause; constructor(message, error) { super(message); this.cause = error; this.name = 'FunctionProcessingError'; } } /** * 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; // 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 = AllConfigPaths[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); } } async load() { if (await filePathExistsAsync(this.appPath)) { this.fileData = await fs.readFile(this.appPath); this.fileHash = hashFile(this.appPath, this.fileData, this.filePath); this.hash = this.hash || this.fileHash; this.byteSize = this.fileData.length; this.mbSize = this.byteSize / 1024 / 1024; } } static async create(attrs = {}) { let instance; switch (attrs.type) { case ConfigType.ASSET: { instance = new AppConfigAsset(attrs); break; } case ConfigType.FUNCTION: { instance = new AppConfigFunction(attrs); break; } case ConfigType.MODEL: { instance = new AppConfigModel(attrs); break; } case ConfigType.CONTENT: { instance = new AppConfigContent(attrs); break; } case ConfigType.NOTIFICATION: { instance = new AppConfigNotification(attrs); break; } case ConfigType.SETTING: { instance = new AppConfigSetting(attrs); break; } case ConfigType.WEBHOOK: { instance = new AppConfigWebhook(attrs); break; } case ConfigType.FRONTEND: { instance = new AppConfigFrontend(attrs); break; } case ConfigType.THEME: { instance = new AppConfigTheme(attrs); break; } case ConfigType.COMPONENT: { instance = new AppConfigComponent(attrs); break; } default: { // the default type is file const defaultConfig = new AppConfigDefault(attrs); defaultConfig.type = attrs.type; instance = defaultConfig; break; } } await instance.load(); return instance; } isRootConfig(configDir) { const pos = this.filePath.startsWith(configDir + '/') ? configDir.length + 1 : 0; return !this.filePath.includes('/', pos); } async postData() { if (this.fileData === undefined) { return; } if (this.hasValues && this.filePath.endsWith('.json')) { try { this.values = JSON.parse(this.fileData.toString('utf8')); } 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'); } } export class AppConfigDefault extends AppConfig { hasValues = false; type = ConfigType.FILE; preparePostData(postData) { return postData; } } export class AppConfigModel extends AppConfig { hasValues = true; type = ConfigType.MODEL; preparePostData(postData) { postData.values.collection = postData.values.collection || postData.values.model || this.name; return postData; } } export class AppConfigContent extends AppConfig { hasValues = true; type = ConfigType.CONTENT; preparePostData(postData) { postData.values.collection = postData.values.collection || this.name; return postData; } } export class AppConfigNotification extends AppConfig { hasValues = true; type = ConfigType.NOTIFICATION; async 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 try { const jsonPath = this.filePath .replace('.tpl', '.json') .replace('.liquid', '.json'); const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8')); postData.values.collection = jsonData?.collection || jsonData?.model; } catch { // noop } postData.file = { content_type: 'text/plain', data: this.fileData?.toString('utf8')?.replaceAll('\r\n', '\n'), }; } return postData; } } export class AppConfigSetting extends AppConfig { hasValues = true; type = ConfigType.SETTING; preparePostData(postData) { return postData; } } export class AppConfigWebhook extends AppConfig { hasValues = true; type = ConfigType.WEBHOOK; preparePostData(postData) { return postData; } } export class AppConfigFunction extends AppConfig { hasValues = true; type = ConfigType.FUNCTION; isRootFunction() { return (this.isRootConfig('functions') && (this.filePath.endsWith('.js') || this.filePath.endsWith('.ts'))); } async preparePostData(postData) { if (this.isRootFunction()) { try { // get file contents and if it's empty ignore const fileData = this.prepareFileData(); if (!fileData) { return; } const analysis = await analyzeFunctionSource(this.appPath); if (analysis.kind === 'workflow') { if (analysis.diagnostics.length > 0) { throw new Error(analysis.diagnostics.join('\n')); } const { code, config } = await bundleWorkflow(this.appPath, analysis); postData.file = { data: fileData, }; postData.build_file = { content_type: 'application/javascript+module', data: code, }; postData.values = config; return postData; } if (hasKindDiagnostic(analysis)) { throw new Error(analysis.diagnostics.join('\n')); } const { code, config } = await bundleFunction(this.appPath); if (!config) { throw new IgnoringFileError('Function must export a `config` object.'); } if (config.kind === 'workflow') { throw workflowStaticConfigError(); } // Save the original file and the bundled version postData.file = { data: fileData, }; 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; } } export class AppConfigComponent extends AppConfig { hasValues = true; type = ConfigType.COMPONENT; isRootComponent() { return (this.isRootConfig('components') && (this.filePath.endsWith('.jsx') || this.filePath.endsWith('.tsx'))); } async preparePostData(postData) { if (!this.isRootComponent()) { return postData; } try { // get file contents and if it's empty ignore const fileData = this.prepareFileData(); if (!fileData) { return; } const { code, config } = await bundleComponent(this.filePath); if (!config) { throw new IgnoringFileError('Component must export a `config` object.'); } // Save the original file and the bundled version postData.file = { data: fileData, }; postData.build_file = { content_type: 'application/javascript', data: code, }; postData.values = config; } catch (error) { throw new FunctionProcessingError(`Unable to compile component ${this.name}`, error); } return postData; } } // Assets do not get installed but saved as plain files export class AppConfigAsset extends AppConfigDefault { hasValues = false; type = ConfigType.ASSET; } export class AppConfigFrontend extends AppConfigDefault { hasValues = false; type = ConfigType.FRONTEND; } export class AppConfigTheme extends AppConfigDefault { hasValues = false; type = ConfigType.THEME; async preparePostData(postData) { const isConfigJson = this.filePath && /^theme\/config\/[^./]+\.json$/.test(this.filePath); // Files in theme/config/*.json are treated as settings with values if (isConfigJson) { try { const jsonData = JSON.parse(await fs.readFile(this.filePath, 'utf8')); postData.values = jsonData; } catch (error) { // If the file is not valid JSON, ignore it silently. if (!(error instanceof SyntaxError)) { throw error; } } } return postData; } }