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 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 shareable URL, and publish when you're ready — all from the terminal.
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, uploads the output, and watches for changes. It prints a shareable URL like:
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 (`layout/`, `templates/`, `snippets/`, `assets/`) to `dist/`
95
- 2. Discovers entry points in `src/layout/` and `src/templates/`
96
- 3. Bundles JS/TS and CSS via esbuild into `dist/assets/`
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 JS/TS/CSS in `src/` is bundled by esbuild into `dist/assets/`
292
- 2. Liquid files and static assets are copied from root to `dist/`
293
- 3. `dist/` is what gets uploaded it looks like a normal Tiendu theme
294
- 4. Liquid templates reference bundles via `asset_url` (e.g. `{{ 'layout-theme.bundle.js' | asset_url | script_tag }}`)
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 { init } from "../lib/init.mjs";
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: `Error subiendo ${filePath}: ${error.message}`,
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
  };
@@ -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
+ };