tiendu 0.4.0 → 0.5.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 +54 -13
- package/bin/tiendu.mjs +1 -136
- package/lib/api.mjs +18 -50
- package/lib/archive.mjs +30 -0
- package/lib/assets.mjs +245 -0
- package/lib/build.mjs +299 -41
- package/lib/dev.mjs +239 -136
- package/lib/fs-utils.mjs +35 -0
- package/lib/local-preview.mjs +350 -0
- package/lib/postcss.mjs +166 -0
- package/lib/preview.mjs +19 -9
- package/lib/push.mjs +39 -50
- package/lib/retry.mjs +69 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Official CLI for [Tiendu](https://tiendu.uy) — develop and publish storefront themes from your local machine.
|
|
4
4
|
|
|
5
|
-
Download your store's theme, edit files locally, preview changes live with a
|
|
5
|
+
Download your store's theme, edit files locally, preview changes live with a local auto-reloading URL plus a sharable preview URL, and publish when you're ready — all from the terminal.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -44,7 +44,7 @@ tiendu init
|
|
|
44
44
|
tiendu dev
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
`tiendu dev` creates a remote preview, builds your source files,
|
|
47
|
+
`tiendu dev` creates a remote preview, builds your source files, runs an initial push from the prepared output, and then watches for changes. It prints a local live-preview URL first, plus a sharable preview URL like:
|
|
48
48
|
|
|
49
49
|
```
|
|
50
50
|
http://preview-xxxxxxxxxxxx.tiendu.uy/
|
|
@@ -52,6 +52,9 @@ http://preview-xxxxxxxxxxxx.tiendu.uy/
|
|
|
52
52
|
|
|
53
53
|
The preview renders with the real Tiendu engine — same output as production.
|
|
54
54
|
|
|
55
|
+
When `tiendu dev` starts, it always re-syncs your current local files to the active preview before watching for changes.
|
|
56
|
+
It also starts a local live-preview URL that proxies the preview and auto-reloads after successful syncs.
|
|
57
|
+
|
|
55
58
|
---
|
|
56
59
|
|
|
57
60
|
## Commands
|
|
@@ -91,9 +94,11 @@ tiendu build
|
|
|
91
94
|
|
|
92
95
|
The build:
|
|
93
96
|
|
|
94
|
-
1. Copies theme files
|
|
95
|
-
2.
|
|
96
|
-
3.
|
|
97
|
+
1. Copies theme files from `src/layout/`, `src/templates/`, and `src/snippets/` to `dist/`
|
|
98
|
+
2. Flattens static files from `src/assets/` into `dist/assets/`
|
|
99
|
+
3. Discovers entry points in `src/layout/` and `src/templates/`
|
|
100
|
+
4. Bundles JS/TS and CSS into `dist/assets/`
|
|
101
|
+
5. Runs project PostCSS plugins for CSS entries when available (for example Tailwind v4)
|
|
97
102
|
|
|
98
103
|
Entry naming convention:
|
|
99
104
|
|
|
@@ -115,7 +120,10 @@ tiendu dev
|
|
|
115
120
|
```
|
|
116
121
|
|
|
117
122
|
- Prints the preview URL on start
|
|
123
|
+
- Re-syncs the full local theme to the preview on startup
|
|
118
124
|
- Syncs file creates, edits and deletes
|
|
125
|
+
- Retries failed file sync operations up to 3 times before giving up
|
|
126
|
+
- Starts a local live-preview URL on `localhost` that refreshes after successful uploads
|
|
119
127
|
- Handles both text and binary files (images, fonts, etc.)
|
|
120
128
|
- Press `Ctrl+C` to stop
|
|
121
129
|
|
|
@@ -272,26 +280,59 @@ my-theme/
|
|
|
272
280
|
├── .gitignore
|
|
273
281
|
├── src/
|
|
274
282
|
│ ├── layout/
|
|
283
|
+
│ │ ├── theme.liquid # copied to dist/layout/theme.liquid
|
|
275
284
|
│ │ ├── theme.ts # layout TS entry → layout-theme.bundle.js
|
|
276
285
|
│ │ └── theme.css # layout CSS entry → layout-theme.bundle.css
|
|
277
286
|
│ ├── templates/
|
|
287
|
+
│ │ ├── product.liquid # copied to dist/templates/product.liquid
|
|
278
288
|
│ │ ├── product.ts # template TS entry → template-product.bundle.js
|
|
279
289
|
│ │ └── product.css # template CSS entry → template-product.bundle.css
|
|
290
|
+
│ ├── snippets/ # Liquid snippets copied to dist/snippets/
|
|
291
|
+
│ ├── assets/ # source assets → flattened into dist/assets/
|
|
280
292
|
│ ├── lib/ # shared modules (bundled into entries, not served)
|
|
281
293
|
│ └── css/ # shared CSS (imported by entry CSS)
|
|
282
|
-
├── layout/ # Liquid layouts
|
|
283
|
-
├── templates/ # Liquid templates
|
|
284
|
-
├── snippets/ # Liquid snippets
|
|
285
|
-
├── assets/ # static assets (SVGs, images, fonts)
|
|
286
294
|
└── dist/ # build output (gitignored, uploaded to Tiendu)
|
|
287
295
|
```
|
|
288
296
|
|
|
289
297
|
### How it works
|
|
290
298
|
|
|
291
|
-
1. Source
|
|
292
|
-
2.
|
|
293
|
-
3.
|
|
294
|
-
4. Liquid
|
|
299
|
+
1. Source assets in `src/assets/` are flattened into `dist/assets/` (`payment-methods/visa.svg` becomes `payment-methods___visa.svg`)
|
|
300
|
+
2. Source JS/TS/CSS in `src/` is bundled by esbuild into `dist/assets/`
|
|
301
|
+
3. CSS entries also run through your local PostCSS pipeline when configured
|
|
302
|
+
4. Liquid files are copied from `src/` to `dist/`
|
|
303
|
+
5. `dist/` is what gets uploaded — it looks like a normal Tiendu theme
|
|
304
|
+
6. Liquid templates reference bundles and assets via `asset_url` (e.g. `{{ 'layout-theme.bundle.js' | asset_url | script_tag }}` or `{{ 'payment-methods/visa.svg' | asset_url }}`)
|
|
305
|
+
|
|
306
|
+
### Tailwind v4
|
|
307
|
+
|
|
308
|
+
Built themes can use Tailwind v4 in CSS entry files.
|
|
309
|
+
|
|
310
|
+
Install it in your theme project:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
npm install -D tailwindcss @tailwindcss/postcss postcss
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Then import Tailwind from a CSS entry such as `src/layout/theme.css`:
|
|
317
|
+
|
|
318
|
+
```css
|
|
319
|
+
@import "tailwindcss";
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
You can either:
|
|
323
|
+
|
|
324
|
+
- rely on Tiendu CLI's automatic Tailwind detection when `@tailwindcss/postcss` is installed, or
|
|
325
|
+
- add a local `postcss.config.mjs` / `postcss.config.js` / `postcss.config.cjs` / `postcss.config.json`
|
|
326
|
+
|
|
327
|
+
Example `postcss.config.mjs`:
|
|
328
|
+
|
|
329
|
+
```js
|
|
330
|
+
export default {
|
|
331
|
+
plugins: {
|
|
332
|
+
"@tailwindcss/postcss": {},
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
```
|
|
295
336
|
|
|
296
337
|
### tiendu.config.json
|
|
297
338
|
|
package/bin/tiendu.mjs
CHANGED
|
@@ -1,138 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import { pull } from "../lib/pull.mjs";
|
|
5
|
-
import { push } from "../lib/push.mjs";
|
|
6
|
-
import { dev } from "../lib/dev.mjs";
|
|
7
|
-
import { publish } from "../lib/publish.mjs";
|
|
8
|
-
import {
|
|
9
|
-
previewCreate,
|
|
10
|
-
previewList,
|
|
11
|
-
previewDelete,
|
|
12
|
-
previewOpen,
|
|
13
|
-
} from "../lib/preview.mjs";
|
|
14
|
-
import {
|
|
15
|
-
checkForUpdates,
|
|
16
|
-
checkForUpdatesNow,
|
|
17
|
-
getCurrentVersion,
|
|
18
|
-
} from "../lib/update-check.mjs";
|
|
19
|
-
|
|
20
|
-
const HELP = `
|
|
21
|
-
tiendu — CLI para desarrollar temas de Tiendu
|
|
22
|
-
|
|
23
|
-
Uso:
|
|
24
|
-
tiendu init Inicializar un tema en el directorio actual
|
|
25
|
-
tiendu pull Descargar el tema live desde Tiendu
|
|
26
|
-
tiendu push [--skip-build] Subir archivos locales al preview activo (ZIP)
|
|
27
|
-
tiendu dev Modo desarrollo: watch + sync automático
|
|
28
|
-
tiendu publish [--skip-build]
|
|
29
|
-
Publicar el preview activo al storefront live
|
|
30
|
-
|
|
31
|
-
tiendu preview create Crear un preview remoto
|
|
32
|
-
tiendu preview list Listar previews de la tienda
|
|
33
|
-
tiendu preview delete Eliminar el preview activo
|
|
34
|
-
tiendu preview open Abrir la URL del preview en el navegador
|
|
35
|
-
|
|
36
|
-
tiendu check-updates Buscar una nueva version del CLI
|
|
37
|
-
tiendu version Mostrar la version actual del CLI
|
|
38
|
-
|
|
39
|
-
tiendu help Mostrar esta ayuda
|
|
40
|
-
|
|
41
|
-
Opciones:
|
|
42
|
-
--help, -h Mostrar esta ayuda
|
|
43
|
-
--version, -v Mostrar la version actual del CLI
|
|
44
|
-
`;
|
|
45
|
-
|
|
46
|
-
const main = async () => {
|
|
47
|
-
const args = process.argv.slice(2);
|
|
48
|
-
const command = args[0];
|
|
49
|
-
const subcommand = args[1];
|
|
50
|
-
const skipBuild = args.includes("--skip-build");
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
command === "version" ||
|
|
54
|
-
command === "--version" ||
|
|
55
|
-
command === "-v"
|
|
56
|
-
) {
|
|
57
|
-
console.log(getCurrentVersion());
|
|
58
|
-
process.exit(0);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
!command ||
|
|
63
|
-
command === "help" ||
|
|
64
|
-
command === "--help" ||
|
|
65
|
-
command === "-h"
|
|
66
|
-
) {
|
|
67
|
-
console.log(HELP.trim());
|
|
68
|
-
process.exit(0);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (command === "check-updates") {
|
|
72
|
-
await checkForUpdatesNow();
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
await checkForUpdates();
|
|
77
|
-
|
|
78
|
-
if (command === "init") {
|
|
79
|
-
await init();
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (command === "pull") {
|
|
84
|
-
await pull();
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (command === "push") {
|
|
89
|
-
await push({ skipBuild });
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (command === "dev") {
|
|
94
|
-
await dev();
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (command === "publish") {
|
|
99
|
-
await publish({ skipBuild });
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (command === "preview") {
|
|
104
|
-
if (subcommand === "create") {
|
|
105
|
-
const name = args[2];
|
|
106
|
-
await previewCreate(name);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (subcommand === "list") {
|
|
111
|
-
await previewList();
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (subcommand === "delete") {
|
|
116
|
-
await previewDelete();
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (subcommand === "open") {
|
|
121
|
-
await previewOpen();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
console.error(`Subcomando desconocido: preview ${subcommand ?? "(vacío)"}`);
|
|
126
|
-
console.log(HELP.trim());
|
|
127
|
-
process.exit(1);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
console.error(`Comando desconocido: ${command}`);
|
|
131
|
-
console.log(HELP.trim());
|
|
132
|
-
process.exit(1);
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
main().catch((error) => {
|
|
136
|
-
console.error(error.message || error);
|
|
137
|
-
process.exit(1);
|
|
138
|
-
});
|
|
3
|
+
import "./tiendu.js";
|
package/lib/api.mjs
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
* @param {string} apiBaseUrl
|
|
3
3
|
* @param {string} apiKey
|
|
4
4
|
* @param {string} path
|
|
5
|
-
* @param {{ method?: string, body?: string | Buffer | Uint8Array, contentType?: string }} [options]
|
|
5
|
+
* @param {{ method?: string, body?: string | Buffer | Uint8Array, contentType?: string, signal?: AbortSignal }} [options]
|
|
6
6
|
* @returns {Promise<Response>}
|
|
7
7
|
*/
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
const isRetriableStatus = (status) =>
|
|
11
|
+
status === 408 || status === 425 || status === 429 || status >= 500;
|
|
12
|
+
|
|
8
13
|
export const apiFetch = (apiBaseUrl, apiKey, path, options = {}) => {
|
|
9
14
|
const url = `${apiBaseUrl}${path}`;
|
|
10
15
|
/** @type {Record<string, string>} */
|
|
@@ -22,6 +27,7 @@ export const apiFetch = (apiBaseUrl, apiKey, path, options = {}) => {
|
|
|
22
27
|
method: options.method ?? "GET",
|
|
23
28
|
headers,
|
|
24
29
|
body: options.body,
|
|
30
|
+
signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
25
31
|
});
|
|
26
32
|
};
|
|
27
33
|
|
|
@@ -126,7 +132,7 @@ export const downloadStorefrontArchive = async (
|
|
|
126
132
|
* @param {number} storeId
|
|
127
133
|
* @param {string} previewKey
|
|
128
134
|
* @param {Buffer} zipBuffer
|
|
129
|
-
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
|
135
|
+
* @returns {Promise<{ ok: true } | { ok: false, error: string, retriable?: boolean }>}
|
|
130
136
|
*/
|
|
131
137
|
export const uploadPreviewZip = async (
|
|
132
138
|
apiBaseUrl,
|
|
@@ -152,51 +158,7 @@ export const uploadPreviewZip = async (
|
|
|
152
158
|
return {
|
|
153
159
|
ok: false,
|
|
154
160
|
error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return { ok: true };
|
|
159
|
-
} catch (error) {
|
|
160
|
-
return { ok: false, error: `No se pudo subir: ${error.message}` };
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Upload a single file to a preview.
|
|
166
|
-
*
|
|
167
|
-
* @param {string} apiBaseUrl
|
|
168
|
-
* @param {string} apiKey
|
|
169
|
-
* @param {number} storeId
|
|
170
|
-
* @param {string} previewKey
|
|
171
|
-
* @param {string} filePath - relative path within the preview
|
|
172
|
-
* @param {string} content - file content
|
|
173
|
-
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
|
174
|
-
*/
|
|
175
|
-
export const uploadPreviewFile = async (
|
|
176
|
-
apiBaseUrl,
|
|
177
|
-
apiKey,
|
|
178
|
-
storeId,
|
|
179
|
-
previewKey,
|
|
180
|
-
filePath,
|
|
181
|
-
content,
|
|
182
|
-
) => {
|
|
183
|
-
try {
|
|
184
|
-
const query = new URLSearchParams({ path: filePath }).toString();
|
|
185
|
-
const response = await apiFetch(
|
|
186
|
-
apiBaseUrl,
|
|
187
|
-
apiKey,
|
|
188
|
-
`/api/v2/stores/${storeId}/theme-previews/${previewKey}/file?${query}`,
|
|
189
|
-
{
|
|
190
|
-
method: "PUT",
|
|
191
|
-
body: JSON.stringify({ content }),
|
|
192
|
-
},
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
if (!response.ok) {
|
|
196
|
-
const body = await response.text().catch(() => "");
|
|
197
|
-
return {
|
|
198
|
-
ok: false,
|
|
199
|
-
error: `Error subiendo ${filePath}: ${response.status}${body ? ` — ${body}` : ""}`,
|
|
161
|
+
retriable: isRetriableStatus(response.status),
|
|
200
162
|
};
|
|
201
163
|
}
|
|
202
164
|
|
|
@@ -204,7 +166,8 @@ export const uploadPreviewFile = async (
|
|
|
204
166
|
} catch (error) {
|
|
205
167
|
return {
|
|
206
168
|
ok: false,
|
|
207
|
-
error: `
|
|
169
|
+
error: `No se pudo subir: ${error.message}`,
|
|
170
|
+
retriable: true,
|
|
208
171
|
};
|
|
209
172
|
}
|
|
210
173
|
};
|
|
@@ -219,7 +182,7 @@ export const uploadPreviewFile = async (
|
|
|
219
182
|
* @param {string} previewKey
|
|
220
183
|
* @param {string} relativePath
|
|
221
184
|
* @param {Buffer} fileBuffer
|
|
222
|
-
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
|
185
|
+
* @returns {Promise<{ ok: true } | { ok: false, error: string, retriable?: boolean }>}
|
|
223
186
|
*/
|
|
224
187
|
export const uploadPreviewFileMultipart = async (
|
|
225
188
|
apiBaseUrl,
|
|
@@ -249,6 +212,7 @@ export const uploadPreviewFileMultipart = async (
|
|
|
249
212
|
Authorization: `Bearer ${apiKey}`,
|
|
250
213
|
},
|
|
251
214
|
body: formData,
|
|
215
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
252
216
|
},
|
|
253
217
|
);
|
|
254
218
|
|
|
@@ -257,6 +221,7 @@ export const uploadPreviewFileMultipart = async (
|
|
|
257
221
|
return {
|
|
258
222
|
ok: false,
|
|
259
223
|
error: `Error subiendo ${relativePath}: ${response.status}${body ? ` — ${body}` : ""}`,
|
|
224
|
+
retriable: isRetriableStatus(response.status),
|
|
260
225
|
};
|
|
261
226
|
}
|
|
262
227
|
|
|
@@ -265,6 +230,7 @@ export const uploadPreviewFileMultipart = async (
|
|
|
265
230
|
return {
|
|
266
231
|
ok: false,
|
|
267
232
|
error: `Error subiendo ${relativePath}: ${error.message}`,
|
|
233
|
+
retriable: true,
|
|
268
234
|
};
|
|
269
235
|
}
|
|
270
236
|
};
|
|
@@ -277,7 +243,7 @@ export const uploadPreviewFileMultipart = async (
|
|
|
277
243
|
* @param {number} storeId
|
|
278
244
|
* @param {string} previewKey
|
|
279
245
|
* @param {string} filePath
|
|
280
|
-
* @returns {Promise<{ ok: true } | { ok: false, error: string }>}
|
|
246
|
+
* @returns {Promise<{ ok: true } | { ok: false, error: string, retriable?: boolean }>}
|
|
281
247
|
*/
|
|
282
248
|
export const deletePreviewFile = async (
|
|
283
249
|
apiBaseUrl,
|
|
@@ -302,6 +268,7 @@ export const deletePreviewFile = async (
|
|
|
302
268
|
return {
|
|
303
269
|
ok: false,
|
|
304
270
|
error: `Error eliminando ${filePath}: ${response.status}${body ? ` — ${body}` : ""}`,
|
|
271
|
+
retriable: isRetriableStatus(response.status),
|
|
305
272
|
};
|
|
306
273
|
}
|
|
307
274
|
|
|
@@ -310,6 +277,7 @@ export const deletePreviewFile = async (
|
|
|
310
277
|
return {
|
|
311
278
|
ok: false,
|
|
312
279
|
error: `Error eliminando ${filePath}: ${error.message}`,
|
|
280
|
+
retriable: true,
|
|
313
281
|
};
|
|
314
282
|
}
|
|
315
283
|
};
|
package/lib/archive.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { zipSync } from "fflate";
|
|
4
|
+
import { listFilesRecursive } from "./fs-utils.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} rootDir
|
|
8
|
+
* @returns {Promise<string[]>}
|
|
9
|
+
*/
|
|
10
|
+
export const listAllFiles = async (rootDir) => listFilesRecursive(rootDir);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} rootDir
|
|
14
|
+
* @returns {Promise<Buffer>}
|
|
15
|
+
*/
|
|
16
|
+
export const createZipFromDirectory = async (rootDir) => {
|
|
17
|
+
const absoluteFiles = await listAllFiles(rootDir);
|
|
18
|
+
/** @type {Record<string, Uint8Array>} */
|
|
19
|
+
const entries = {};
|
|
20
|
+
|
|
21
|
+
for (const absolutePath of absoluteFiles) {
|
|
22
|
+
const relativePath = path
|
|
23
|
+
.relative(rootDir, absolutePath)
|
|
24
|
+
.split(path.sep)
|
|
25
|
+
.join("/");
|
|
26
|
+
entries[relativePath] = new Uint8Array(await readFile(absolutePath));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return Buffer.from(zipSync(entries, { level: 6 }));
|
|
30
|
+
};
|
package/lib/assets.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { copyFile, mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileExists, listFilesRecursive } from "./fs-utils.mjs";
|
|
4
|
+
|
|
5
|
+
const STATIC_ASSET_SOURCE_DIRS = ["src/assets", "assets"];
|
|
6
|
+
const ASSET_IMPORT_EXTENSIONS = [
|
|
7
|
+
".apng",
|
|
8
|
+
".avif",
|
|
9
|
+
".bmp",
|
|
10
|
+
".gif",
|
|
11
|
+
".ico",
|
|
12
|
+
".jpeg",
|
|
13
|
+
".jpg",
|
|
14
|
+
".mp3",
|
|
15
|
+
".mp4",
|
|
16
|
+
".ogg",
|
|
17
|
+
".otf",
|
|
18
|
+
".pdf",
|
|
19
|
+
".png",
|
|
20
|
+
".svg",
|
|
21
|
+
".ttf",
|
|
22
|
+
".wav",
|
|
23
|
+
".webm",
|
|
24
|
+
".webp",
|
|
25
|
+
".woff",
|
|
26
|
+
".woff2",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const toPosixPath = (value) => value.split(path.sep).join("/");
|
|
30
|
+
|
|
31
|
+
const hasAssetExtension = (filePath) =>
|
|
32
|
+
ASSET_IMPORT_EXTENSIONS.includes(path.extname(filePath).toLowerCase());
|
|
33
|
+
|
|
34
|
+
const staticAssetSourceDirsCache = new Map();
|
|
35
|
+
|
|
36
|
+
const buildAssetSourceInfo = (resolvedPath, logicalPath, sourceDir) => ({
|
|
37
|
+
absolutePath: resolvedPath,
|
|
38
|
+
logicalPath,
|
|
39
|
+
outputRelativePath: getFlattenedAssetRelativePath(logicalPath),
|
|
40
|
+
sourceDir: sourceDir.relativeDir,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const resolveAssetSourceInfo = (sourceDirs, absolutePath) => {
|
|
44
|
+
const resolvedPath = path.resolve(absolutePath);
|
|
45
|
+
|
|
46
|
+
for (const sourceDir of sourceDirs) {
|
|
47
|
+
const relativePath = path.relative(sourceDir.absoluteDir, resolvedPath);
|
|
48
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return buildAssetSourceInfo(resolvedPath, toPosixPath(relativePath), sourceDir);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const resolveAssetSourceInfoFromRelativePath = (rootDir, sourceDirs, relativePath) => {
|
|
59
|
+
const normalizedRelativePath = toPosixPath(relativePath);
|
|
60
|
+
|
|
61
|
+
for (const sourceDir of sourceDirs) {
|
|
62
|
+
const sourcePrefix = `${sourceDir.relativeDir}/`;
|
|
63
|
+
if (!normalizedRelativePath.startsWith(sourcePrefix)) continue;
|
|
64
|
+
|
|
65
|
+
const logicalPath = normalizedRelativePath.slice(sourcePrefix.length);
|
|
66
|
+
return buildAssetSourceInfo(
|
|
67
|
+
path.join(rootDir, normalizedRelativePath),
|
|
68
|
+
logicalPath,
|
|
69
|
+
sourceDir,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const getStaticAssetSourceDirs = async (rootDir, { refresh = false } = {}) => {
|
|
77
|
+
if (!refresh && staticAssetSourceDirsCache.has(rootDir)) {
|
|
78
|
+
return staticAssetSourceDirsCache.get(rootDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const directories = [];
|
|
82
|
+
|
|
83
|
+
for (const relativeDir of STATIC_ASSET_SOURCE_DIRS) {
|
|
84
|
+
const absoluteDir = path.join(rootDir, relativeDir);
|
|
85
|
+
if (!(await fileExists(absoluteDir))) continue;
|
|
86
|
+
|
|
87
|
+
directories.push({
|
|
88
|
+
absoluteDir,
|
|
89
|
+
relativeDir: toPosixPath(relativeDir),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
staticAssetSourceDirsCache.set(rootDir, directories);
|
|
94
|
+
return directories;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const normalizeAssetLogicalPath = (input) => {
|
|
98
|
+
if (typeof input !== "string") return "";
|
|
99
|
+
|
|
100
|
+
let normalized = input.trim().replace(/^\/+/, "").replace(/\\/g, "/");
|
|
101
|
+
|
|
102
|
+
if (normalized.startsWith("src/assets/")) {
|
|
103
|
+
normalized = normalized.slice("src/assets/".length);
|
|
104
|
+
} else if (normalized.startsWith("assets/") && !normalized.startsWith("assets/assets/")) {
|
|
105
|
+
normalized = normalized.slice("assets/".length);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return normalized.replace(/^\/+/, "");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const flattenAssetLogicalPath = (logicalPath) => {
|
|
112
|
+
const normalized = normalizeAssetLogicalPath(logicalPath);
|
|
113
|
+
if (!normalized) return "";
|
|
114
|
+
|
|
115
|
+
return normalized.split("/").filter(Boolean).join("___");
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const getFlattenedAssetRelativePath = (logicalPath) => {
|
|
119
|
+
const flattenedName = flattenAssetLogicalPath(logicalPath);
|
|
120
|
+
return flattenedName ? `assets/${flattenedName}` : "";
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const getAssetSourceInfo = async (rootDir, absolutePath, sourceDirs) => {
|
|
124
|
+
return resolveAssetSourceInfo(
|
|
125
|
+
sourceDirs ?? await getStaticAssetSourceDirs(rootDir),
|
|
126
|
+
absolutePath,
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const getAssetSourceInfoFromRelativePath = async (rootDir, relativePath, sourceDirs) => {
|
|
131
|
+
return resolveAssetSourceInfoFromRelativePath(
|
|
132
|
+
rootDir,
|
|
133
|
+
sourceDirs ?? await getStaticAssetSourceDirs(rootDir),
|
|
134
|
+
relativePath,
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const getAssetImportFilter = () =>
|
|
139
|
+
new RegExp(`\\.(${ASSET_IMPORT_EXTENSIONS.map((extension) => extension.slice(1)).join("|")})$`, "i");
|
|
140
|
+
|
|
141
|
+
export const isSupportedAssetImport = (filePath) => hasAssetExtension(filePath);
|
|
142
|
+
|
|
143
|
+
export const rewriteCssAssetUrls = async (source, cssFilePath, rootDir, sourceDirs) => {
|
|
144
|
+
const CSS_URL_PATTERN = /url\(\s*(['"]?)([^'"()]+)\1\s*\)/g;
|
|
145
|
+
const resolvedSourceDirs = sourceDirs ?? await getStaticAssetSourceDirs(rootDir);
|
|
146
|
+
|
|
147
|
+
return source.replace(CSS_URL_PATTERN, (fullMatch, _quote, rawValue) => {
|
|
148
|
+
const rawPath = rawValue?.trim() ?? "";
|
|
149
|
+
if (!rawPath || rawPath.startsWith("data:") || rawPath.startsWith("http://") || rawPath.startsWith("https://") || rawPath.startsWith("/assets/") || rawPath.startsWith("#")) {
|
|
150
|
+
return fullMatch;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const resolvedPath = path.resolve(path.dirname(cssFilePath), rawPath);
|
|
154
|
+
const assetInfo = resolveAssetSourceInfo(resolvedSourceDirs, resolvedPath);
|
|
155
|
+
if (!assetInfo) return fullMatch;
|
|
156
|
+
|
|
157
|
+
return `url("/${assetInfo.outputRelativePath}")`;
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const syncStaticAssets = async (rootDir, distDir, reservedOutputPaths = new Set(), sourceDirs) => {
|
|
162
|
+
const resolvedSourceDirs = sourceDirs ?? await getStaticAssetSourceDirs(rootDir);
|
|
163
|
+
const outputDir = path.join(distDir, "assets");
|
|
164
|
+
await mkdir(outputDir, { recursive: true });
|
|
165
|
+
|
|
166
|
+
const claimedOutputs = new Map();
|
|
167
|
+
|
|
168
|
+
for (const sourceDir of resolvedSourceDirs) {
|
|
169
|
+
const absoluteFiles = await listFilesRecursive(sourceDir.absoluteDir);
|
|
170
|
+
|
|
171
|
+
for (const absolutePath of absoluteFiles) {
|
|
172
|
+
const assetInfo = resolveAssetSourceInfo(resolvedSourceDirs, absolutePath);
|
|
173
|
+
if (!assetInfo) continue;
|
|
174
|
+
|
|
175
|
+
if (reservedOutputPaths.has(assetInfo.outputRelativePath)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Asset ${assetInfo.logicalPath} conflicts with a generated bundle at ${assetInfo.outputRelativePath}.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const previousOwner = claimedOutputs.get(assetInfo.outputRelativePath);
|
|
182
|
+
if (previousOwner && previousOwner !== assetInfo.logicalPath) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Asset collision: ${previousOwner} and ${assetInfo.logicalPath} both flatten to ${assetInfo.outputRelativePath}.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
claimedOutputs.set(assetInfo.outputRelativePath, assetInfo.logicalPath);
|
|
189
|
+
|
|
190
|
+
const outputPath = path.join(distDir, assetInfo.outputRelativePath);
|
|
191
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
192
|
+
await copyFile(absolutePath, outputPath);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return claimedOutputs;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const syncSingleStaticAsset = async (
|
|
200
|
+
rootDir,
|
|
201
|
+
distDir,
|
|
202
|
+
relativePath,
|
|
203
|
+
reservedOutputPaths = new Set(),
|
|
204
|
+
claimedOutputs = new Map(),
|
|
205
|
+
sourceDirs,
|
|
206
|
+
) => {
|
|
207
|
+
const resolvedSourceDirs = sourceDirs ?? await getStaticAssetSourceDirs(rootDir);
|
|
208
|
+
const assetInfo = resolveAssetSourceInfoFromRelativePath(rootDir, resolvedSourceDirs, relativePath);
|
|
209
|
+
if (!assetInfo) return null;
|
|
210
|
+
|
|
211
|
+
if (reservedOutputPaths.has(assetInfo.outputRelativePath)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Asset ${assetInfo.logicalPath} conflicts with a generated bundle at ${assetInfo.outputRelativePath}.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const outputPath = path.join(distDir, assetInfo.outputRelativePath);
|
|
218
|
+
const sourceExists = await fileExists(assetInfo.absolutePath);
|
|
219
|
+
|
|
220
|
+
if (!sourceExists) {
|
|
221
|
+
claimedOutputs.delete(assetInfo.outputRelativePath);
|
|
222
|
+
await rm(outputPath, { force: true });
|
|
223
|
+
return {
|
|
224
|
+
type: "delete",
|
|
225
|
+
logicalPath: assetInfo.logicalPath,
|
|
226
|
+
outputRelativePath: assetInfo.outputRelativePath,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const previousOwner = claimedOutputs.get(assetInfo.outputRelativePath);
|
|
231
|
+
if (previousOwner && previousOwner !== assetInfo.logicalPath) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Asset collision: ${previousOwner} and ${assetInfo.logicalPath} both flatten to ${assetInfo.outputRelativePath}.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
238
|
+
await copyFile(assetInfo.absolutePath, outputPath);
|
|
239
|
+
claimedOutputs.set(assetInfo.outputRelativePath, assetInfo.logicalPath);
|
|
240
|
+
return {
|
|
241
|
+
type: "copy",
|
|
242
|
+
logicalPath: assetInfo.logicalPath,
|
|
243
|
+
outputRelativePath: assetInfo.outputRelativePath,
|
|
244
|
+
};
|
|
245
|
+
};
|