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.
- package/README.md +27 -1
- package/dist/cli.js +124 -21
- 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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
822
|
+
return first() + rest();
|
|
732
823
|
}
|
|
733
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
|
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
|
|