@haptiq/kit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/haptiq.kit.js CHANGED
@@ -21,6 +21,7 @@ import path from 'path';
21
21
  import packageJson from '../package.json' with { type: 'json' };
22
22
  import { buildCSS } from '../lib/css.js';
23
23
  import { buildJS } from '../lib/js.js';
24
+ import { ship } from '../lib/ship.js';
24
25
 
25
26
  const { version } = packageJson;
26
27
 
@@ -80,6 +81,31 @@ async function loadConfig(verbose = false) {
80
81
  }
81
82
  }
82
83
 
84
+ // Validate ship config structure if present
85
+ if (config.ship) {
86
+ if (typeof config.ship !== 'object' || Array.isArray(config.ship)) {
87
+ throw new Error('ship configuration must be an object');
88
+ }
89
+
90
+ if (config.ship.targets) {
91
+ if (typeof config.ship.targets !== 'object' || Array.isArray(config.ship.targets)) {
92
+ throw new Error('ship.targets must be an object');
93
+ }
94
+
95
+ for (const [targetName, targetConfig] of Object.entries(config.ship.targets)) {
96
+ if (targetConfig.dest && typeof targetConfig.dest !== 'string') {
97
+ throw new Error(`ship.targets.${targetName}.dest must be a string`);
98
+ }
99
+ if (targetConfig.host && typeof targetConfig.host !== 'string') {
100
+ throw new Error(`ship.targets.${targetName}.host must be a string`);
101
+ }
102
+ if (targetConfig.dev !== undefined && typeof targetConfig.dev !== 'boolean') {
103
+ throw new Error(`ship.targets.${targetName}.dev must be a boolean (true or false, not a string)`);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
83
109
  return config;
84
110
  } catch (configError) {
85
111
  // ERR_MODULE_NOT_FOUND is the ESM equivalent of MODULE_NOT_FOUND
@@ -107,10 +133,12 @@ async function loadConfig(verbose = false) {
107
133
  * @returns {Function} Command handler function for commander.js
108
134
  */
109
135
  function createCommandHandler(taskName, taskFunction) {
110
- return async (options) => {
136
+ return async (...args) => {
137
+ const options = args[args.length - 1];
138
+ const positionalArgs = args.slice(0, -1);
111
139
  try {
112
140
  const config = await loadConfig(options.verbose);
113
- await taskFunction(config, options);
141
+ await taskFunction(config, options, ...positionalArgs);
114
142
  } catch (error) {
115
143
  console.error(`❌ ${taskName} failed:`, error.message);
116
144
  process.exit(1);
@@ -127,19 +155,30 @@ program
127
155
  program
128
156
  .command('css')
129
157
  .description('Build CSS from Sass and CSS files')
158
+ .option('--dev', 'Build without minification')
130
159
  .option('--verbose', 'Show detailed output for each file processed')
131
160
  .action(createCommandHandler('CSS build', async (config, options) => {
132
- await buildCSS(config, options.verbose);
161
+ await buildCSS(config, options.verbose, options.dev);
133
162
  }));
134
163
 
135
164
  program
136
165
  .command('js')
137
166
  .description('Bundle JavaScript files with Terser')
167
+ .option('--dev', 'Build without minification')
138
168
  .option('--verbose', 'Show detailed output for bundling process')
139
169
  .option('--only <name>', 'Only run the named configuration')
140
170
  .option('--skip <name>', 'Skip the named configuration')
141
171
  .action(createCommandHandler('JavaScript build', async (config, options) => {
142
- await buildJS(config, options.verbose, { only: options.only, skip: options.skip });
172
+ await buildJS(config, options.verbose, { only: options.only, skip: options.skip, dev: options.dev });
173
+ }));
174
+
175
+ program
176
+ .command('ship [target]')
177
+ .description('Build assets and ship to remote server via rsync')
178
+ .option('--dev', 'Build without minification before shipping')
179
+ .option('--verbose', 'Show detailed output')
180
+ .action(createCommandHandler('Ship', async (config, options, target) => {
181
+ await ship(config, options.verbose, { target, dev: options.dev });
143
182
  }));
144
183
 
145
184
  program.parse();
package/lib/css.js CHANGED
@@ -23,10 +23,11 @@ import haptiqBrowserslistConfig from '@haptiq/browserslist-config';
23
23
  *
24
24
  * @param {Object} config - Configuration object from haptiq.config.js
25
25
  * @param {boolean} verbose - Show detailed processing logs
26
+ * @param {boolean} dev - Skip minification (--dev flag)
26
27
  * @returns {Promise<void>}
27
28
  * @see {@link ../examples/haptiq.config.js} for all available options
28
29
  */
29
- async function buildCSS(config = {}, verbose = false) {
30
+ async function buildCSS(config = {}, verbose = false, dev = false) {
30
31
  // Use project's own browserslist config if present, otherwise fall back to @haptiq/browserslist-config
31
32
  const projectConfig = browserslist.loadConfig({ path: process.cwd() });
32
33
  const resolvedTargets = browserslistToTargets(
@@ -35,7 +36,7 @@ async function buildCSS(config = {}, verbose = false) {
35
36
 
36
37
  const defaultLightningConfig = {
37
38
  targets: resolvedTargets,
38
- minify: true,
39
+ minify: !dev,
39
40
  sourceMap: true
40
41
  };
41
42
 
@@ -43,10 +44,10 @@ async function buildCSS(config = {}, verbose = false) {
43
44
  src: 'src/**/*.{scss,sass,css}',
44
45
  dest: 'css',
45
46
  ...config.css,
46
- // Merge lightning config properly (after user config)
47
47
  lightning: {
48
48
  ...defaultLightningConfig,
49
- ...config.css?.lightning
49
+ ...config.css?.lightning,
50
+ ...(dev && { minify: false })
50
51
  }
51
52
  };
52
53
 
package/lib/js.js CHANGED
@@ -18,7 +18,7 @@ import { minify } from 'terser';
18
18
  *
19
19
  * @param {Object} config - Configuration object from haptiq.config.js
20
20
  * @param {boolean} verbose - Show detailed processing logs
21
- * @param {{ only?: string, skip?: string }} options - CLI filter options
21
+ * @param {{ only?: string, skip?: string, dev?: boolean }} options - CLI filter options
22
22
  * @returns {Promise<void>}
23
23
  */
24
24
  async function buildJS(config = {}, verbose = false, options = {}) {
@@ -31,7 +31,7 @@ async function buildJS(config = {}, verbose = false, options = {}) {
31
31
  if (options.only || options.skip) {
32
32
  console.warn('⚠️ --only and --skip have no effect with a single configuration');
33
33
  }
34
- await processSingleConfig(jsConfig, verbose);
34
+ await processSingleConfig(jsConfig, verbose, options.dev ?? false);
35
35
  }
36
36
 
37
37
  console.log(`✅ JavaScript processing completed.`);
@@ -43,10 +43,11 @@ async function buildJS(config = {}, verbose = false, options = {}) {
43
43
  *
44
44
  * @param {Object} configs - Object with named configurations
45
45
  * @param {boolean} verbose - Show detailed processing logs
46
- * @param {{ only?: string, skip?: string }} options - CLI filter options
46
+ * @param {{ only?: string, skip?: string, dev?: boolean }} options - CLI filter options
47
47
  * @returns {Promise<void>}
48
48
  */
49
49
  async function processMultipleConfigs(configs, verbose, options = {}) {
50
+ const dev = options.dev ?? false;
50
51
  const configNames = Object.keys(configs);
51
52
 
52
53
  let configsToProcess = configNames;
@@ -67,7 +68,7 @@ async function processMultipleConfigs(configs, verbose, options = {}) {
67
68
  }
68
69
 
69
70
  for (const configName of configsToProcess) {
70
- await processSingleConfig(configs[configName], verbose);
71
+ await processSingleConfig(configs[configName], verbose, dev);
71
72
  if (verbose) {
72
73
  console.log(` ✅ [${configName}] done`);
73
74
  }
@@ -82,7 +83,7 @@ async function processMultipleConfigs(configs, verbose, options = {}) {
82
83
  * @param {boolean} verbose - Show detailed processing logs
83
84
  * @returns {Promise<void>}
84
85
  */
85
- async function processSingleConfig(jsConfig, verbose) {
86
+ async function processSingleConfig(jsConfig, verbose, dev = false) {
86
87
  const dest = jsConfig.dest || 'js/bundle.js';
87
88
  let combine = jsConfig.combine;
88
89
  if (combine === undefined) {
@@ -96,7 +97,8 @@ async function processSingleConfig(jsConfig, verbose) {
96
97
  combine,
97
98
  terser: {
98
99
  sourceMap: true,
99
- ...jsConfig.terser
100
+ ...jsConfig.terser,
101
+ ...(dev && { compress: false, mangle: false, format: { beautify: true } })
100
102
  }
101
103
  };
102
104
 
package/lib/ship.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Ship Module
3
+ *
4
+ * Builds CSS and JS assets then syncs them to a remote or local
5
+ * destination via rsync. Target configuration lives in haptiq.config.js
6
+ * under the `ship` key.
7
+ */
8
+ import { spawn } from 'child_process';
9
+ import { existsSync } from 'fs';
10
+ import path from 'path';
11
+ import { buildCSS } from './css.js';
12
+ import { buildJS } from './js.js';
13
+
14
+
15
+ /**
16
+ * Main ship function — builds assets then rsyncs for each target
17
+ *
18
+ * @param {Object} config - Configuration object from haptiq.config.js
19
+ * @param {boolean} verbose - Show detailed output
20
+ * @param {{ target?: string, dev?: boolean }} options - CLI options
21
+ * @returns {Promise<void>}
22
+ */
23
+ async function ship(config, verbose, options = {}) {
24
+ const { target: targetName, dev: cliDev } = options;
25
+
26
+ const shipConfig = config.ship;
27
+
28
+ const { targets = {}, src = './', exclude, delete: deleteRemoved = false } = shipConfig ?? {};
29
+
30
+ if (!exclude || exclude.length === 0) {
31
+ console.warn('⚠️ No ship.exclude configured — everything in src will be synced including node_modules. Add an exclude list to haptiq.config.js to filter unwanted files.');
32
+ }
33
+
34
+ const configuredTargetNames = Object.keys(targets);
35
+ let targetsToProcess;
36
+
37
+ if (targetName) {
38
+ if (targetName !== 'dist' && !targets[targetName]) {
39
+ const available = [...configuredTargetNames, 'dist'].join(', ');
40
+ throw new Error(`Target "${targetName}" not found. Available: ${available}`);
41
+ }
42
+ targetsToProcess = [targetName];
43
+ } else {
44
+ if (configuredTargetNames.length === 0) {
45
+ throw new Error('No ship targets configured. Add a ship.targets object to haptiq.config.js, or run `kit ship dist` for a local build.');
46
+ }
47
+ targetsToProcess = configuredTargetNames;
48
+ }
49
+
50
+ for (const name of targetsToProcess) {
51
+ const targetConfig = targets[name] ?? {};
52
+ const dev = cliDev || targetConfig.dev || false;
53
+
54
+ await buildCSS(config, verbose, dev);
55
+ await buildJS(config, verbose, { dev });
56
+ await shipSingleTarget(name, targetConfig, { src, exclude, deleteRemoved }, verbose);
57
+
58
+ console.log(`✅ Shipped to [${name}].`);
59
+ }
60
+ }
61
+
62
+
63
+ /**
64
+ * Rsync a single target
65
+ *
66
+ * @param {string} name - Target name (for error messages)
67
+ * @param {{ dest?: string, host?: string, dev?: boolean }} targetConfig - Target settings. `dest` is required for remote targets; local targets derive it automatically.
68
+ * @param {{ src: string, exclude: string[], deleteRemoved: boolean }} shipConfig - Shared settings
69
+ * @param {boolean} verbose - Stream rsync output to console
70
+ * @returns {Promise<void>}
71
+ */
72
+ async function shipSingleTarget(name, targetConfig, shipConfig, verbose) {
73
+ const { dest, host } = targetConfig;
74
+ const { src = './', exclude = [], deleteRemoved = false } = shipConfig;
75
+
76
+ let destination;
77
+
78
+ if (host) {
79
+ if (!dest) {
80
+ throw new Error(`ship.targets.${name}: missing required field "dest" for remote target`);
81
+ }
82
+ destination = `${host}:${dest}`;
83
+ } else {
84
+ if (dest) {
85
+ console.warn(`⚠️ ship.targets.${name}: "dest" has no effect on local targets — destination is always derived as ../project-name-dist/.`);
86
+ }
87
+
88
+ const projectRoot = process.cwd();
89
+ const projectName = path.basename(projectRoot);
90
+ const localDest = path.resolve(projectRoot, '..', `${projectName}-dist`);
91
+
92
+ if (existsSync(localDest)) {
93
+ throw new Error(`Local destination "${localDest}" already exists. Remove it manually before shipping to avoid overwriting existing content.`);
94
+ }
95
+
96
+ destination = localDest;
97
+ }
98
+
99
+ const projectRoot = process.cwd();
100
+ const resolvedSrc = path.resolve(projectRoot, src);
101
+ if (resolvedSrc !== projectRoot && !resolvedSrc.startsWith(projectRoot + path.sep)) {
102
+ throw new Error(`ship.src must be inside the project root. Got: ${resolvedSrc}`);
103
+ }
104
+
105
+ const normalizedSrc = src.endsWith('/') ? src : `${src}/`;
106
+
107
+ const args = [
108
+ '-az',
109
+ ...(verbose ? ['-v'] : []),
110
+ ...exclude.flatMap(e => ['--exclude', e]),
111
+ ...(deleteRemoved ? ['--delete', '--delete-excluded'] : []),
112
+ normalizedSrc,
113
+ destination,
114
+ ];
115
+
116
+ console.log(`🚀 Shipping [${name}] → ${destination}`);
117
+
118
+ await spawnRsync(args, verbose);
119
+ }
120
+
121
+
122
+ /**
123
+ * Spawn rsync as a child process without shell interpolation
124
+ *
125
+ * @param {string[]} args - rsync argument array
126
+ * @param {boolean} verbose - Pipe stdout to console
127
+ * @returns {Promise<void>}
128
+ */
129
+ function spawnRsync(args, verbose) {
130
+ return new Promise((resolve, reject) => {
131
+ const childProcess = spawn('rsync', args, { shell: false });
132
+
133
+ let stderr = '';
134
+
135
+ if (verbose) {
136
+ childProcess.stdout.on('data', (data) => process.stdout.write(data));
137
+ }
138
+
139
+ childProcess.stderr.on('data', (data) => {
140
+ stderr += data.toString();
141
+ });
142
+
143
+ childProcess.on('close', (code) => {
144
+ if (code === 0) {
145
+ resolve();
146
+ } else {
147
+ reject(new Error(`rsync failed: ${stderr.trim()}`));
148
+ }
149
+ });
150
+
151
+ childProcess.on('error', (err) => {
152
+ if (err.code === 'ENOENT') {
153
+ reject(new Error('rsync not found. Please ensure rsync is installed on your system.'));
154
+ } else {
155
+ reject(new Error(`Failed to start rsync: ${err.message}`));
156
+ }
157
+ });
158
+ });
159
+ }
160
+
161
+
162
+ export { ship };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haptiq/kit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Build tools for Haptiq projects.",
5
5
  "keywords": [
6
6
  "build",