droppage 0.2.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +43 -8
  2. package/dist/cli.js +175 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,18 +38,18 @@ Open the printed link in any browser. That's it.
38
38
  |---|---|
39
39
  | `droppage login` | Authenticate with a magic link (session stored in `~/.droppage/config.json`). |
40
40
  | `droppage logout` | Sign out and clear local credentials. |
41
- | `droppage upload <file>` | Publish an `.html`/`.htm` file (and the local images it references) and print its URL. Options: `--password`, `--email <addr>`, `--private`, `--expires <when>`. |
42
- | `droppage update <slug> <file>` | Replace the HTML (and images) behind an existing URL. Keeps the URL, access mode, and stats. `--expires <when\|never>` changes the expiry. |
41
+ | `droppage upload <file>` | Publish an `.html`/`.htm` file (and the local images and videos it references) and print its URL. Options: `--password`, `--email <addr>`, `--private`, `--expires <when>`. |
42
+ | `droppage update <slug> <file>` | Replace the HTML (and images and videos) behind an existing URL. Keeps the URL, access mode, and stats. `--expires <when\|never>` changes the expiry. |
43
43
  | `droppage expire <slug> <when\|never>` | Set or clear when a page expires without re-uploading. |
44
44
  | `droppage list` | List your pages, including an EXPIRES column. `--json` for machines (rows have `expires_at`), `--preview` for no-count preview URLs. |
45
- | `droppage delete <slug>` | Delete a page and its images. `--yes` skips the confirmation prompt. |
45
+ | `droppage delete <slug>` | Delete a page and its images and videos. `--yes` skips the confirmation prompt. |
46
46
  | `droppage recipients list <slug>` | List recipients of an email-gated page (`--json`, `--page <n>`). |
47
47
  | `droppage recipients add <slug> <emails...>` | Add recipients and queue their invitation emails. |
48
48
  | `droppage recipients remove <slug> <email>` | Revoke a recipient; their invitation link stops working (`--yes`). |
49
49
  | `droppage recipients resend <slug> <email>` | Queue a fresh invitation for an existing recipient. |
50
- | `droppage image upload <files...> --page <slug>` | Advanced: add images (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each) to an existing page and print their URLs. `--json` prints `[{file,id,url,bytes,content_type,existing}]`. |
51
- | `droppage image list [--page <slug>]` | List your pages' images; with `--page`, one page and its image budget. `--json` for machines. |
52
- | `droppage image delete <id-or-url...>` | Delete images. Cached copies may still show for up to a day. |
50
+ | `droppage image upload <files...> --page <slug>` | Advanced: add images (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each) or videos (MP4, WebM; up to 20 MB each) to an existing page and print their URLs. Alias: `droppage media upload`. `--json` prints `[{file,id,url,bytes,content_type,existing}]`. |
51
+ | `droppage image list [--page <slug>]` | List your pages' images and videos; with `--page`, one page and its image budget. `--json` for machines. |
52
+ | `droppage image delete <id-or-url...>` | Delete images or videos. They stop being served within 5 minutes; a browser that already loaded one may keep it up to a day. |
53
53
  | `droppage quota` | Show the current plan, page usage, monthly meter, reset date, and enabled features. `--json` returns the account API body. |
54
54
  | `droppage feedback <message>` | Send feedback without requiring login. Options: `--kind idea|bug|review|other`, `--agent [model]`, `--contact <email>`, review-only `--rating <1-5>` and `--allow-quote`. Quote permission never publishes automatically. |
55
55
  | `droppage upgrade` | Install the latest CLI globally with npm and report the installed version. |
@@ -104,6 +104,9 @@ only the page URL.
104
104
 
105
105
  - `update` uploads any new local images, keeps the same URL for an unchanged image, and deletes
106
106
  the page's images the new HTML no longer uses.
107
+ - Besides `<img src>`, the CLI picks up local images in `srcset` on `<img>`/`<source>` (every
108
+ candidate, descriptors kept), `<link rel="icon"|"apple-touch-icon" href>`, and `url(...)` inside
109
+ `<style>` blocks (0.2.2+). Not picked up: `style="..."` attributes and `.ico` files.
107
110
  - Deleting a page, or the page expiring, removes its images too. Images never outlive their page.
