htmlhost-cli 1.4.1 → 1.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/assets.mjs CHANGED
@@ -1,6 +1,14 @@
1
1
  /**
2
- * Asset inlining — scan HTML for local asset references, upload them,
3
- * and rewrite the HTML to point at the hosted URLs.
2
+ * Asset processing — scan HTML for local asset references, inline CSS/JS,
3
+ * upload binary assets (images, fonts, PDFs, SVGs), and rewrite the HTML.
4
+ *
5
+ * Strategy:
6
+ * CSS/JS files (< 512 KB) → inlined directly into the HTML
7
+ * CSS/JS files (≥ 512 KB) → uploaded to media library, URL rewritten
8
+ * Binary assets → uploaded to media library, URL rewritten
9
+ *
10
+ * Before inlining CSS, all url() references inside the CSS are resolved,
11
+ * uploaded, and rewritten so the inlined stylesheet is fully self-contained.
4
12
  *
5
13
  * Handles: <script src>, <link href>, <img src>, <img srcset>,
6
14
  * <source src/srcset>, <video src/poster>, <audio src>,
@@ -10,15 +18,19 @@
10
18
  * anchors (#), and files > 10 MB.
11
19
  */
12
20
  import { readFileSync, statSync, existsSync } from "node:fs";
13
- import { resolve, dirname, basename } from "node:path";
21
+ import { resolve, dirname, basename, extname } from "node:path";
14
22
  import { uploadFile } from "./api.mjs";
15
23
  import { getApi } from "./config.mjs";
16
24
  import { ok, warn, dim, cyan, formatBytes, mimeFromExt } from "./ui.mjs";
17
25
 
18
26
  const MAX_ASSET_BYTES = 10 * 1024 * 1024; // 10 MB
27
+ const INLINE_THRESHOLD = 512 * 1024; // 512 KB — inline CSS/JS below this
28
+
29
+ /** File extensions that should be inlined into the HTML instead of uploaded. */
30
+ const INLINE_EXTENSIONS = new Set([".css", ".js", ".mjs"]);
19
31
 
20
32
  /**
21
- * Returns true for references we should NOT try to upload.
33
+ * Returns true for references we should NOT try to process.
22
34
  */
