droppage 0.2.0 → 0.2.2

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 +27 -1
  2. package/dist/cli.js +124 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -49,13 +49,17 @@ Open the printed link in any browser. That's it.
49
49
  | `droppage recipients resend <slug> <email>` | Queue a fresh invitation for an existing recipient. |
50
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
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. |
52
+ | `droppage image delete <id-or-url...>` | Delete images. 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. |
56
56
 
57
57
  Run `droppage <command> --help` for full option details.
58
58
 
59
+ Slugs from 0.2.1 on never start with `-`. If an older page's slug does (say `-AbC12xYz9`), put `--` in front of
60
+ it, with any options before the `--`: `droppage expire -- -AbC12xYz9 never` or
61
+ `droppage update --expires 7d -- -AbC12xYz9 report.html`. The CLI prints the same hint when it happens.
62
+
59
63
  ## Access modes
60
64
 
61
65
  | Upload flag | Mode | Who can open the link |
@@ -100,6 +104,9 @@ only the page URL.
100
104
 
101
105
  - `update` uploads any new local images, keeps the same URL for an unchanged image, and deletes
102
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.
103
110
  - Deleting a page, or the page expiring, removes its images too. Images never outlive their page.
104
111
  - Image budget per page by plan: Free 5 MB, Starter 20 MB, Builder 50 MB. Each image up to 4 MB.
105
112
  PNG, JPEG, GIF, WebP, AVIF, SVG. Image traffic counts toward your monthly bandwidth.
@@ -128,6 +135,25 @@ its images stop working. A daily cleanup then deletes the page and its images fo
128
135
  `droppage list` shows an EXPIRES column (`never` or a date); `list --json` rows have `expires_at`
129
136
  (`null` means never).
130
137
 
138
+ ## Sharing reports and dashboards
139
+
140
+ droppage is a quick way to send an analysis to people who don't have your tools: a dashboard for your
141
+ team, a weekly KPI or stakeholder report, a client deliverable, a notebook export. They open a link.
142
+
143
+ - **Export one HTML file**, up to 5 MB, with the data inside it: `jupyter nbconvert --to html
144
+ analysis.ipynb`, Plotly's `fig.write_html("report.html", include_plotlyjs="cdn")`, Altair's
145
+ `chart.save("report.html")`, or your own Chart.js / ECharts page. Scripts run, so interactive
146
+ charts work, and chart libraries can load from a CDN.
147
+ - **Pages run in a sandbox**: `localStorage`, cookies, and same-origin requests are not available, so
148
+ keep the data in the file rather than fetching it.
149
+ - **Pick who sees it.** `--email cfo@example.com,ops@example.com` for named people (Builder; manage
150
+ them later with `droppage recipients`), `--password` for a group link (Starter and Builder),
151
+ `--private` for a draft. On the Free plan, pages can be public or private, without a password or email list.
152
+ - **Same link every week.** `droppage update <slug> report.html` replaces the report and keeps the
153
+ link and its access settings (Starter and Builder). Use `--expires 30d` for one-off deliverables.
154
+ - **Views are people.** `droppage list --json` shows `view_count` (unique visitors) and
155
+ `last_viewed_at` for each report.
156
+
131
157
  ## For agents and scripts
132
158
 