108
111
  - Image budget per page by plan: Free 5 MB, Starter 20 MB, Builder 50 MB. Each image up to 4 MB.
109
112
  PNG, JPEG, GIF, WebP, AVIF, SVG. Image traffic counts toward your monthly bandwidth.
@@ -117,6 +120,38 @@ only the page URL.
117
120
  `{images:[{id,url,ext,content_type,bytes,name,created_at,page}], page?:{slug,used_bytes,limit_bytes}}`);
118
121
  `droppage image delete <id-or-url...>` removes them.
119
122
 
123
+ ## Videos
124
+
125
+ Short product films (CLI 0.3.0+) are hosted exactly like images. Reference local MP4 or WebM files,
126
+ and a poster image, in a `<video>`:
127
+
128
+ ```html
129
+ <video autoplay muted loop playsinline poster="still.webp">
130
+ <source src="film.webm" type="video/webm">
131
+ <source src="film.mp4" type="video/mp4">
132
+ </video>
133
+ ```
134
+
135
+ ```bash
136
+ droppage upload page.html
137
+ # stderr: droppage: image still.webp → https://droppage.dev/i/AbCdEfGhIjKl.webp
138
+ # stderr: droppage: video film.webm → https://droppage.dev/i/MnOpQrStUvWx.webm
139
+ # stderr: droppage: video film.mp4 → https://droppage.dev/i/YzAbCdEfGhIj.mp4
140
+ ```
141
+
142
+ - Picked up: `<video src>`, `<source src>` (inside `<video>`, and `<picture>` as before), and
143
+ `<video poster>`. Same folder rule as images.
144
+ - Each video up to 20 MB (20,000,000 bytes), checked by its file signature, not its name: MP4
145
+ (H.264 plays everywhere) and WebM (VP9). `.mov` and `.mkv` are refused; convert them first, e.g.
146
+ `ffmpeg -i in.mov -c:v libx264 -pix_fmt yuv420p -movflags +faststart -an film.mp4`.
147
+ - Videos share the page's image budget (Free 5 MB, Starter 20 MB, Builder 50 MB per page), are
148
+ pruned by `update` and deleted with the page, like images. Video traffic counts toward your
149
+ monthly bandwidth.
150
+ - Served from `https://droppage.dev/i/<id>.mp4|webm` with HTTP Range support (`206 Partial Content`),
151
+ so Safari, iOS and seeking work. Public to anyone with the URL, like images.
152
+ - No need to inline films as base64 any more. `droppage image upload film.mp4 --page <slug>` (or
153
+ `droppage media upload`) adds a video to an existing page.
154
+
120
155
  ## Expiry
121
156
 
122
157
  Pages can expire. Nothing expires unless you set it, on any plan.
@@ -156,9 +191,9 @@ team, a weekly KPI or stakeholder report, a client deliverable, a notebook expor
156
191
  droppage keeps a strict stream contract so it is safe to call from automation:
157
192
 
158
193
  - `upload` and `update` print **only the URL** on stdout, even when they upload images;
159
- `image upload` prints **only the image URL(s)**; `expire` prints **nothing** on stdout.
194
+ `image upload` prints **only the image or video URL(s)**; `expire` prints **nothing** on stdout.
160
195
  - `list --json`, `image list --json`, `recipients list --json`, and `quota --json` print **only JSON** on stdout.
161
- - Progress, warnings, prompts, errors, and the per-image `droppage: image … → …` lines go to **stderr**.
196
+ - Progress, warnings, prompts, errors, and the per-file `droppage: image … → …` / `droppage: video … → …` lines go to **stderr**.
162
197
  - Non-zero exit code on any failure.
163
198
  - Use `--yes` on `delete` / `recipients remove` for non-interactive runs.
