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 +390 -1
- package/index.js +112 -101
- package/lib/entry.js +120 -0
- package/lib/glob.js +24 -0
- package/lib/log.js +33 -0
- package/lib/tags.js +66 -0
- package/package.json +4 -2
- package/tests/unit/entry.test.js +173 -0
- package/tests/unit/tags.test.js +201 -0
- package/sails-hook-shipwright-0.3.5.tgz +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,394 @@
|
|
|
1
1
|
# Shipwright
|
|
2
|
-
|
|
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
|
|
2
|
+
* sails-hook-shipwright
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
54
|
+
log.verbose('Skipping build (dontLift)')
|
|
56
55
|
return
|
|
57
56
|
}
|
|
58
|
-
|
|
59
|
-
const appPath = sails.config
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
+
|
|
143
|
+
sails.on('lifted', () => {
|
|
144
|
+
devServer.afterListen()
|
|
145
|
+
log.verbose('Dev server ready ⚓')
|
|
146
|
+
log.silly('🚢 %s', randomQuote())
|
|
142
147
|
})
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
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
|