23
35
  function isExternal(ref) {
24
36
  if (!ref || !ref.trim()) return true;
@@ -37,8 +49,6 @@ function isExternal(ref) {
37
49
  /**
38
50
  * Given a base directory and a relative path, resolve it and check
39
51
  * it exists, is a file, and is under the size limit.
40
- *
41
- * Returns { filePath, skip, reason } — if skip is true, reason explains why.
42
52
  */
43
53
  function resolveAsset(baseDir, ref) {
44
54
  const cleaned = ref.split("?")[0].split("#")[0]; // strip query/hash
@@ -68,35 +78,121 @@ function resolveAsset(baseDir, ref) {
68
78
  return { filePath, size: stat.size, skip: false };
69
79
  }
70
80
 
81
+ /**
82
+ * Check if a file should be inlined based on extension and size.
83
+ */
84
+ function shouldInline(filePath, fileSize) {
85
+ const ext = extname(filePath).toLowerCase();
86
+ return INLINE_EXTENSIONS.has(ext) && fileSize < INLINE_THRESHOLD;
87
+ }
88
+
89
+ /**
90
+ * Extract url(…) references from a CSS string.
91
+ */
92
+ function extractCssUrls(css) {
93
+ const urlRe = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi;
94
+ const refs = [];
95
+ let m;
96
+ while ((m = urlRe.exec(css)) !== null) {
97
+ const val = (m[1] ?? m[2] ?? m[3] ?? "").trim();
98
+ if (val && !isExternal(val)) {
99
+ refs.push(val);
100
+ }
101
+ }
102
+ return refs;
103
+ }
104
+
105
+ /**
106
+ * Process a CSS file's url() references — upload assets and rewrite URLs.
107
+ * Returns the rewritten CSS content.
108
+ */
109
+ async function processCssContent(css, cssDir, sharedCache, urlMap) {
110
+ const refs = extractCssUrls(css);
111
+ if (refs.length === 0) return css;
112
+
113
+ const api = getApi();
114
+
115
+ for (const ref of refs) {
116
+ if (urlMap.has(ref)) continue;
117
+
118
+ const asset = resolveAsset(cssDir, ref);
119
+ if (asset.skip) {
120
+ warn(` CSS url() ${cyan(ref)} — ${asset.reason}`);
121
+ continue;
122
+ }
123
+
124
+ // Check shared cache
125
+ if (sharedCache && sharedCache.has(asset.filePath)) {
126
+ urlMap.set(ref, sharedCache.get(asset.filePath));
127
+ continue;
128
+ }
129
+
130
+ const fileName = basename(asset.filePath);
131
+ const mime = mimeFromExt(fileName);
132
+
133
+ try {
134
+ const data = await uploadFile("/api/media", asset.filePath, fileName, mime);
135
+ const hostedUrl = `${api}${data.url}`;
136
+ urlMap.set(ref, hostedUrl);
137
+ if (sharedCache) sharedCache.set(asset.filePath, hostedUrl);
138
+ ok(` ${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${hostedUrl}`);
139
+ } catch (e) {
140
+ warn(` Failed to upload ${cyan(ref)}: ${e.message}`);
141
+ }
142
+ }
143
+
144
+ // Rewrite all url() references in the CSS
145
+ let result = css;
146
+ for (const [ref, hostedUrl] of urlMap) {
147
+ result = replaceAll(result, `"${ref}"`, `"${hostedUrl}"`);
148
+ result = replaceAll(result, `'${ref}'`, `'${hostedUrl}'`);
149
+ result = replaceAll(result, `url(${ref})`, `url(${hostedUrl})`);
150
+ }
151
+
152
+ return result;
153
+ }
154
+
71
155
  /**
72
156
  * Scan HTML string for local asset references.
73
- * Returns an array of { original, ref } entries where `original` is the
74
- * exact attribute value substring and `ref` is the cleaned path.
157
+ * Returns an array of { original, ref, tagType } entries.
75
158
  */
76
159
  function findAssetRefs(html) {
77
160
  const refs = new Set();
78
161
  const entries = [];
79
162
 
80
- function add(original) {
163
+ function add(original, tagType = "attr") {
81
164
  const ref = original.trim();
82
165
  if (isExternal(ref)) return;
83
166
  if (refs.has(ref)) return; // dedupe
84
167
  refs.add(ref);
85
- entries.push({ original, ref });
168
+ entries.push({ original, ref, tagType });
86
169
  }
87
170
 
88
- // Match src="" and href="…" on relevant tags.
89
- // Capture: <script src="x">, <img src="x">, <source src="x">,
90
- // <video src="x" poster="x">, <audio src="x">,
91
- // <link href="x"> (only stylesheets/icons/preloads)
92
- // We intentionally use a broad regex and then filter.
93
-
94
- // --- Tag attributes: src, href, poster ---
95
- const attrRe = /<(?:script|img|source|video|audio|link|embed|track)\b[^>]*?\b(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
171
+ // --- <script src="..."> ---
172
+ const scriptRe = /<script\b[^>]*?\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
96
173
  let m;
174
+ while ((m = scriptRe.exec(html)) !== null) {
175
+ const val = m[1] ?? m[2];
176
+ if (val) add(val, "script");
177
+ }
178
+
179
+ // --- <link href="..." rel="stylesheet"> ---
180
+ const linkRe = /<link\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
181
+ while ((m = linkRe.exec(html)) !== null) {
182
+ const val = m[1] ?? m[2];
183
+ const fullTag = m[0];
184
+ if (val) {
185
+ // Only treat as CSS if it's a stylesheet link
186
+ const isStylesheet = /rel\s*=\s*["']stylesheet["']/i.test(fullTag);
187
+ add(val, isStylesheet ? "css" : "attr");
188
+ }
189
+ }
190
+
191
+ // --- Other tag attributes: src, href, poster (non-script, non-link) ---
192
+ const attrRe = /<(?:img|source|video|audio|embed|track)\b[^>]*?\b(?:src|href|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
97
193
  while ((m = attrRe.exec(html)) !== null) {
98
194
  const val = m[1] ?? m[2];
99
- if (val) add(val);
195
+ if (val) add(val, "attr");
100
196
  }
101
197
 
102
198
  // --- srcset attributes (img, source) ---
@@ -104,48 +200,39 @@ function findAssetRefs(html) {
104
200
  while ((m = srcsetRe.exec(html)) !== null) {
105
201
  const srcset = m[1] ?? m[2];
106
202
  if (!srcset) continue;
107
- // srcset is comma-separated: "path 2x, path2 300w"
108
203
  for (const part of srcset.split(",")) {
109
204
  const src = part.trim().split(/\s+/)[0];
110
- if (src) add(src);
205
+ if (src) add(src, "attr");
111
206
  }
112
207
  }
113
208
 
114
209
  // --- CSS url() in <style> blocks ---
115
210
  const styleBlockRe = /<style[^>]*>([\s\S]*?)<\/style>/gi;
116
211
  while ((m = styleBlockRe.exec(html)) !== null) {
117
- extractCssUrls(m[1], add);
212
+ const urls = extractCssUrls(m[1]);
213
+ for (const u of urls) add(u, "css-url");
118
214
  }
119
215
 
120
216
  // --- CSS url() in inline style="…" attributes ---
121
217
  const inlineStyleRe = /\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
122
218
  while ((m = inlineStyleRe.exec(html)) !== null) {
123
219
  const css = m[1] ?? m[2];
124
- if (css) extractCssUrls(css, add);
220
+ if (css) {
221
+ const urls = extractCssUrls(css);
222
+ for (const u of urls) add(u, "css-url");
223
+ }
125
224
  }
126
225
 
127
226
  return entries;
128
227
  }
129
228
 
130
229
  /**
131
- * Extract url(…) references from a CSS string.
132
- */
133
- function extractCssUrls(css, add) {
134
- const urlRe = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/gi;
135
- let m;
136
- while ((m = urlRe.exec(css)) !== null) {
137
- const val = (m[1] ?? m[2] ?? m[3] ?? "").trim();
138
- if (val) add(val);
139
- }
140
- }
141
-
142
- /**
143
- * Main entry: process HTML, upload local assets, return rewritten HTML.
230
+ * Main entry: process HTML, inline CSS/JS, upload binary assets, return rewritten HTML.
144
231
  *
145
232
  * @param {string} html The original HTML content
146
- * @param {string} baseDir Directory the HTML file lives in (for resolving relative paths)
147
- * @param {Map<string, string>} [sharedCache] Optional cache of filePath → hostedUrl for batch deploys
148
- * @returns {Promise<string>} Rewritten HTML with hosted asset URLs
233
+ * @param {string} baseDir Directory the HTML file lives in
234
+ * @param {Map<string, string>} [sharedCache] Optional filePath → hostedUrl cache for batch deploys
235
+ * @returns {Promise<string>} Rewritten HTML with inlined/hosted assets
149
236
  */
150
237
  export async function processAssets(html, baseDir, sharedCache) {
151
238
  const entries = findAssetRefs(html);
@@ -154,48 +241,82 @@ export async function processAssets(html, baseDir, sharedCache) {
154
241
 
155
242
  const api = getApi();
156
243
 
157
- // Collect unique refsupload once, build a replacement map
158
- /** @type {Map<string, string>} ref → hosted URL */
244
+ /** @type {Map<string, string>} ref hosted URL (for binary uploads) */
159
245
  const urlMap = new Map();
160
246
 
161
- // Count how many actually need uploading (not already cached)
162
- let needsUpload = 0;
247
+ /** @type {Map<string, {content: string, size: number}>} ref → inline content (for CSS/JS) */
248
+ const inlineMap = new Map();
249
+
250
+ // --- Phase 1: Categorize and process each reference ---
251
+
252
+ let uploadCount = 0;
253
+ let inlineCount = 0;
254
+ let cachedCount = 0;
255
+
256
+ // Pre-scan to count
163
257
  for (const { ref } of entries) {
164
- if (urlMap.has(ref)) continue;
258
+ if (urlMap.has(ref) || inlineMap.has(ref)) continue;
165
259
  const asset = resolveAsset(baseDir, ref);
166
260
  if (asset.skip) continue;
167
261
  if (sharedCache && sharedCache.has(asset.filePath)) {
168
- urlMap.set(ref, sharedCache.get(asset.filePath));
169
- } else {
170
- needsUpload++;
262
+ cachedCount++;
171
263
  }
172
264
  }
173
265
 
174
- const cachedCount = urlMap.size;
175
- const totalLocal = cachedCount + needsUpload;
266
+ const totalRefs = entries.filter((e) => {
267
+ const asset = resolveAsset(baseDir, e.ref);
268
+ return !asset.skip;
269
+ }).length;
176
270
 
177
- if (totalLocal > 0) {
271
+ if (totalRefs > 0) {
178
272
  console.log("");
179
- const label = totalLocal === 1 ? "local asset" : "local assets";
180
- if (cachedCount > 0 && needsUpload > 0) {
181
- ok(`Found ${cyan(String(totalLocal))} ${label} (${cachedCount} cached, ${needsUpload} to upload)`);
182
- } else if (cachedCount > 0) {
183
- ok(`Found ${cyan(String(totalLocal))} ${label} (all cached)`);
184
- } else {
185
- ok(`Found ${cyan(String(totalLocal))} ${label} to upload`);
186
- }
273
+ ok(`Found ${cyan(String(totalRefs))} local asset${totalRefs === 1 ? "" : "s"} to process`);
187
274
  console.log("");
188
275
  }
189
276
 
190
- for (const { ref } of entries) {
191
- if (urlMap.has(ref)) continue; // already resolved (from cache or previous ref)
277
+ for (const { ref, tagType } of entries) {
278
+ if (urlMap.has(ref) || inlineMap.has(ref)) continue;
192
279
 
193
280
  const asset = resolveAsset(baseDir, ref);
194
281
  if (asset.skip) {
195
- warn(`Skipping ${cyan(ref)} — ${asset.reason}`);
282
+ if (asset.reason !== "empty") warn(`Skipping ${cyan(ref)} — ${asset.reason}`);
196
283
  continue;
197
284
  }
198
285
 
286
+ // Check shared cache first
287
+ if (sharedCache && sharedCache.has(asset.filePath)) {
288
+ urlMap.set(ref, sharedCache.get(asset.filePath));
289
+ ok(`${cyan(ref)} ${dim("(cached)")}`);
290
+ continue;
291
+ }
292
+
293
+ const ext = extname(asset.filePath).toLowerCase();
294
+
295
+ // --- CSS file: inline (with nested url() processing) ---
296
+ if (tagType === "css" && shouldInline(asset.filePath, asset.size)) {
297
+ let cssContent = readFileSync(asset.filePath, "utf8");
298
+ const cssDir = dirname(asset.filePath);
299
+
300
+ // Process url() references inside the CSS file
301
+ const cssUrlMap = new Map();
302
+ cssContent = await processCssContent(cssContent, cssDir, sharedCache, cssUrlMap);
303
+
304
+ inlineMap.set(ref, { content: cssContent, size: asset.size });
305
+ inlineCount++;
306
+ ok(`${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${dim("inlined as <style>")}`);
307
+ continue;
308
+ }
309
+
310
+ // --- JS file: inline ---
311
+ if (tagType === "script" && shouldInline(asset.filePath, asset.size)) {
312
+ const jsContent = readFileSync(asset.filePath, "utf8");
313
+ inlineMap.set(ref, { content: jsContent, size: asset.size });
314
+ inlineCount++;
315
+ ok(`${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${dim("inlined as <script>")}`);
316
+ continue;
317
+ }
318
+
319
+ // --- Binary asset or large CSS/JS: upload to media library ---
199
320
  const fileName = basename(asset.filePath);
200
321
  const mime = mimeFromExt(fileName);
201
322
 
@@ -203,35 +324,66 @@ export async function processAssets(html, baseDir, sharedCache) {
203
324
  const data = await uploadFile("/api/media", asset.filePath, fileName, mime);
204
325
  const hostedUrl = `${api}${data.url}`;
205
326
  urlMap.set(ref, hostedUrl);
206
- // Store in shared cache by absolute path for cross-file dedup
207
- if (sharedCache) {
208
- sharedCache.set(asset.filePath, hostedUrl);
209
- }
327
+ if (sharedCache) sharedCache.set(asset.filePath, hostedUrl);
328
+ uploadCount++;
210
329
  ok(`${cyan(ref)} ${dim(`(${formatBytes(asset.size)})`)} → ${hostedUrl}`);
211
330
  } catch (e) {
212
331
  warn(`Failed to upload ${cyan(ref)}: ${e.message}`);
213
332
  }
214
333
  }
215
334
 
216
- if (urlMap.size === 0) return html;
335
+ if (urlMap.size === 0 && inlineMap.size === 0) return html;
336
+
337
+ // --- Phase 2: Rewrite the HTML ---
217
338
 
218
- // Replace all occurrences.
219
- // We do a simple, safe string replacement — find all attribute values matching
220
- // our refs and swap them with the hosted URL.
221
339
  let result = html;
222
340
 
341
+ // 2a. Inline CSS: replace <link rel="stylesheet" href="ref"> with <style>content</style>
342
+ for (const [ref, { content }] of inlineMap) {
343
+ const ext = extname(ref).toLowerCase();
344
+
345
+ if (ext === ".css") {
346
+ // Match <link ... href="ref" ...> (handles both quote styles, any attribute order)
347
+ const linkPattern = new RegExp(
348
+ `<link\\b[^>]*?\\bhref\\s*=\\s*(?:"${escapeRegex(ref)}"|'${escapeRegex(ref)}')[^>]*/?>`,
349
+ "gi"
350
+ );
351
+ result = result.replace(linkPattern, `<style>\n${content}\n</style>`);
352
+ }
353
+
354
+ if (ext === ".js" || ext === ".mjs") {
355
+ // Match <script src="ref" ...></script> (handles attributes before/after src)
356
+ const scriptPattern = new RegExp(
357
+ `<script\\b([^>]*?)\\bsrc\\s*=\\s*(?:"${escapeRegex(ref)}"|'${escapeRegex(ref)}')([^>]*)>\\s*</script>`,
358
+ "gi"
359
+ );
360
+ result = result.replace(scriptPattern, (_, before, after) => {
361
+ // Remove type="module" if present since inlined scripts don't need it for basic cases
362
+ // But keep it if the user explicitly set it — they might need ES module behavior
363
+ const attrs = (before + after).trim();
364
+ const cleanedAttrs = attrs
365
+ .replace(/\bsrc\s*=\s*(?:"[^"]*"|'[^']*')/gi, "")
366
+ .trim();
367
+ return `<script${cleanedAttrs ? " " + cleanedAttrs : ""}>\n${content}\n</script>`;
368
+ });
369
+ }
370
+ }
371
+
372
+ // 2b. Rewrite URL references for uploaded assets
223
373
  for (const [ref, hostedUrl] of urlMap) {
224
- // Replace in attribute values: ="ref" and ='ref'
225
- // Use a function replacer so we handle all occurrences correctly.
226
374
  result = replaceAll(result, `"${ref}"`, `"${hostedUrl}"`);
227
375
  result = replaceAll(result, `'${ref}'`, `'${hostedUrl}'`);
228
-
229
- // Replace in CSS url(): url(ref), url("ref"), url('ref')
230
- // The quoted forms are handled above. Handle the unquoted form too.
231
376
  result = replaceAll(result, `url(${ref})`, `url(${hostedUrl})`);
232
377
  }
233
378
 
379
+ // Summary
234
380
  console.log("");
381
+ if (inlineCount > 0) {
382
+ ok(`Inlined ${cyan(String(inlineCount))} file${inlineCount === 1 ? "" : "s"} into HTML`);
383
+ }
384
+ if (uploadCount > 0) {
385
+ ok(`Uploaded ${cyan(String(uploadCount))} asset${uploadCount === 1 ? "" : "s"} to media library`);
386
+ }
235
387
 
236
388
  return result;
237
389
  }
@@ -240,6 +392,12 @@ export async function processAssets(html, baseDir, sharedCache) {
240
392
  * Replace all occurrences of `search` in `str` with `replacement`.
241
393
  */
242
394
  function replaceAll(str, search, replacement) {
243
- // Use split/join for safe literal replacement (no regex escaping needed)
244
395
  return str.split(search).join(replacement);
245
396
  }
397
+
398
+ /**
399
+ * Escape a string for use in a RegExp.
400
+ */
401
+ function escapeRegex(str) {
402
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
403
+ }
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
 
7
- const VERSION = "1.4.1";
7
+ const VERSION = "1.5.0";
8
8
 
9
9
  const HELP = `
10
10
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -101,13 +101,31 @@ export async function deploy(args) {
101
101
  async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }) {
102
102
  const filePath = resolve(file);
103
103
  const projectDir = dirname(filePath);
104
+ const fileName = basename(filePath);
104
105
 
105
106
  let isLinked = false;
106
107
 
107
108
  if (!slug && !forceNew) {
108
109
  const link = readLink(projectDir);
109
- // Only use single-file link format (has slug at top level, not sites map)
110
- if (link?.slug && !link.sites) {
110
+
111
+ if (link?.sites) {
112
+ // Multi-file format — warn that only one file is being deployed
113
+ const siteCount = Object.keys(link.sites).length;
114
+ if (siteCount > 1) {
115
+ console.log("");
116
+ console.log(` ${yellow("⚠")} This project has ${bold(String(siteCount))} linked sites but you're deploying only ${cyan(fileName)}.`);
117
+ console.log(` ${dim(`To deploy all files, run: ${bold("htmlhost deploy .")}`)}`);
118
+ console.log("");
119
+ }
120
+ // Look up this file's entry in the sites map
121
+ const entry = link.sites[fileName];
122
+ if (entry?.slug) {
123
+ slug = entry.slug;
124
+ isLinked = true;
125
+ info(`Linked to ${cyan(slug)} ${dim(`(via .htmlhost sites map)`)}`);
126
+ }
127
+ } else if (link?.slug) {
128
+ // Single-file format (has slug at top level, no sites map)
111
129
  if (link.onRedeploy === "overwrite") {
112
130
  slug = link.slug;
113
131
  isLinked = true;
@@ -186,13 +204,21 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug }
186
204
 
187
205
  const data = await post("/api/sites", body);
188
206
 
189
- // Save/update the link (preserve onRedeploy preference)
207
+ // Save/update the link merge into existing .htmlhost instead of clobbering
190
208
  const existingLink = readLink(projectDir);
191
- writeLink(projectDir, {
192
- slug: data.slug,
193
- url: data.url,
194
- ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
195
- });
209
+
210
+ if (existingLink?.sites) {
211
+ // Multi-file format: merge this file's entry into the existing sites map
212
+ existingLink.sites[fileName] = { slug: data.slug, url: data.url };
213
+ writeLink(projectDir, existingLink);
214
+ } else {
215
+ // Single-file format: write the flat slug/url (preserve onRedeploy preference)
216
+ writeLink(projectDir, {
217
+ slug: data.slug,
218
+ url: data.url,
219
+ ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
220
+ });
221
+ }
196
222
 
197
223
  console.log("");
198
224
  ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
package/src/ui.mjs CHANGED
@@ -99,6 +99,7 @@ export function mimeFromExt(filename) {
99
99
  gif: "image/gif",
100
100
  svg: "image/svg+xml",
101
101
  webp: "image/webp",
102
+ avif: "image/avif",
102
103
  ico: "image/x-icon",
103
104
  woff: "font/woff",
104
105
  woff2: "font/woff2",
@@ -106,10 +107,12 @@ export function mimeFromExt(filename) {
106
107
  otf: "font/otf",
107
108
  css: "text/css",
108
109
  js: "text/javascript",
110
+ mjs: "text/javascript",
109
111
  json: "application/json",
110
112
  mp4: "video/mp4",
111
113
  webm: "video/webm",
112
114
  mp3: "audio/mpeg",
115
+ ogg: "audio/ogg",
113
116
  wav: "audio/wav",
114
117
  pdf: "application/pdf",
115
118
  };