133
159
  droppage keeps a strict stream contract so it is safe to call from automation:
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.0",
18
+ version: "0.2.2",
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,76 @@ 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 match of html.matchAll(SRC_RE)) {
528
+ const { start, value } = attrValue(match);
529
+ slots.push({ start, end: start + value.length, ref: value.trim() });
530
+ }
531
+ for (const match of html.matchAll(SRCSET_RE)) {
532
+ const { start, value } = attrValue(match);
533
+ for (const url of srcsetUrls(value)) slots.push({ start: start + url.offset, end: start + url.offset + url.ref.length, ref: url.ref });
534
+ }
535
+ for (const match of html.matchAll(LINK_RE)) {
536
+ const tag = match[0];
537
+ let rel = null;
538
+ let href = null;
539
+ for (const attr of tag.matchAll(ATTR_RE)) {
540
+ const name = attr[1].toLowerCase();
541
+ const value = attr[3] ?? attr[4];
542
+ const end = attr.index + attr[0].length - (attr[3] === void 0 ? 0 : 1);
543
+ if (name === "rel") rel = value;
544
+ if (name === "href") href = { offset: end - value.length, value };
545
+ }
546
+ if (!rel || !href || !rel.toLowerCase().split(/\s+/).some((token) => ICON_RELS.has(token))) continue;
547
+ const start = match.index + href.offset;
548
+ slots.push({ start, end: start + href.value.length, ref: href.value.trim() });
549
+ }
550
+ for (const match of html.matchAll(STYLE_RE)) {
551
+ const cssStart = match.index + match[1].length;
552
+ const css = match[2].replace(/\/\*[\s\S]*?\*\//g, (comment) => " ".repeat(comment.length));
553
+ for (const url of css.matchAll(CSS_URL_RE)) {
554
+ const value = url[1] ?? url[2] ?? url[3];
555
+ const quoted = url[1] !== void 0 || url[2] !== void 0;
556
+ const paren = url[0].indexOf("(") + 1;
557
+ const open2 = paren + url[0].slice(paren).search(/\S/);
558
+ const start = cssStart + url.index + open2 + (quoted ? 1 : 0);
559
+ slots.push({ start, end: start + value.length, ref: value.trim() });
560
+ }
561
+ }
562
+ slots.sort((a, b) => a.start - b.start);
563
+ const ordered = [];
564
+ for (const slot of slots) {
565
+ if (slot.ref && (ordered.length === 0 || slot.start >= ordered[ordered.length - 1].end)) ordered.push(slot);
566
+ }
567
+ return ordered;
568
+ }
569
+ function srcsetUrls(value) {
570
+ const urls = [];
571
+ let i = 0;
572
+ while (i < value.length) {
573
+ while (i < value.length && (/\s/.test(value[i]) || value[i] === ",")) i++;
574
+ if (i >= value.length) break;
575
+ const start = i;
576
+ while (i < value.length && !/\s/.test(value[i])) i++;
577
+ const raw = value.slice(start, i);
578
+ const ref = raw.replace(/,+$/, "");
579
+ if (ref) urls.push({ offset: start, ref });
580
+ if (ref.length !== raw.length) continue;
581
+ let depth = 0;
582
+ for (; i < value.length; i++) {
583
+ const c = value[i];
584
+ if (c === "(") depth++;
585
+ else if (c === ")") depth = Math.max(0, depth - 1);
586
+ else if (c === "," && depth === 0) {
587
+ i++;
588
+ break;
589
+ }
590
+ }
591
+ }
592
+ return urls;
593
+ }
524
594
  function inside(base, path) {
525
595
  const rel = relative(base, path);
526
596
  return !!rel && !rel.startsWith("..") && !rel.startsWith(sep) && !/^[a-z]:/i.test(rel);
@@ -534,8 +604,7 @@ function findLocalImages(html, htmlFile) {
534
604
  return [];
535
605
  }
536
606
  const seen = /* @__PURE__ */ new Map();
537
- for (const match of html.matchAll(SRC_RE)) {
538
- const ref = match[3].trim();
607
+ for (const { ref } of imageSlots(html)) {
539
608
  if (seen.has(ref) || /^(?:[a-z][a-z0-9+.-]*:|\/|\\|#)/i.test(ref)) continue;
540
609
  let decoded;
541
610
  try {
@@ -558,17 +627,29 @@ function findLocalImages(html, htmlFile) {
558
627
  return [...seen.values()];
559
628
  }
560
629
  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
- });
630
+ let out = "";
631
+ let last = 0;
632
+ for (const slot of imageSlots(html)) {
633
+ const url = urls.get(slot.ref);
634
+ if (!url) continue;
635
+ out += html.slice(last, slot.start) + url;
636
+ last = slot.end;
637
+ }
638
+ return out + html.slice(last);
565
639
  }
566
- var IMAGE_EXTS, SRC_RE;
640
+ var IMAGE_EXTS, imgAttrRe, SRC_RE, SRCSET_RE, LINK_RE, ATTR_RE, ICON_RELS, STYLE_RE, CSS_URL_RE;
567
641
  var init_html_images = __esm({
568
642
  "src/lib/html-images.ts"() {
569
643
  "use strict";
570
644
  IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"]);
571
- SRC_RE = /(<(?:img|source)\b[^>]*?\ssrc\s*=\s*)(["'])([^"']+)\2/gi;
645
+ imgAttrRe = (name) => new RegExp(`(<(?:img|source)\\b[^>]*?\\s${name}\\s*=\\s*)(["'])([^"']+)\\2`, "gi");
646
+ SRC_RE = imgAttrRe("src");
647
+ SRCSET_RE = imgAttrRe("srcset");
648
+ LINK_RE = /<link\b[^>]*>/gi;
649
+ ATTR_RE = /\s([a-z][a-z0-9-]*)\s*=\s*(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
650
+ ICON_RELS = /* @__PURE__ */ new Set(["icon", "apple-touch-icon", "apple-touch-icon-precomposed"]);
651
+ STYLE_RE = /(<style\b[^>]*>)([\s\S]*?)<\/style\s*>/gi;
652
+ CSS_URL_RE = /\burl\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s"'()]+))\s*\)/gi;
572
653
  }
573
654
  });
574
655
 
@@ -589,6 +670,7 @@ var init_share_url = __esm({
589
670
  });
590
671
 
591
672
  // src/services/images.ts
673
+ import { createHash } from "crypto";
592
674
  import { readFile, stat } from "fs/promises";
593
675
  import { basename } from "path";
594
676
  async function accessToken() {
@@ -668,10 +750,11 @@ async function deleteImage(idOrUrl) {
668
750
  });
669
751
  return id;
670
752
  }
671
- async function pruneImages(slug) {
753
+ async function pruneImages(slug, html) {
672
754
  const body = await call(`/api/pages/${encodeURIComponent(slug)}/images/prune`, {
673
755
  method: "POST",
674
- headers: { Authorization: `Bearer ${await accessToken()}` }
756
+ headers: { Authorization: `Bearer ${await accessToken()}`, "Content-Type": "application/json" },
757
+ body: JSON.stringify(html ? { html_sha256: createHash("sha256").update(html).digest("hex") } : {})
675
758
  });
676
759
  return Number(body?.removed ?? 0);
677
760
  }
@@ -714,8 +797,16 @@ async function attachLocalImages(file, slug, { html, update = false } = {}) {
714
797
  async function hasLocalImages(file) {
715
798
  return findLocalImages((await readFile2(file)).toString("utf8"), file).length > 0;
716
799
  }
717
- async function pruneUnusedImages(slug) {
718
- return pruneImages(slug);
800
+ async function pruneUnusedImages(slug, html) {
801
+ try {
802
+ return await pruneImages(slug, html);
803
+ } catch (err) {
804
+ if (err instanceof ImageRequestError && err.status === 409) {
805
+ console.error(`droppage: warning: unused images were kept (${err.message}); the next update removes them`);
806
+ return 0;
807
+ }
808
+ throw err;
809
+ }
719
810
  }
720
811
  var init_page_images = __esm({
721
812
  "src/services/page-images.ts"() {
@@ -728,15 +819,26 @@ var init_page_images = __esm({
728
819
  // src/lib/slug.ts
729
820
  import { customAlphabet } from "nanoid";
730
821
  function createSlug() {
731
- return generate();
822
+ return first() + rest();
732
823
  }
733
- var SLUG_LENGTH, alphabet, generate;
824
+ function dashSlugHint(error) {
825
+ const slug = DASH_SLUG_ERROR.exec(error)?.[1];
826
+ if (!slug) return "";
827
+ return `droppage: if ${slug} is a page, put -- in front of it (options go before the --), for example:
828
+ droppage expire -- ${slug} never
829
+ droppage update --expires 7d -- ${slug} report.html
830
+ `;
831
+ }
832
+ var SLUG_LENGTH, alphanumeric, alphabet, first, rest, DASH_SLUG_ERROR;
734
833
  var init_slug = __esm({
735
834
  "src/lib/slug.ts"() {
736
835
  "use strict";
737
836
  SLUG_LENGTH = 10;
738
- alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
739
- generate = customAlphabet(alphabet, SLUG_LENGTH);
837
+ alphanumeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
838
+ alphabet = `${alphanumeric}_-`;
839
+ first = customAlphabet(alphanumeric, 1);
840
+ rest = customAlphabet(alphabet, SLUG_LENGTH - 1);
841
+ DASH_SLUG_ERROR = new RegExp(`unknown option '(-[A-Za-z0-9_][A-Za-z0-9_-]{${SLUG_LENGTH - 2}})'`);
740
842
  }
741
843
  });
742
844
 
@@ -1145,7 +1247,7 @@ function registerUpdate(program2) {
1145
1247
  validateHtmlUpload(file, await readFile5(file));
1146
1248
  const { html } = await attachLocalImages(file, slug, { update: true });
1147
1249
  const result = await updatePageHtml({ slug, file, user, html });
1148
- const removed = await pruneUnusedImages(slug);
1250
+ const removed = await pruneUnusedImages(slug, html);
1149
1251
  if (removed) console.error(`droppage: removed ${removed} image(s) the page no longer uses`);
1150
1252
  if (expiresAt !== void 0) {
1151
1253
  await setPageExpiry(slug, expiresAt);
@@ -1860,7 +1962,7 @@ function fail(err) {
1860
1962
  process.exitCode = 1;
1861
1963
  }
1862
1964
  function registerImage(program2) {
1863
- const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local <img src> files)");
1965
+ const image = program2.command("image").description("Images that belong to a page (uploaded automatically by upload/update from local files the HTML references)");
1864
1966
  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) => {
1865
1967
  if (!await signedIn()) return;
1866
1968
  const results = [];
@@ -1904,7 +2006,7 @@ function registerImage(program2) {
1904
2006
  fail(err);
1905
2007
  }
1906
2008
  });
1907
- 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) => {
2009
+ image.command("delete").description("Delete images (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 ids or URLs").action(async (ids) => {
1908
2010
  if (!await signedIn()) return;
1909
2011
  for (const value of ids) {
1910
2012
  try {
@@ -1934,7 +2036,7 @@ import { Command } from "commander";
1934
2036
  function createProgram() {
1935
2037
  const program2 = new Command().name("droppage").description(
1936
2038
  "Publish HTML files as shareable, access-controlled live pages"
1937
- ).version(package_default.version);
2039
+ ).version(package_default.version).configureOutput({ outputError: (str, write) => write(str + dashSlugHint(str)) });
1938
2040
  registerLogin(program2);
1939
2041
  registerLogout(program2);
1940
2042
  registerUpload(program2);
@@ -1963,6 +2065,7 @@ var init_index = __esm({
1963
2065
  init_feedback2();
1964
2066
  init_upgrade();
1965
2067
  init_image();
2068
+ init_slug();
1966
2069
  }
1967
2070
  });
1968
2071
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droppage",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Publish HTML files as shareable, access-controlled live pages",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",