164
199
 
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ var init_package = __esm({
15
15
  "package.json"() {
16
16
  package_default = {
17
17
  name: "droppage",
18
- version: "0.2.1",
18
+ version: "0.3.0",
19
19
  description: "Publish HTML files as shareable, access-controlled live pages",
20
20
  type: "module",
21
21
  license: "Apache-2.0",
@@ -521,6 +521,78 @@ var init_duration = __esm({
521
521
  // src/lib/html-images.ts
522
522
  import { existsSync, realpathSync, statSync } from "fs";
523
523
  import { dirname, extname, relative, resolve, sep } from "path";
524
+ function imageSlots(html) {
525
+ const slots = [];
526
+ const attrValue = (match) => ({ start: match.index + match[1].length + 1, value: match[3] });
527
+ for (const re of [SRC_RE, POSTER_RE]) {
528
+ for (const match of html.matchAll(re)) {
529
+ const { start, value } = attrValue(match);
530
+ slots.push({ start, end: start + value.length, ref: value.trim() });
531
+ }
532
+ }
533
+ for (const match of html.matchAll(SRCSET_RE)) {
534
+ const { start, value } = attrValue(match);
535
+ for (const url of srcsetUrls(value)) slots.push({ start: start + url.offset, end: start + url.offset + url.ref.length, ref: url.ref });
536
+ }
537
+ for (const match of html.matchAll(LINK_RE)) {
538
+ const tag = match[0];
539
+ let rel = null;
540
+ let href = null;
541
+ for (const attr of tag.matchAll(ATTR_RE)) {
542
+ const name = attr[1].toLowerCase();
543
+ const value = attr[3] ?? attr[4];
544
+ const end = attr.index + attr[0].length - (attr[3] === void 0 ? 0 : 1);
545
+ if (name === "rel") rel = value;
546
+ if (name === "href") href = { offset: end - value.length, value };
547
+ }
548
+ if (!rel || !href || !rel.toLowerCase().split(/\s+/).some((token) => ICON_RELS.has(token))) continue;
549
+ const start = match.index + href.offset;
550
+ slots.push({ start, end: start + href.value.length, ref: href.value.trim() });
551
+ }
552
+ for (const match of html.matchAll(STYLE_RE)) {
553
+ const cssStart = match.index + match[1].length;
554
+ const css = match[2].replace(/\/\*[\s\S]*?\*\//g, (comment) => " ".repeat(comment.length));
555
+ for (const url of css.matchAll(CSS_URL_RE)) {
556
+ const value = url[1] ?? url[2] ?? url[3];
557
+ const quoted = url[1] !== void 0 || url[2] !== void 0;
558
+ const paren = url[0].indexOf("(") + 1;
559
+ const open2 = paren + url[0].slice(paren).search(/\S/);
560
+ const start = cssStart + url.index + open2 + (quoted ? 1 : 0);
561
+ slots.push({ start, end: start + value.length, ref: value.trim() });
562
+ }
563
+ }
564
+ slots.sort((a, b) => a.start - b.start);
565
+ const ordered = [];
566
+ for (const slot of slots) {
567
+ if (slot.ref && (ordered.length === 0 || slot.start >= ordered[ordered.length - 1].end)) ordered.push(slot);
568
+ }
569
+ return ordered;
570
+ }
571
+ function srcsetUrls(value) {
572
+ const urls = [];
573
+ let i = 0;
574
+ while (i < value.length) {
575
+ while (i < value.length && (/\s/.test(value[i]) || value[i] === ",")) i++;
576
+ if (i >= value.length) break;
577
+ const start = i;
578
+ while (i < value.length && !/\s/.test(value[i])) i++;
579
+ const raw = value.slice(start, i);
580
+ const ref = raw.replace(/,+$/, "");
581
+ if (ref) urls.push({ offset: start, ref });
582
+ if (ref.length !== raw.length) continue;
583
+ let depth = 0;
584
+ for (; i < value.length; i++) {
585
+ const c = value[i];
586
+ if (c === "(") depth++;
587
+ else if (c === ")") depth = Math.max(0, depth - 1);
588
+ else if (c === "," && depth === 0) {
589
+ i++;
590
+ break;
591
+ }
592
+ }
593
+ }
594
+ return urls;
595
+ }
524
596
  function inside(base, path) {
525
597
  const rel = relative(base, path);
526
598
  return !!rel && !rel.startsWith("..") && !rel.startsWith(sep) && !/^[a-z]:/i.test(rel);
@@ -534,8 +606,7 @@ function findLocalImages(html, htmlFile) {
534
606
  return [];
535
607
  }
536
608
  const seen = /* @__PURE__ */ new Map();
537
- for (const match of html.matchAll(SRC_RE)) {
538
- const ref = match[3].trim();
609
+ for (const { ref } of imageSlots(html)) {
539
610
  if (seen.has(ref) || /^(?:[a-z][a-z0-9+.-]*:|\/|\\|#)/i.test(ref)) continue;
540
611
  let decoded;
541
612
  try {
@@ -543,7 +614,7 @@ function findLocalImages(html, htmlFile) {
543
614
  } catch {
544
615
  continue;
545
616
  }
546
- if (!IMAGE_EXTS.has(extname(decoded).toLowerCase())) continue;
617
+ if (!MEDIA_EXTS.has(extname(decoded).toLowerCase())) continue;
547
618
  const path = resolve(base, decoded);
548
619
  if (!inside(base, path) || !existsSync(path)) continue;
549
620
  let real;
@@ -558,17 +629,33 @@ function findLocalImages(html, htmlFile) {
558
629
  return [...seen.values()];
559
630
  }
560
631
  function rewriteImageRefs(html, urls) {
561
- return html.replace(SRC_RE, (whole, before, quote, ref) => {
562
- const url = urls.get(ref.trim());
563
- return url ? `${before}${quote}${url}${quote}` : whole;
564
- });
632
+ let out = "";
633
+ let last = 0;
634
+ for (const slot of imageSlots(html)) {
635
+ const url = urls.get(slot.ref);
636
+ if (!url) continue;
637
+ out += html.slice(last, slot.start) + url;
638
+ last = slot.end;
639
+ }
640
+ return out + html.slice(last);
565
641
  }
566
- var IMAGE_EXTS, SRC_RE;
642
+ var IMAGE_EXTS, VIDEO_EXTS, MEDIA_EXTS, attrRe, SRC_RE, SRCSET_RE, POSTER_RE, LINK_RE, ATTR_RE, ICON_RELS, STYLE_RE, CSS_URL_RE, isVideoFile;
567
643
  var init_html_images = __esm({
568
644
  "src/lib/html-images.ts"() {
569
645
  "use strict";
570
646
  IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
571
- SRC_RE = /(<(?:img|source)\b[^>]*?\ssrc\s*=\s*)(["'])([^"']+)\2/gi;
647
+ VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".webm"]);
648
+ MEDIA_EXTS = /* @__PURE__ */ new Set([...IMAGE_EXTS, ...VIDEO_EXTS]);
649
+ attrRe = (tags, name) => new RegExp(`(<(?:${tags})\\b[^>]*?\\s${name}\\s*=\\s*)(["'])([^"']+)\\2`, "gi");
650
+ SRC_RE = attrRe("img|source|video", "src");
651
+ SRCSET_RE = attrRe("img|source", "srcset");
652
+ POSTER_RE = attrRe("video", "poster");
653
+ LINK_RE = /<link\b[^>]*>/gi;
654
+ ATTR_RE = /\s([a-z][a-z0-9-]*)\s*=\s*(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
655
+ ICON_RELS = /* @__PURE__ */ new Set(["icon", "apple-touch-icon", "apple-touch-icon-precomposed"]);
656
+ STYLE_RE = /(<style\b[^>]*>)([\s\S]*?)<\/style\s*>/gi;
657
+ CSS_URL_RE = /\burl\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s"'()]+))\s*\)/gi;
658
+ isVideoFile = (path) => VIDEO_EXTS.has(extname(path).toLowerCase());
572
659
  }
573
660
  });
574
661
 
@@ -589,6 +676,7 @@ var init_share_url = __esm({
589
676
  });
590
677
 
591
678
  // src/services/images.ts
679
+ import { createHash } from "crypto";
592
680
  import { readFile, stat } from "fs/promises";
593
681
  import { basename } from "path";
594
682
  async function accessToken() {
@@ -617,10 +705,18 @@ async function call(path, init) {
617
705
  }
618
706
  return body;
619
707
  }
708
+ function asUploaded(body) {
709
+ const image = body;
710
+ if (!image || typeof image.url !== "string" || typeof image.id !== "string") {
711
+ throw new ImageRequestError("unexpected response from the image API", 0);
712
+ }
713
+ return image;
714
+ }
620
715
  async function uploadImage(file, slug, { update = false } = {}) {
621
716
  const info = await stat(file).catch(() => null);
622
717
  if (!info?.isFile()) throw new ImageRequestError(`file not found: ${file}`, 0);
623
718
  if (info.size === 0) throw new ImageRequestError(`file is empty: ${file}`, 0);
719
+ if (isVideoFile(file)) return uploadVideo(file, info.size, slug, update);
624
720
  if (info.size > MAX_IMAGE_BYTES) {
625
721
  throw new ImageRequestError(`image is larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB (${info.size} bytes): ${file}`, 413);
626
722
  }
@@ -639,11 +735,48 @@ async function uploadImage(file, slug, { update = false } = {}) {
639
735
  if (info.size > HEAVY_IMAGE_BYTES && /\.(png|jpe?g|gif)$/i.test(file)) {
640
736
  console.error(`droppage: tip: ${basename(file)} is ${Math.round(info.size / 1024)} KB; WebP or AVIF is usually several times smaller (SVG for charts).`);
641
737
  }
642
- const image = body;
643
- if (!image || typeof image.url !== "string" || typeof image.id !== "string") {
738
+ return asUploaded(body);
739
+ }
740
+ async function uploadVideo(file, size, slug, update) {
741
+ if (size > MAX_VIDEO_BYTES) {
742
+ throw new ImageRequestError(`video is larger than ${MAX_VIDEO_BYTES / 1e6} MB (${size} bytes): ${file}`, 413);
743
+ }
744
+ const bytes = await readFile(file);
745
+ const token = await accessToken();
746
+ const started = await call("/api/images/uploads", {
747
+ method: "POST",
748
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
749
+ body: JSON.stringify({
750
+ page: slug,
751
+ bytes: bytes.length,
752
+ sha256: createHash("sha256").update(bytes).digest("hex"),
753
+ filename: basename(file),
754
+ head: bytes.subarray(0, 64).toString("base64"),
755
+ update
756
+ })
757
+ });
758
+ if (typeof started?.url === "string") return asUploaded(started);
759
+ if (!started?.upload_url || !started.complete_url || !started.content_type) {
644
760
  throw new ImageRequestError("unexpected response from the image API", 0);
645
761
  }
646
- return image;
762
+ let stored;
763
+ try {
764
+ stored = await fetch(started.upload_url, {
765
+ method: "PUT",
766
+ headers: { "Content-Type": started.content_type, "x-upsert": "false" },
767
+ body: bytes
768
+ });
769
+ } catch (err) {
770
+ throw new ImageRequestError(`could not upload the video: ${err instanceof Error ? err.message : String(err)}`, 0);
771
+ }
772
+ if (!stored.ok) {
773
+ const detail = await stored.text().catch(() => "");
774
+ throw new ImageRequestError(`could not upload the video (storage answered HTTP ${stored.status}${detail ? `: ${detail.slice(0, 200)}` : ""})`, stored.status);
775
+ }
776
+ return asUploaded(await call(started.complete_url, {
777
+ method: "POST",
778
+ headers: { Authorization: `Bearer ${token}` }
779
+ }));
647
780
  }
648
781
  async function listImages(slug) {
649
782
  const query = slug ? `?page=${encodeURIComponent(slug)}` : "";
@@ -668,20 +801,23 @@ async function deleteImage(idOrUrl) {
668
801
  });
669
802
  return id;
670
803
  }
671
- async function pruneImages(slug) {
804
+ async function pruneImages(slug, html) {
672
805
  const body = await call(`/api/pages/${encodeURIComponent(slug)}/images/prune`, {
673
806
  method: "POST",
674
- headers: { Authorization: `Bearer ${await accessToken()}` }
807
+ headers: { Authorization: `Bearer ${await accessToken()}`, "Content-Type": "application/json" },
808
+ body: JSON.stringify(html ? { html_sha256: createHash("sha256").update(html).digest("hex") } : {})
675
809
  });
676
810
  return Number(body?.removed ?? 0);
677
811
  }
678
- var MAX_IMAGE_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
812
+ var MAX_IMAGE_BYTES, MAX_VIDEO_BYTES, HEAVY_IMAGE_BYTES, ImageRequestError;
679
813
  var init_images = __esm({
680
814
  "src/services/images.ts"() {
681
815
  "use strict";
816
+ init_html_images();
682
817
  init_share_url();
683
818
  init_supabase();
684
819
  MAX_IMAGE_BYTES = 4 * 1024 * 1024;
820
+ MAX_VIDEO_BYTES = 2e7;
685
821
  HEAVY_IMAGE_BYTES = 300 * 1024;
686
822
  ImageRequestError = class extends Error {
687
823
  constructor(message, status) {
@@ -707,15 +843,24 @@ async function attachLocalImages(file, slug, { html, update = false } = {}) {
707
843
  const result = await uploadImage(image.path, slug, { update });
708
844
  urls.set(image.ref, result.url);
709
845
  uploaded.push(result);
710
- console.error(`droppage: image ${basename2(image.path)} \u2192 ${result.url}`);
846
+ const kind = isVideoFile(image.path) ? "video" : "image";
847
+ console.error(`droppage: ${kind} ${basename2(image.path)} \u2192 ${result.url}`);
711
848
  }
712
849
  return { html: Buffer.from(rewriteImageRefs(text, urls), "utf8"), uploaded };
713
850
  }
714
851
  async function hasLocalImages(file) {
715
852
  return findLocalImages((await readFile2(file)).toString("utf8"), file).length > 0;
716
853
  }
717
- async function pruneUnusedImages(slug) {
718
- return pruneImages(slug);
854
+ async function pruneUnusedImages(slug, html) {
855
+ try {
856
+ return await pruneImages(slug, html);
857
+ } catch (err) {
858
+ if (err instanceof ImageRequestError && err.status === 409) {
859
+ console.error(`droppage: warning: unused images were kept (${err.message}); the next update removes them`);
860
+ return 0;
861
+ }
862
+ throw err;
863
+ }
719
864
  }
720
865
  var init_page_images = __esm({
721
866
  "src/services/page-images.ts"() {
@@ -930,7 +1075,7 @@ function registerUpload(program2) {
930
1075
  "--email <address>",
931
1076
  "restrict access to an email allowlist (repeatable; comma-separated)",
932
1077
  (val, prev = []) => prev.concat(val.split(","))
933
- ).option("--private", "restrict access to only you (owner-only draft)").option("--expires <when>", "delete the page (and its images) after 30m, 12h, 7d, 2w or on a date").action(
1078
+ ).option("--private", "restrict access to only you (owner-only draft)").option("--expires <when>", "delete the page (and its images and videos) after 30m, 12h, 7d, 2w or on a date").action(
934
1079
  async (file, options) => {
935
1080
  let user;
936
1081
  try {
@@ -1141,7 +1286,7 @@ var init_pages = __esm({
1141
1286
  // src/commands/update.ts
1142
1287
  import { readFile as readFile5 } from "fs/promises";
1143
1288
  function registerUpdate(program2) {
1144
- program2.command("update").description("Replace the HTML behind an existing page URL (its local images are uploaded too)").argument("<slug>", "slug of the page to update").argument("<file>", "path to replacement .html file").option("--expires <when>", "set when the page expires: 30m, 12h, 7d, 2w, a date, or never").action(async (slug, file, options) => {
1289
+ program2.command("update").description("Replace the HTML behind an existing page URL (its local images and videos are uploaded too)").argument("<slug>", "slug of the page to update").argument("<file>", "path to replacement .html file").option("--expires <when>", "set when the page expires: 30m, 12h, 7d, 2w, a date, or never").action(async (slug, file, options) => {
1145
1290
  let user;
1146
1291
  try {
1147
1292
  user = await requireAuth();
@@ -1156,8 +1301,8 @@ function registerUpdate(program2) {
1156
1301
  validateHtmlUpload(file, await readFile5(file));
1157
1302
  const { html } = await attachLocalImages(file, slug, { update: true });
1158
1303
  const result = await updatePageHtml({ slug, file, user, html });
1159
- const removed = await pruneUnusedImages(slug);
1160
- if (removed) console.error(`droppage: removed ${removed} image(s) the page no longer uses`);
1304
+ const removed = await pruneUnusedImages(slug, html);
1305
+ if (removed) console.error(`droppage: removed ${removed} image or video file(s) the page no longer uses`);
1161
1306
  if (expiresAt !== void 0) {
1162
1307
  await setPageExpiry(slug, expiresAt);
1163
1308
  console.error(expiresAt ? `droppage: expires ${expiresAt.toISOString()}` : "droppage: never expires");
@@ -1173,7 +1318,7 @@ function registerUpdate(program2) {
1173
1318
  }
1174
1319
  }
1175
1320
  });
1176
- program2.command("expire").description("Set when a page (and its images) is deleted: 30m, 12h, 7d, 2w, a date, or never").argument("<slug>", "slug of the page").argument("<when>", "e.g. 7d, 2026-12-31, or never").action(async (slug, when) => {
1321
+ program2.command("expire").description("Set when a page (and its images and videos) is deleted: 30m, 12h, 7d, 2w, a date, or never").argument("<slug>", "slug of the page").argument("<when>", "e.g. 7d, 2026-12-31, or never").action(async (slug, when) => {
1177
1322
  try {
1178
1323
  await requireAuth();
1179
1324
  } catch (err) {
@@ -1871,8 +2016,8 @@ function fail(err) {
1871
2016
  process.exitCode = 1;
1872
2017
  }
1873
2018
  function registerImage(program2) {
1874
- const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local <img src> files)");
1875
- image.command("upload").description("Add images to one of your pages and print their URLs (PNG, JPEG, GIF, WebP, AVIF, SVG; up to 4 MB each)").argument("<files...>", "image files").requiredOption("--page <slug>", "the page the images belong to (they go when it is deleted or expires)").option("--json", "print the results as JSON").action(async (files, options) => {
2019
+ const image = program2.command("image").alias("media").description("Images and videos that belong to a page (uploaded automatically by upload/update from local files the HTML references)");
2020
+ image.command("upload").description("Add images or videos to one of your pages and print their URLs (PNG, JPEG, GIF, WebP, AVIF, SVG up to 4 MB each; MP4, WebM up to 20 MB each)").argument("<files...>", "image or video files").requiredOption("--page <slug>", "the page the files belong to (they go when it is deleted or expires)").option("--json", "print the results as JSON").action(async (files, options) => {
1876
2021
  if (!await signedIn()) return;
1877
2022
  const results = [];
1878
2023
  for (const file of files) {
@@ -1890,7 +2035,7 @@ function registerImage(program2) {
1890
2035
  }
1891
2036
  if (options.json) process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1892
2037
  });
1893
- image.command("list").description("List your pages' images (one page with --page, including its image budget)").option("--page <slug>", "only this page").option("--json", "print the list as JSON").action(async (options) => {
2038
+ image.command("list").description("List your pages' images and videos (one page with --page, including its media budget)").option("--page <slug>", "only this page").option("--json", "print the list as JSON").action(async (options) => {
1894
2039
  if (!await signedIn()) return;
1895
2040
  try {
1896
2041
  const list = await listImages(options.page);
@@ -1899,7 +2044,7 @@ function registerImage(program2) {
1899
2044
  return;
1900
2045
  }
1901
2046
  if (list.images.length === 0) {
1902
- console.error("droppage: no images. `droppage upload page.html` uploads the images the page uses.");
2047
+ console.error("droppage: no images or videos. `droppage upload page.html` uploads the ones the page uses.");
1903
2048
  } else {
1904
2049
  process.stdout.write(renderTable(
1905
2050
  ["ID", "PAGE", "NAME", "SIZE", "UPLOADED", "URL"],
@@ -1909,13 +2054,13 @@ function registerImage(program2) {
1909
2054
  }
1910
2055
  if (list.page) {
1911
2056
  const limit = list.page.limit_bytes === null ? "unlimited" : formatBytes3(list.page.limit_bytes);
1912
- console.error(`droppage: images on ${list.page.slug}: ${formatBytes3(list.page.used_bytes)} of ${limit} per page.`);
2057
+ console.error(`droppage: images and videos on ${list.page.slug}: ${formatBytes3(list.page.used_bytes)} of ${limit} per page.`);
1913
2058
  }
1914
2059
  } catch (err) {
1915
2060
  fail(err);
1916
2061
  }
1917
2062
  });
1918
- image.command("delete").description("Delete images (by id or URL); a browser that already loaded one may keep it up to a day").argument("<images...>", "image ids or URLs").action(async (ids) => {
2063
+ image.command("delete").description("Delete images or videos (by id or URL); they stop being served within 5 minutes; a browser that already loaded one may keep it up to a day").argument("<images...>", "image or video ids or URLs").action(async (ids) => {
1919
2064
  if (!await signedIn()) return;
1920
2065
  for (const value of ids) {
1921
2066
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droppage",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Publish HTML files as shareable, access-controlled live pages",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",