sails-hook-shipwright 0.3.8 → 1.0.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/README.md CHANGED
@@ -1,5 +1,394 @@
1
1
  # Shipwright
2
- Shipwright is the modern asset pipeline for Sails.
2
+
3
+ The modern asset pipeline for Sails.js, powered by [Rsbuild](https://rsbuild.dev).
4
+
5
+ Shipwright replaces the legacy Grunt-based asset pipeline with a fast, modern bundler that supports TypeScript, ES modules, LESS/SASS, and Hot Module Replacement out of the box.
6
+
7
+ ## Why Shipwright?
8
+
9
+ | Feature | Grunt (legacy) | Shipwright |
10
+ | ---------------------- | -------------- | ---------- |
11
+ | Build speed | ~16s | ~1.4s |
12
+ | JS bundle size | 3.0MB | 229KB |
13
+ | CSS bundle size | 733KB | 551KB |
14
+ | Hot Module Replacement | No | Yes |
15
+ | TypeScript | No | Yes |
16
+ | ES Modules | No | Yes |
17
+ | Tree Shaking | No | Yes |
18
+
19
+ _Benchmarks from [fleetdm.com](https://fleetdm.com) migration ([fleetdm/fleet#38079](https://github.com/fleetdm/fleet/issues/38079))_
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install sails-hook-shipwright --save
25
+ ```
26
+
27
+ Disable the grunt hook in `.sailsrc`:
28
+
29
+ ```json
30
+ {
31
+ "hooks": {
32
+ "grunt": false
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ Shipwright works with zero configuration for most apps. Just create your entry point:
40
+
41
+ ```
42
+ assets/
43
+ js/
44
+ app.js # Auto-detected entry point
45
+ styles/
46
+ importer.less # Auto-detected styles entry
47
+ ```
48
+
49
+ In your layout, use the shipwright helpers:
50
+
51
+ ```ejs
52
+ <!DOCTYPE html>
53
+ <html>
54
+ <head>
55
+ <%- shipwright.styles() %>
56
+ </head>
57
+ <body>
58
+ <!-- your content -->
59
+ <%- shipwright.scripts() %>
60
+ </body>
61
+ </html>
62
+ ```
63
+
64
+ That's it! Shipwright will bundle your JS, compile your styles, and inject the appropriate tags.
65
+
66
+ ## Configuration
67
+
68
+ Create `config/shipwright.js` to customize behavior:
69
+
70
+ ```js
71
+ module.exports.shipwright = {
72
+ js: {
73
+ entry: 'assets/js/app.js' // optional, auto-detected by default
74
+ },
75
+ styles: {
76
+ entry: 'assets/styles/app.css' // optional, auto-detected by default
77
+ },
78
+ build: {
79
+ // Rsbuild configuration - see https://rsbuild.dev/config/
80
+ }
81
+ }
82
+ ```
83
+
84
+ Most apps don't need a config file at all - shipwright auto-detects entry points and uses sensible defaults:
85
+
86
+ - **JS inject default:** `['dependencies/**/*.js']`
87
+ - **CSS inject default:** `['dependencies/**/*.css']`
88
+
89
+ ### Entry Points
90
+
91
+ Shipwright auto-detects entry points in this order:
92
+
93
+ **JavaScript:**
94
+
95
+ 1. `assets/js/app.js`
96
+ 2. `assets/js/main.js`
97
+ 3. `assets/js/index.js`
98
+
99
+ **Styles:**
100
+
101
+ 1. `assets/styles/importer.less`
102
+ 2. `assets/styles/importer.scss`
103
+ 3. `assets/styles/importer.css`
104
+ 4. `assets/styles/main.less`
105
+ 5. `assets/styles/main.scss`
106
+ 6. `assets/styles/main.css`
107
+ 7. `assets/styles/app.less`
108
+ 8. `assets/styles/app.scss`
109
+ 9. `assets/styles/app.css`
110
+ 10. `assets/css/app.css`
111
+ 11. `assets/css/main.css`
112
+
113
+ ### Two Bundling Modes
114
+
115
+ #### Modern Mode (ES Modules)
116
+
117
+ For new apps or apps using `import`/`export`:
118
+
119
+ ```js
120
+ // assets/js/app.js
121
+ import { setupCloud } from './cloud.setup'
122
+ import { formatDate } from './utilities/format'
123
+
124
+ setupCloud()
125
+ ```
126
+
127
+ Shipwright detects the single entry point and bundles all imports.
128
+
129
+ #### Legacy Mode (Glob Patterns)
130
+
131
+ For existing apps that concatenate scripts without ES modules (like Grunt's pipeline.js):
132
+
133
+ ```js
134
+ // config/shipwright.js
135
+ module.exports.shipwright = {
136
+ js: {
137
+ entry: [
138
+ 'js/cloud.setup.js',
139
+ 'js/components/**/*.js',
140
+ 'js/utilities/**/*.js',
141
+ 'js/pages/**/*.js'
142
+ ]
143
+ }
144
+ }
145
+ ```
146
+
147
+ Files are concatenated in the specified order, preserving the global scope behavior of the legacy pipeline. This is a drop-in replacement for `tasks/pipeline.js`.
148
+
149
+ ### Inject vs Entry
150
+
151
+ - **entry** - Files bundled together by Rsbuild (minified, tree-shaken, hashed)
152
+ - **inject** - Files loaded as separate `<script>` or `<link>` tags before the bundle
153
+
154
+ Use `inject` for vendor libraries that need to be loaded separately:
155
+
156
+ ```js
157
+ module.exports.shipwright = {
158
+ js: {
159
+ inject: [
160
+ 'dependencies/sails.io.js',
161
+ 'dependencies/lodash.js',
162
+ 'dependencies/jquery.min.js',
163
+ 'dependencies/vue.js',
164
+ 'dependencies/**/*.js' // catch remaining dependencies
165
+ ]
166
+ }
167
+ }
168
+ ```
169
+
170
+ The order is preserved, and duplicates are automatically removed.
171
+
172
+ ## TypeScript Support
173
+
174
+ Shipwright supports TypeScript out of the box. Just use `.ts` or `.tsx` files:
175
+
176
+ ```js
177
+ // config/shipwright.js
178
+ module.exports.shipwright = {
179
+ js: {
180
+ entry: 'assets/js/app.ts'
181
+ // or with glob patterns:
182
+ // entry: ['js/**/*.ts', 'js/**/*.tsx']
183
+ }
184
+ }
185
+ ```
186
+
187
+ No `tsconfig.json` required for basic usage. Add one if you want strict type checking.
188
+
189
+ ## LESS/SASS Support
190
+
191
+ Install the appropriate plugin:
192
+
193
+ ```bash
194
+ # For LESS
195
+ npm install @rsbuild/plugin-less --save-dev
196
+
197
+ # For SASS/SCSS
198
+ npm install @rsbuild/plugin-sass --save-dev
199
+ ```
200
+
201
+ Add the plugin to your config:
202
+
203
+ ```js
204
+ const { pluginLess } = require('@rsbuild/plugin-less')
205
+
206
+ module.exports.shipwright = {
207
+ build: {
208
+ plugins: [pluginLess()]
209
+ }
210
+ }
211
+ ```
212
+
213
+ Shipwright auto-detects your styles entry point (`importer.less`, `main.scss`, etc.).
214
+
215
+ ## Hot Module Replacement
216
+
217
+ In development, Shipwright provides HMR via Rsbuild's dev server. Changes to your JS and CSS files are instantly reflected in the browser without a full page reload.
218
+
219
+ HMR is enabled automatically when `NODE_ENV !== 'production'`.
220
+
221
+ ## Production Builds
222
+
223
+ In production (`NODE_ENV=production`), Shipwright:
224
+
225
+ - Minifies JS and CSS
226
+ - Adds content hashes for cache busting (`app.a1b2c3d4.js`)
227
+ - Enables tree shaking to remove unused code
228
+ - Generates a manifest for asset versioning
229
+
230
+ ## Output Structure
231
+
232
+ ```
233
+ .tmp/public/
234
+ js/
235
+ app.js # development
236
+ app.a1b2c3d4.js # production (with hash)
237
+ css/
238
+ styles.css
239
+ styles.b2c3d4e5.css
240
+ manifest.json # maps entry names to hashed filenames
241
+ dependencies/ # copied from assets/dependencies
242
+ images/ # copied from assets/images
243
+ ...
244
+ ```
245
+
246
+ ## Path Aliases
247
+
248
+ Shipwright configures these aliases by default:
249
+
250
+ - `@` → `assets/js`
251
+ - `~` → `assets`
252
+
253
+ ```js
254
+ // In your JS files
255
+ import utils from '@/utilities/helpers'
256
+ import styles from '~/styles/components.css'
257
+ ```
258
+
259
+ ## Advanced Configuration
260
+
261
+ Pass any Rsbuild configuration via the `build` key:
262
+
263
+ ```js
264
+ const { pluginLess } = require('@rsbuild/plugin-less')
265
+ const { pluginReact } = require('@rsbuild/plugin-react')
266
+
267
+ module.exports.shipwright = {
268
+ build: {
269
+ plugins: [pluginLess(), pluginReact()],
270
+ output: {
271
+ // Custom output options
272
+ },
273
+ performance: {
274
+ // Custom performance options
275
+ }
276
+ }
277
+ }
278
+ ```
279
+
280
+ See [Rsbuild Configuration](https://rsbuild.dev/config/) for all available options.
281
+
282
+ ## Migrating from Grunt
283
+
284
+ 1. Install shipwright and disable grunt:
285
+
286
+ ```bash
287
+ npm install sails-hook-shipwright --save
288
+ npm install @rsbuild/plugin-less --save-dev # if using LESS
289
+ ```
290
+
291
+ ```json
292
+ // .sailsrc
293
+ {
294
+ "hooks": {
295
+ "grunt": false
296
+ }
297
+ }
298
+ ```
299
+
300
+ 2. Create `config/shipwright.js` based on your `tasks/pipeline.js`:
301
+
302
+ ```js
303
+ // If your pipeline.js has:
304
+ // var jsFilesToInject = [
305
+ // 'dependencies/sails.io.js',
306
+ // 'dependencies/lodash.js',
307
+ // 'js/cloud.setup.js',
308
+ // 'js/**/*.js'
309
+ // ]
310
+
311
+ // Your shipwright.js becomes:
312
+ const { pluginLess } = require('@rsbuild/plugin-less')
313
+
314
+ module.exports.shipwright = {
315
+ js: {
316
+ entry: [
317
+ 'js/cloud.setup.js',
318
+ 'js/components/**/*.js',
319
+ 'js/utilities/**/*.js',
320
+ 'js/pages/**/*.js'
321
+ ],
322
+ inject: [
323
+ 'dependencies/sails.io.js',
324
+ 'dependencies/lodash.js',
325
+ 'dependencies/**/*.js'
326
+ ]
327
+ },
328
+ build: {
329
+ plugins: [pluginLess()]
330
+ }
331
+ }
332
+ ```
333
+
334
+ 3. Update your layout to use shipwright helpers:
335
+
336
+ ```diff
337
+ - <!--STYLES-->
338
+ - <!--STYLES END-->
339
+ + <%- shipwright.styles() %>
340
+
341
+ - <!--SCRIPTS-->
342
+ - <!--SCRIPTS END-->
343
+ + <%- shipwright.scripts() %>
344
+ ```
345
+
346
+ 4. Remove the `tasks/` directory (optional, but recommended).
347
+
348
+ ## API
349
+
350
+ ### View Helpers
351
+
352
+ #### `shipwright.scripts()`
353
+
354
+ Returns `<script>` tags for:
355
+
356
+ 1. Injected files (from `js.inject` patterns)
357
+ 2. Bundled files (from manifest)
358
+
359
+ #### `shipwright.styles()`
360
+
361
+ Returns `<link>` tags for:
362
+
363
+ 1. Injected files (from `styles.inject` patterns)
364
+ 2. Compiled styles (from manifest)
365
+
366
+ ## Troubleshooting
367
+
368
+ ### "Missing @rsbuild/plugin-less"
369
+
370
+ Install the required plugin:
371
+
372
+ ```bash
373
+ npm install @rsbuild/plugin-less --save-dev
374
+ ```
375
+
376
+ And add it to your config:
377
+
378
+ ```js
379
+ const { pluginLess } = require('@rsbuild/plugin-less')
380
+ module.exports.shipwright = {
381
+ build: { plugins: [pluginLess()] }
382
+ }
383
+ ```
384
+
385
+ ### Scripts loading twice
386
+
387
+ Check that your `inject` patterns don't overlap with files in the bundle. Shipwright automatically deduplicates, but explicit is better than implicit.
388
+
389
+ ### HMR not working
390
+
391
+ Ensure `NODE_ENV` is not set to `production` in development.
3
392
 
4
393
  ## License
5
394
 
package/index.js CHANGED
@@ -1,68 +1,93 @@
1
1
  /**
2
- * shipwright hook
2
+ * sails-hook-shipwright
3
3
  *
4
- * @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions, and/or initialization logic.
5
- * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
4
+ * Modern asset pipeline for Sails.js, powered by Rsbuild.
5
+ *
6
+ * @see https://github.com/sailshq/sails-hook-shipwright
6
7
  */
7
8
 
8
9
  const path = require('path')
9
- const { defineConfig, mergeRsbuildConfig } = require('@rsbuild/core')
10
+ const {
11
+ detectStylesEntry,
12
+ detectJsEntry,
13
+ expandEntryPatterns,
14
+ validatePlugins
15
+ } = require('./lib/entry')
16
+ const { createTagGenerators } = require('./lib/tags')
17
+ const { createLogger, randomQuote } = require('./lib/log')
18
+
10
19
  module.exports = function defineShipwrightHook(sails) {
11
- function getManifestFiles() {
12
- const manifestPath = path.resolve(
13
- sails.config.appPath,
14
- '.tmp',
15
- 'public',
16
- 'manifest.json'
17
- )
18
- const data = require(manifestPath)
19
- const files = data.allFiles
20
- return files
21
- }
20
+ let log
22
21
 
23
22
  return {
24
- generateScripts: function generateScripts() {
25
- const manifestFiles = getManifestFiles()
26
- let scripts = []
27
- manifestFiles.forEach((file) => {
28
- if (file.endsWith('.js')) {
29
- scripts.push(`<script type="text/javascript" src="${file}"></script>`)
30
- }
31
- })
32
- return scripts.join('\n')
33
- },
34
- generateStyles: function generateStyles() {
35
- const manifestFiles = getManifestFiles()
36
- let styles = []
37
- manifestFiles.forEach((file) => {
38
- if (file.endsWith('.css')) {
39
- styles.push(`<link rel="stylesheet" href="${file}">`)
40
- }
41
- })
42
- return styles.join('\n')
43
- },
44
23
  defaults: {
45
24
  shipwright: {
25
+ styles: {},
26
+ js: {},
46
27
  build: {}
47
28
  }
48
29
  },
30
+
49
31
  /**
50
- * Runs when this Sails app loads/lifts.
32
+ * Configure hook.
33
+ * Runs after defaults merge, before initialize.
34
+ * Detects entry points and validates plugins early.
35
+ */
36
+ configure: function () {
37
+ log = createLogger(sails.log)
38
+
39
+ const { appPath } = sails.config
40
+ const config = sails.config.shipwright
41
+
42
+ config.styles.entry = detectStylesEntry(appPath, config.styles.entry)
43
+ config.js.entry = detectJsEntry(appPath, config.js.entry)
44
+
45
+ validatePlugins({ appPath, stylesEntry: config.styles.entry, log })
46
+ },
47
+
48
+ /**
49
+ * Initialize hook.
50
+ * Starts Rsbuild build (production) or dev server (development).
51
51
  */
52
52
  initialize: async function () {
53
- // Skip asset building if --dontLift is set
54
53
  if (sails.config.dontLift) {
55
- sails.log.info('shipwright: Skipping asset build due to dontLift flag')
54
+ log.verbose('Skipping build (dontLift)')
56
55
  return
57
56
  }
58
- const hook = this
59
- const appPath = sails.config.appPath
60
- const defaultConfigs = defineConfig({
61
- source: {
62
- entry: {
63
- app: path.resolve(appPath, 'assets', 'js', 'app.js')
64
- }
65
- },
57
+
58
+ const { appPath } = sails.config
59
+ const config = sails.config.shipwright
60
+
61
+ // Build entry object - array patterns get expanded to file list
62
+ const entry = {}
63
+ const jsFiles = expandEntryPatterns(config.js.entry, appPath)
64
+ if (jsFiles?.length) {
65
+ entry.app = jsFiles
66
+ log.verbose('Bundling %d JS files', jsFiles.length)
67
+ } else if (config.js.entry && !Array.isArray(config.js.entry)) {
68
+ entry.app = path.resolve(appPath, config.js.entry)
69
+ log.verbose('Bundling %s', config.js.entry)
70
+ }
71
+ if (config.styles.entry) {
72
+ entry.styles = path.resolve(appPath, config.styles.entry)
73
+ log.verbose('Compiling %s', config.styles.entry)
74
+ }
75
+
76
+ log.silly('Preparing to set sail...')
77
+
78
+ if (!Object.keys(entry).length) {
79
+ log.verbose('No entry points found, skipping')
80
+ return
81
+ }
82
+
83
+ const {
84
+ defineConfig,
85
+ mergeRsbuildConfig,
86
+ createRsbuild
87
+ } = require('@rsbuild/core')
88
+
89
+ const defaultConfig = defineConfig({
90
+ source: { entry },
66
91
  resolve: {
67
92
  alias: {
68
93
  '@': path.resolve(appPath, 'assets', 'js'),
@@ -76,82 +101,68 @@ module.exports = function defineShipwrightHook(sails) {
76
101
  css: 'css',
77
102
  js: 'js',
78
103
  font: 'fonts',
79
- image: 'images',
80
- html: '/'
104
+ image: 'images'
81
105
  },
82
106
  copy: [
83
107
  {
84
- from: path.resolve(appPath, 'assets', 'images'),
85
- to: path.resolve(appPath, '.tmp', 'public', 'images'),
86
- noErrorOnMissing: true
87
- },
88
- {
89
- from: path.resolve(appPath, 'assets', 'fonts'),
90
- to: path.resolve(appPath, '.tmp', 'public', 'fonts'),
91
- noErrorOnMissing: true
92
- },
93
- {
94
- from: path.resolve(appPath, 'assets', 'dependencies'),
95
- to: path.resolve(appPath, '.tmp', 'public', 'dependencies'),
96
- noErrorOnMissing: true
97
- },
98
- {
99
- context: path.resolve(appPath, 'assets'),
100
- from: '**/*.html',
108
+ from: path.resolve(appPath, 'assets'),
101
109
  to: path.resolve(appPath, '.tmp', 'public'),
102
- noErrorOnMissing: true
110
+ noErrorOnMissing: true,
111
+ globOptions: { ignore: ['**/js/**', '**/styles/**', '**/css/**'] }
103
112
  }
104
113
  ]
105
114
  },
106
115
  tools: {
107
- htmlPlugin: false
108
- },
109
- performance: {
110
- chunkSplit: {
111
- strategy: 'split-by-experience'
112
- }
116
+ htmlPlugin: false,
117
+ // Don't process absolute URLs in CSS - they reference static assets served from .tmp/public
118
+ cssLoader: { url: { filter: (url) => !url.startsWith('/') } }
113
119
  },
114
- server: {
115
- port: sails.config.port,
116
- strictPort: true,
117
- printUrls: false
118
- },
119
- dev: {
120
- writeToDisk: (file) => file.includes('manifest.json') // Write manifest file
121
- }
120
+ performance: { chunkSplit: { strategy: 'split-by-experience' } },
121
+ server: { port: sails.config.port, strictPort: true, printUrls: false },
122
+ dev: { writeToDisk: (file) => file.includes('manifest.json') }
122
123
  })
123
- const config = mergeRsbuildConfig(
124
- defaultConfigs,
125
- sails.config.shipwright.build
126
- )
127
- const { createRsbuild } = require('@rsbuild/core')
124
+
125
+ const rsbuildConfig = mergeRsbuildConfig(defaultConfig, config.build)
126
+
128
127
  try {
129
- const rsbuild = await createRsbuild({ rsbuildConfig: config })
128
+ const rsbuild = await createRsbuild({ rsbuildConfig })
129
+
130
130
  if (process.env.NODE_ENV === 'production') {
131
+ log.silly('Building for production...')
131
132
  await rsbuild.build()
133
+ log.verbose('Build complete ⚓')
134
+ log.silly('🚢 %s', randomQuote())
132
135
  } else {
133
- const rsbuildDevServer = await rsbuild.createDevServer()
134
- sails.after('hook:http:loaded', async () => {
135
- sails.hooks.http.app.use(rsbuildDevServer.middlewares)
136
- rsbuildDevServer.connectWebSocket({
137
- server: sails.hooks.http.server
138
- })
136
+ const devServer = await rsbuild.createDevServer()
137
+
138
+ sails.after('hook:http:loaded', () => {
139
+ sails.hooks.http.app.use(devServer.middlewares)
140
+ devServer.connectWebSocket({ server: sails.hooks.http.server })
139
141
  })
140
- sails.on('lifted', async () => {
141
- await rsbuildDevServer.afterListen()
142
+
143
+ sails.on('lifted', () => {
144
+ devServer.afterListen()
145
+ log.verbose('Dev server ready ⚓')
146
+ log.silly('🚢 %s', randomQuote())
142
147
  })
143
- sails.on('lower', async () => {
144
- await rsbuildDevServer.close()
148
+
149
+ sails.on('lower', () => {
150
+ devServer.close()
151
+ log.silly('Dropping anchor... goodbye!')
145
152
  })
146
153
  }
154
+
155
+ // Register view locals (merge, don't overwrite)
147
156
  sails.config.views.locals = {
148
- shipwright: {
149
- scripts: hook.generateScripts,
150
- styles: hook.generateStyles
151
- }
157
+ ...sails.config.views.locals,
158
+ shipwright: createTagGenerators(appPath, {
159
+ jsInject: config.js.inject,
160
+ cssInject: config.styles.inject
161
+ })
152
162
  }
153
163
  } catch (error) {
154
- sails.log.error(error)
164
+ log.error('Build failed')
165
+ log.error(error)
155
166
  }
156
167
  }
157
168
  }
package/lib/entry.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * lib/entry.js
3
+ *
4
+ * Entry point detection and plugin validation.
5
+ */
6
+
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+ const { glob } = require('./glob')
10
+
11
+ // Auto-detect styles in priority order (Sails convention: importer.less first)
12
+ const STYLE_CANDIDATES = [
13
+ 'assets/styles/importer.less',
14
+ 'assets/styles/importer.scss',
15
+ 'assets/styles/importer.sass',
16
+ 'assets/styles/importer.css',
17
+ 'assets/styles/main.less',
18
+ 'assets/styles/main.scss',
19
+ 'assets/styles/main.css',
20
+ 'assets/styles/app.less',
21
+ 'assets/styles/app.scss',
22
+ 'assets/styles/app.css',
23
+ 'assets/css/app.css',
24
+ 'assets/css/main.css'
25
+ ]
26
+
27
+ const JS_CANDIDATES = [
28
+ 'assets/js/app.js',
29
+ 'assets/js/main.js',
30
+ 'assets/js/index.js'
31
+ ]
32
+
33
+ // Maps file extensions to required Rsbuild plugins
34
+ const PLUGIN_MAP = {
35
+ '.less': { pkg: '@rsbuild/plugin-less', name: 'pluginLess' },
36
+ '.scss': { pkg: '@rsbuild/plugin-sass', name: 'pluginSass' },
37
+ '.sass': { pkg: '@rsbuild/plugin-sass', name: 'pluginSass' }
38
+ }
39
+
40
+ function detectEntry(appPath, configured, candidates) {
41
+ if (configured) return configured
42
+ return candidates.find((c) => fs.existsSync(path.resolve(appPath, c))) || null
43
+ }
44
+
45
+ function detectStylesEntry(appPath, configured) {
46
+ return detectEntry(appPath, configured, STYLE_CANDIDATES)
47
+ }
48
+
49
+ function detectJsEntry(appPath, configured) {
50
+ // Array entry = glob patterns, skip auto-detection
51
+ if (Array.isArray(configured)) return configured
52
+ return detectEntry(appPath, configured, JS_CANDIDATES)
53
+ }
54
+
55
+ const JS_EXTENSIONS = ['.js', '.ts', '.tsx', '.jsx']
56
+
57
+ /**
58
+ * Expand array of glob patterns to file paths, preserving order and deduping.
59
+ * Returns null if input is not an array, or array of absolute paths.
60
+ */
61
+ function expandEntryPatterns(patterns, appPath) {
62
+ if (!Array.isArray(patterns)) return null
63
+
64
+ const assetsPath = path.resolve(appPath, 'assets')
65
+ const seen = new Set()
66
+ const files = []
67
+
68
+ for (const pattern of patterns) {
69
+ for (const file of glob(pattern, assetsPath)) {
70
+ const ext = path.extname(file).toLowerCase()
71
+ if (JS_EXTENSIONS.includes(ext) && !seen.has(file)) {
72
+ seen.add(file)
73
+ files.push(path.resolve(assetsPath, file))
74
+ }
75
+ }
76
+ }
77
+ return files
78
+ }
79
+
80
+ function isInstalled(pkg, appPath) {
81
+ try {
82
+ require.resolve(pkg, { paths: [appPath] })
83
+ return true
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Validate required plugins are installed. Throws with helpful instructions if missing.
91
+ */
92
+ function validatePlugins({ appPath, stylesEntry, log }) {
93
+ if (!stylesEntry) return
94
+
95
+ const ext = path.extname(stylesEntry).toLowerCase()
96
+ const plugin = PLUGIN_MAP[ext]
97
+
98
+ if (plugin && !isInstalled(plugin.pkg, appPath)) {
99
+ log.error('')
100
+ log.error('Found %s but %s is not installed.', stylesEntry, plugin.pkg)
101
+ log.error('')
102
+ log.error('To compile these files:')
103
+ log.error(' 1. npm install %s --save-dev', plugin.pkg)
104
+ log.error(' 2. Add to config/shipwright.js:')
105
+ log.error('')
106
+ log.error(" const { %s } = require('%s')", plugin.name, plugin.pkg)
107
+ log.error(' module.exports.shipwright = {')
108
+ log.error(' build: { plugins: [%s()] }', plugin.name)
109
+ log.error(' }')
110
+ log.error('')
111
+ throw new Error(`Missing ${plugin.pkg}`)
112
+ }
113
+ }
114
+
115
+ module.exports = {
116
+ detectStylesEntry,
117
+ detectJsEntry,
118
+ expandEntryPatterns,
119
+ validatePlugins
120
+ }
package/lib/glob.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * lib/glob.js
3
+ *
4
+ * Glob wrapper for easy swapping between implementations.
5
+ *
6
+ * To swap to Node's built-in (Node 22+):
7
+ * const { globSync } = require('fs')
8
+ * module.exports = { glob: (pattern, cwd) => globSync(pattern, { cwd }) }
9
+ */
10
+
11
+ const fg = require('fast-glob')
12
+
13
+ /**
14
+ * Match files against glob patterns.
15
+ *
16
+ * @param {string|string[]} patterns - Glob pattern(s)
17
+ * @param {string} cwd - Base directory
18
+ * @returns {string[]} - Matching file paths (relative to cwd)
19
+ */
20
+ function glob(patterns, cwd) {
21
+ return fg.sync(patterns, { cwd, onlyFiles: true })
22
+ }
23
+
24
+ module.exports = { glob }
package/lib/log.js ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * lib/log.js
3
+ *
4
+ * Prefixed logging with maritime easter eggs.
5
+ */
6
+
7
+ const PREFIX = 'shipwright:'
8
+
9
+ const QUOTES = [
10
+ 'Smooth seas never made a skilled shipwright.',
11
+ "A ship in harbor is safe, but that's not what ships are built for.",
12
+ "Red sky at night, sailor's delight. Green build output, developer's delight.",
13
+ 'Fair winds and following seas to your assets.',
14
+ 'The sea finds out everything you did wrong. So does production.',
15
+ "We're gonna need a bigger bundle.",
16
+ "I'm the captain of this asset pipeline now.",
17
+ 'Ahoy! Spotted land at .tmp/public/'
18
+ ]
19
+
20
+ const randomQuote = () => QUOTES[Math.floor(Math.random() * QUOTES.length)]
21
+
22
+ const createLogger = (sailsLog) => ({
23
+ error: (msg, ...args) => sailsLog.error(`${PREFIX} ${msg}`, ...args),
24
+ warn: (msg, ...args) => sailsLog.warn(`${PREFIX} ${msg}`, ...args),
25
+ info: (msg, ...args) => sailsLog.info(`${PREFIX} ${msg}`, ...args),
26
+ verbose: (msg, ...args) => sailsLog.verbose(`${PREFIX} ${msg}`, ...args),
27
+ silly: (msg, ...args) => {
28
+ sailsLog.silly(`${PREFIX} ${msg}`, ...args)
29
+ sailsLog.silly(`${PREFIX} 🚢 ${randomQuote()}`)
30
+ }
31
+ })
32
+
33
+ module.exports = { createLogger, randomQuote }
package/lib/tags.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/tags.js
3
+ *
4
+ * Generate script and style tags from manifest and inject patterns.
5
+ */
6
+
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+ const { glob } = require('./glob')
10
+
11
+ const DEFAULT_INJECT = {
12
+ js: ['dependencies/**/*.js'],
13
+ css: ['dependencies/**/*.css']
14
+ }
15
+
16
+ /**
17
+ * Expand glob patterns, preserving order and deduping.
18
+ */
19
+ function expandPatterns(patterns, cwd, ext) {
20
+ const seen = new Set()
21
+ const result = []
22
+
23
+ for (const pattern of patterns) {
24
+ if (!pattern.endsWith(ext) && !pattern.includes('*')) continue
25
+ for (const file of glob(pattern, cwd)) {
26
+ if (file.endsWith(ext) && !seen.has(file)) {
27
+ seen.add(file)
28
+ result.push('/' + file.replace(/\\/g, '/'))
29
+ }
30
+ }
31
+ }
32
+ return result
33
+ }
34
+
35
+ /**
36
+ * Create tag generators for views.
37
+ */
38
+ function createTagGenerators(appPath, config = {}) {
39
+ const assetsPath = path.resolve(appPath, 'assets')
40
+ const manifestPath = path.resolve(appPath, '.tmp', 'public', 'manifest.json')
41
+
42
+ function getManifest(ext) {
43
+ if (!fs.existsSync(manifestPath)) return []
44
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8')).allFiles.filter(
45
+ (f) => f.endsWith(ext)
46
+ )
47
+ }
48
+
49
+ function getTags(ext, template, injectKey) {
50
+ const patterns = config[injectKey] || DEFAULT_INJECT[ext.slice(1)]
51
+ const injected = expandPatterns(patterns, assetsPath, ext)
52
+ const injectedSet = new Set(injected)
53
+ // Filter manifest to exclude already-injected files (avoid duplicates)
54
+ const bundled = getManifest(ext).filter((f) => !injectedSet.has(f))
55
+ return [...injected, ...bundled].map(template).join('\n')
56
+ }
57
+
58
+ return {
59
+ scripts: () =>
60
+ getTags('.js', (f) => `<script src="${f}"></script>`, 'jsInject'),
61
+ styles: () =>
62
+ getTags('.css', (f) => `<link rel="stylesheet" href="${f}">`, 'cssInject')
63
+ }
64
+ }
65
+
66
+ module.exports = { createTagGenerators }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "sails-hook-shipwright",
3
- "version": "0.3.8",
3
+ "version": "1.0.0",
4
4
  "description": "The modern asset pipeline for Sails",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
+ "test": "node --test tests/unit/*.test.js",
7
8
  "prepare": "husky install"
8
9
  },
9
10
  "repository": {
@@ -30,7 +31,8 @@
30
31
  "hookName": "shipwright"
31
32
  },
32
33
  "dependencies": {
33
- "@rsbuild/core": "^1.5.6"
34
+ "@rsbuild/core": "^1.7.0",
35
+ "fast-glob": "^3.3.3"
34
36
  },
35
37
  "devDependencies": {
36
38
  "@commitlint/cli": "^19.8.1",
@@ -0,0 +1,173 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+
7
+ const {
8
+ detectStylesEntry,
9
+ detectJsEntry,
10
+ expandEntryPatterns,
11
+ validatePlugins
12
+ } = require('../../lib/entry')
13
+
14
+ describe('entry.js', () => {
15
+ let tmpDir
16
+ const mockLog = { error: () => {} }
17
+
18
+ beforeEach(() => {
19
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shipwright-test-'))
20
+ })
21
+
22
+ afterEach(() => {
23
+ fs.rmSync(tmpDir, { recursive: true, force: true })
24
+ })
25
+
26
+ function createFile(relativePath) {
27
+ const fullPath = path.join(tmpDir, relativePath)
28
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true })
29
+ fs.writeFileSync(fullPath, '')
30
+ }
31
+
32
+ describe('detectStylesEntry', () => {
33
+ it('returns configured entry over auto-detection', () => {
34
+ createFile('assets/styles/importer.less')
35
+ assert.strictEqual(
36
+ detectStylesEntry(tmpDir, 'custom.less'),
37
+ 'custom.less'
38
+ )
39
+ })
40
+
41
+ it('auto-detects in priority order: importer.less > main.less > app.less', () => {
42
+ createFile('assets/styles/app.less')
43
+ assert.strictEqual(
44
+ detectStylesEntry(tmpDir, null),
45
+ 'assets/styles/app.less'
46
+ )
47
+
48
+ createFile('assets/styles/importer.less')
49
+ assert.strictEqual(
50
+ detectStylesEntry(tmpDir, null),
51
+ 'assets/styles/importer.less'
52
+ )
53
+ })
54
+
55
+ it('returns null if no entry found', () => {
56
+ assert.strictEqual(detectStylesEntry(tmpDir, null), null)
57
+ })
58
+ })
59
+
60
+ describe('detectJsEntry', () => {
61
+ it('returns configured entry over auto-detection', () => {
62
+ createFile('assets/js/app.js')
63
+ assert.strictEqual(detectJsEntry(tmpDir, 'custom.js'), 'custom.js')
64
+ })
65
+
66
+ it('auto-detects in priority order: app.js > main.js > index.js', () => {
67
+ createFile('assets/js/index.js')
68
+ assert.strictEqual(detectJsEntry(tmpDir, null), 'assets/js/index.js')
69
+
70
+ createFile('assets/js/app.js')
71
+ assert.strictEqual(detectJsEntry(tmpDir, null), 'assets/js/app.js')
72
+ })
73
+
74
+ it('returns null if no entry found', () => {
75
+ assert.strictEqual(detectJsEntry(tmpDir, null), null)
76
+ })
77
+
78
+ it('returns array as-is (glob patterns)', () => {
79
+ const patterns = ['js/utilities/**/*.js', 'js/pages/**/*.js']
80
+ assert.deepStrictEqual(detectJsEntry(tmpDir, patterns), patterns)
81
+ })
82
+ })
83
+
84
+ describe('expandEntryPatterns', () => {
85
+ it('returns null for non-array input', () => {
86
+ assert.strictEqual(expandEntryPatterns('assets/js/app.js', tmpDir), null)
87
+ assert.strictEqual(expandEntryPatterns(null, tmpDir), null)
88
+ })
89
+
90
+ it('expands glob patterns preserving order', () => {
91
+ createFile('assets/js/utilities/helpers.js')
92
+ createFile('assets/js/utilities/format.js')
93
+ createFile('assets/js/pages/home.js')
94
+ createFile('assets/js/pages/about.js')
95
+
96
+ const result = expandEntryPatterns(
97
+ ['js/utilities/**/*.js', 'js/pages/**/*.js'],
98
+ tmpDir
99
+ )
100
+
101
+ assert.strictEqual(result.length, 4)
102
+ const utilitiesIdx = result.findIndex((f) => f.includes('utilities'))
103
+ const pagesIdx = result.findIndex((f) => f.includes('pages'))
104
+ assert.ok(utilitiesIdx < pagesIdx, 'utilities should come before pages')
105
+ })
106
+
107
+ it('deduplicates files across patterns', () => {
108
+ createFile('assets/js/app.js')
109
+ createFile('assets/js/utils.js')
110
+
111
+ const result = expandEntryPatterns(['js/app.js', 'js/**/*.js'], tmpDir)
112
+
113
+ const appMatches = result.filter((f) => f.includes('app.js'))
114
+ assert.strictEqual(appMatches.length, 1)
115
+ assert.ok(result[0].includes('app.js'))
116
+ })
117
+
118
+ it('returns absolute paths', () => {
119
+ createFile('assets/js/app.js')
120
+ const result = expandEntryPatterns(['js/**/*.js'], tmpDir)
121
+ assert.ok(path.isAbsolute(result[0]))
122
+ })
123
+
124
+ it('includes js, ts, tsx, jsx files', () => {
125
+ createFile('assets/js/app.js')
126
+ createFile('assets/js/utils.ts')
127
+ createFile('assets/js/component.tsx')
128
+ createFile('assets/js/legacy.jsx')
129
+ createFile('assets/js/styles.css')
130
+ createFile('assets/js/readme.md')
131
+
132
+ const result = expandEntryPatterns(['js/**/*'], tmpDir)
133
+
134
+ assert.strictEqual(result.length, 4)
135
+ })
136
+
137
+ it('returns empty array when no matches', () => {
138
+ const result = expandEntryPatterns(['js/**/*.js'], tmpDir)
139
+ assert.strictEqual(result.length, 0)
140
+ })
141
+ })
142
+
143
+ describe('validatePlugins', () => {
144
+ it('passes for .css (no plugin needed) or no entry', () => {
145
+ validatePlugins({ appPath: tmpDir, stylesEntry: null, log: mockLog })
146
+ validatePlugins({ appPath: tmpDir, stylesEntry: 'app.css', log: mockLog })
147
+ })
148
+
149
+ it('throws for .less when plugin missing', () => {
150
+ assert.throws(
151
+ () =>
152
+ validatePlugins({
153
+ appPath: tmpDir,
154
+ stylesEntry: 'app.less',
155
+ log: mockLog
156
+ }),
157
+ { message: 'Missing @rsbuild/plugin-less' }
158
+ )
159
+ })
160
+
161
+ it('throws for .scss/.sass when plugin missing', () => {
162
+ assert.throws(
163
+ () =>
164
+ validatePlugins({
165
+ appPath: tmpDir,
166
+ stylesEntry: 'app.scss',
167
+ log: mockLog
168
+ }),
169
+ { message: 'Missing @rsbuild/plugin-sass' }
170
+ )
171
+ })
172
+ })
173
+ })
@@ -0,0 +1,201 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+
7
+ const { createTagGenerators } = require('../../lib/tags')
8
+
9
+ describe('tags.js', () => {
10
+ let tmpDir
11
+ let manifestPath
12
+ let assetsPath
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shipwright-test-'))
16
+ manifestPath = path.join(tmpDir, '.tmp', 'public', 'manifest.json')
17
+ assetsPath = path.join(tmpDir, 'assets')
18
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true })
19
+ })
20
+
21
+ afterEach(() => {
22
+ fs.rmSync(tmpDir, { recursive: true, force: true })
23
+ })
24
+
25
+ function writeManifest(files) {
26
+ fs.writeFileSync(manifestPath, JSON.stringify({ allFiles: files }))
27
+ }
28
+
29
+ function createAsset(relativePath) {
30
+ const fullPath = path.join(assetsPath, relativePath)
31
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true })
32
+ fs.writeFileSync(fullPath, '')
33
+ }
34
+
35
+ describe('scripts()', () => {
36
+ it('returns empty string when no manifest and no dependencies', () => {
37
+ const { scripts } = createTagGenerators(tmpDir, { jsInject: [] })
38
+ assert.strictEqual(scripts(), '')
39
+ })
40
+
41
+ it('generates script tags for manifest JS files', () => {
42
+ writeManifest(['/js/app.js', '/css/app.css', '/js/vendor.js'])
43
+ const { scripts } = createTagGenerators(tmpDir, { jsInject: [] })
44
+
45
+ assert.strictEqual(
46
+ scripts(),
47
+ '<script src="/js/app.js"></script>\n<script src="/js/vendor.js"></script>'
48
+ )
49
+ })
50
+
51
+ it('uses default inject pattern when not specified', () => {
52
+ createAsset('dependencies/lodash.js')
53
+ createAsset('dependencies/vue.js')
54
+ writeManifest(['/js/app.js'])
55
+
56
+ // No jsInject = defaults to dependencies/**/*.js
57
+ const { scripts } = createTagGenerators(tmpDir)
58
+
59
+ const output = scripts()
60
+ assert.ok(output.includes('/dependencies/lodash.js'))
61
+ assert.ok(output.includes('/dependencies/vue.js'))
62
+ assert.ok(output.includes('/js/app.js'))
63
+ })
64
+
65
+ it('injects files before manifest in specified order', () => {
66
+ createAsset('dependencies/sails.io.js')
67
+ createAsset('dependencies/lodash.js')
68
+ createAsset('dependencies/vue.js')
69
+ writeManifest(['/js/app.js'])
70
+
71
+ const { scripts } = createTagGenerators(tmpDir, {
72
+ jsInject: [
73
+ 'dependencies/sails.io.js',
74
+ 'dependencies/lodash.js',
75
+ 'dependencies/vue.js'
76
+ ]
77
+ })
78
+
79
+ const output = scripts()
80
+ const sailsIdx = output.indexOf('sails.io.js')
81
+ const lodashIdx = output.indexOf('lodash.js')
82
+ const vueIdx = output.indexOf('vue.js')
83
+ const appIdx = output.indexOf('/js/app.js')
84
+
85
+ assert.ok(sailsIdx < lodashIdx, 'sails.io should come before lodash')
86
+ assert.ok(lodashIdx < vueIdx, 'lodash should come before vue')
87
+ assert.ok(vueIdx < appIdx, 'vue should come before app.js')
88
+ })
89
+
90
+ it('supports glob patterns with deduplication', () => {
91
+ createAsset('dependencies/sails.io.js')
92
+ createAsset('dependencies/lodash.js')
93
+ createAsset('dependencies/vue.js')
94
+
95
+ const { scripts } = createTagGenerators(tmpDir, {
96
+ jsInject: [
97
+ 'dependencies/sails.io.js', // Explicit first
98
+ 'dependencies/**/*.js' // Glob catches rest, sails.io deduped
99
+ ]
100
+ })
101
+
102
+ const output = scripts()
103
+ // sails.io should appear only once
104
+ const matches = output.match(/sails\.io\.js/g)
105
+ assert.strictEqual(matches.length, 1, 'sails.io should appear only once')
106
+
107
+ // Order: sails.io first (explicit), then rest
108
+ const sailsIdx = output.indexOf('sails.io.js')
109
+ const lodashIdx = output.indexOf('lodash.js')
110
+ assert.ok(
111
+ sailsIdx < lodashIdx,
112
+ 'explicit file should come before glob matches'
113
+ )
114
+ })
115
+
116
+ it('empty inject array means no injection', () => {
117
+ createAsset('dependencies/lodash.js')
118
+ writeManifest(['/js/app.js'])
119
+
120
+ const { scripts } = createTagGenerators(tmpDir, { jsInject: [] })
121
+
122
+ const output = scripts()
123
+ assert.ok(!output.includes('lodash.js'), 'should not inject dependencies')
124
+ assert.ok(output.includes('/js/app.js'), 'should still include manifest')
125
+ })
126
+ })
127
+
128
+ describe('styles()', () => {
129
+ it('returns empty string when no manifest and no dependencies', () => {
130
+ const { styles } = createTagGenerators(tmpDir, { cssInject: [] })
131
+ assert.strictEqual(styles(), '')
132
+ })
133
+
134
+ it('generates link tags for manifest CSS files', () => {
135
+ writeManifest(['/css/app.css', '/js/app.js', '/css/vendor.css'])
136
+ const { styles } = createTagGenerators(tmpDir, { cssInject: [] })
137
+
138
+ assert.strictEqual(
139
+ styles(),
140
+ '<link rel="stylesheet" href="/css/app.css">\n<link rel="stylesheet" href="/css/vendor.css">'
141
+ )
142
+ })
143
+
144
+ it('uses default inject pattern when not specified', () => {
145
+ createAsset('dependencies/normalize.css')
146
+ writeManifest(['/css/app.css'])
147
+
148
+ const { styles } = createTagGenerators(tmpDir)
149
+
150
+ const output = styles()
151
+ assert.ok(output.includes('/dependencies/normalize.css'))
152
+ assert.ok(output.includes('/css/app.css'))
153
+ })
154
+
155
+ it('injects CSS before manifest', () => {
156
+ createAsset('dependencies/normalize.css')
157
+ createAsset('dependencies/fontawesome.css')
158
+ writeManifest(['/css/app.css'])
159
+
160
+ const { styles } = createTagGenerators(tmpDir, {
161
+ cssInject: ['dependencies/**/*.css']
162
+ })
163
+
164
+ const output = styles()
165
+ const normalizeIdx = output.indexOf('normalize.css')
166
+ const appIdx = output.indexOf('/css/app.css')
167
+
168
+ assert.ok(
169
+ normalizeIdx < appIdx,
170
+ 'injected CSS should come before manifest'
171
+ )
172
+ })
173
+ })
174
+
175
+ it('reads fresh manifest on each call (no caching)', () => {
176
+ const { scripts } = createTagGenerators(tmpDir, { jsInject: [] })
177
+
178
+ assert.strictEqual(scripts(), '')
179
+
180
+ writeManifest(['/js/app.js'])
181
+ assert.strictEqual(scripts(), '<script src="/js/app.js"></script>')
182
+
183
+ writeManifest(['/js/app.js', '/js/new.js'])
184
+ assert.ok(scripts().includes('/js/new.js'))
185
+ })
186
+
187
+ it('handles nested directories in inject patterns', () => {
188
+ createAsset('js/components/header.js')
189
+ createAsset('js/components/footer.js')
190
+ createAsset('js/utilities/helpers.js')
191
+
192
+ const { scripts } = createTagGenerators(tmpDir, {
193
+ jsInject: ['js/components/**/*.js', 'js/utilities/**/*.js']
194
+ })
195
+
196
+ const output = scripts()
197
+ assert.ok(output.includes('/js/components/header.js'))
198
+ assert.ok(output.includes('/js/components/footer.js'))
199
+ assert.ok(output.includes('/js/utilities/helpers.js'))
200
+ })
201
+ })
Binary file