htmlship 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {htmlship-0.2.0 → htmlship-0.3.0}/PKG-INFO +5 -1
- {htmlship-0.2.0 → htmlship-0.3.0}/README.md +4 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/pyproject.toml +1 -1
- htmlship-0.3.0/src/htmlship/_version.py +1 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/build.py +193 -3
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/cli.py +64 -39
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/client.py +68 -0
- htmlship-0.3.0/src/htmlship_mcp/__init__.py +1 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_mcp/server.py +12 -8
- htmlship-0.3.0/src/htmlship_server/__init__.py +1 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/config.py +2 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/db_models/page.py +7 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/middleware.py +8 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/pages.py +138 -1
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/view.py +131 -4
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/schemas/pages.py +29 -0
- htmlship-0.3.0/src/htmlship_server/storage.py +237 -0
- htmlship-0.2.0/src/htmlship/_version.py +0 -1
- htmlship-0.2.0/src/htmlship_mcp/__init__.py +0 -1
- htmlship-0.2.0/src/htmlship_server/__init__.py +0 -1
- htmlship-0.2.0/src/htmlship_server/storage.py +0 -137
- {htmlship-0.2.0 → htmlship-0.3.0}/.gitignore +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/LICENSE +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/exceptions.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/models.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/database.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/db_models/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/exceptions.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/main.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/meta.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/schemas/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/security.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/slugs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlship
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Host and share HTML pages from LLMs and coding agents in one line.
|
|
5
5
|
Project-URL: Homepage, https://htmlship.com
|
|
6
6
|
Project-URL: Repository, https://github.com/htmlship/htmlship
|
|
@@ -163,6 +163,10 @@ print(page.url)
|
|
|
163
163
|
|
|
164
164
|
**Relaxed-mode limits (by design):** a deployed app runs in an isolated, opaque origin and **cannot** make network requests (`connect-src 'none'`), read cookies, access other pages, or use `eval`. It's intended for self-contained client-side apps and demos — not for apps that call external APIs at runtime.
|
|
165
165
|
|
|
166
|
+
**What `deploy` supports:** single-page static apps that build to one `index.html` plus assets — Vite, Create React App, Astro (static), SvelteKit (`adapter-static`), Vue CLI, plain static-site generators. `deploy` auto-detects `dist/`, `build/`, and `out/` with no flags.
|
|
167
|
+
|
|
168
|
+
**Next.js note:** a default `next build` produces a *server* build in `.next/`, which is not statically hostable anywhere — and renaming it via `distDir` doesn't change that. To deploy a Next.js app you must add `output: "export"` to `next.config` (it emits a static `out/` folder, which `deploy` picks up automatically). Apps that rely on middleware, SSR, or multi-route i18n can't be statically exported and aren't a fit for single-file hosting.
|
|
169
|
+
|
|
166
170
|
## API
|
|
167
171
|
|
|
168
172
|
Base URL: `https://api.htmlship.com`.
|
|
@@ -118,6 +118,10 @@ print(page.url)
|
|
|
118
118
|
|
|
119
119
|
**Relaxed-mode limits (by design):** a deployed app runs in an isolated, opaque origin and **cannot** make network requests (`connect-src 'none'`), read cookies, access other pages, or use `eval`. It's intended for self-contained client-side apps and demos — not for apps that call external APIs at runtime.
|
|
120
120
|
|
|
121
|
+
**What `deploy` supports:** single-page static apps that build to one `index.html` plus assets — Vite, Create React App, Astro (static), SvelteKit (`adapter-static`), Vue CLI, plain static-site generators. `deploy` auto-detects `dist/`, `build/`, and `out/` with no flags.
|
|
122
|
+
|
|
123
|
+
**Next.js note:** a default `next build` produces a *server* build in `.next/`, which is not statically hostable anywhere — and renaming it via `distDir` doesn't change that. To deploy a Next.js app you must add `output: "export"` to `next.config` (it emits a static `out/` folder, which `deploy` picks up automatically). Apps that rely on middleware, SSR, or multi-route i18n can't be statically exported and aren't a fit for single-file hosting.
|
|
124
|
+
|
|
121
125
|
## API
|
|
122
126
|
|
|
123
127
|
Base URL: `https://api.htmlship.com`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -115,8 +115,42 @@ def _exec(command: str, cwd: Path, timeout: int, log: LogFn) -> None:
|
|
|
115
115
|
raise HTMLShipError(f"build exited with code {result.returncode}: `{command}`")
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
# Static-site output directories, in priority order. `out` covers static
|
|
119
|
+
# exports (e.g. Next.js `output: "export"`, Nuxt `nuxi generate`).
|
|
120
|
+
_OUTPUT_DIR_CANDIDATES = ["dist", "build", "out"]
|
|
121
|
+
_NEXT_CONFIGS = ("next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _looks_like_nextjs(directory: Path) -> bool:
|
|
125
|
+
if (directory / ".next").exists():
|
|
126
|
+
return True
|
|
127
|
+
return any((directory / f).exists() for f in _NEXT_CONFIGS)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_next_server_build(directory: Path) -> bool:
|
|
131
|
+
# A Next.js server build (`.next`, or renamed via `distDir`) has a BUILD_ID
|
|
132
|
+
# and server/ chunks but no static HTML entry.
|
|
133
|
+
return (directory / "BUILD_ID").exists() or (
|
|
134
|
+
(directory / "server").exists() and (directory / "static").exists()
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _no_output_message(directory: Path) -> str:
|
|
139
|
+
looked = ", ".join(f"{c}/" for c in _OUTPUT_DIR_CANDIDATES)
|
|
140
|
+
base = f"no build output found (looked for {looked}; use out to specify)"
|
|
141
|
+
if _looks_like_nextjs(directory):
|
|
142
|
+
return (
|
|
143
|
+
base + ". This looks like a Next.js app: `next build` writes a server build to "
|
|
144
|
+
'.next/, which is not statically hostable. Add `output: "export"` to your '
|
|
145
|
+
"next.config to emit a static out/ folder, then re-run. Note: deploy inlines a "
|
|
146
|
+
"single-page static app — server-rendered or multi-route Next.js sites are not "
|
|
147
|
+
"supported."
|
|
148
|
+
)
|
|
149
|
+
return base
|
|
150
|
+
|
|
151
|
+
|
|
118
152
|
def resolve_output_dir(directory: Path, override: str | None = None) -> Path:
|
|
119
|
-
candidates = [override] if override else
|
|
153
|
+
candidates = [override] if override else _OUTPUT_DIR_CANDIDATES
|
|
120
154
|
for c in candidates:
|
|
121
155
|
assert c is not None
|
|
122
156
|
p = Path(c) if Path(c).is_absolute() else directory / c
|
|
@@ -124,7 +158,44 @@ def resolve_output_dir(directory: Path, override: str | None = None) -> Path:
|
|
|
124
158
|
return p
|
|
125
159
|
if override:
|
|
126
160
|
raise HTMLShipError(f"build output dir not found: {override}")
|
|
127
|
-
raise HTMLShipError(
|
|
161
|
+
raise HTMLShipError(_no_output_message(directory))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _no_entry_message(out_dir: Path, htmls: list[str]) -> str:
|
|
165
|
+
if _is_next_server_build(out_dir):
|
|
166
|
+
return (
|
|
167
|
+
f"no static HTML entry in {out_dir} — this looks like a Next.js server build, not a "
|
|
168
|
+
'static site. Set `output: "export"` in your next.config (this emits a fully static '
|
|
169
|
+
"out/ folder); changing distDir alone only renames the server build. Note: apps that "
|
|
170
|
+
"use middleware or server rendering cannot be statically exported."
|
|
171
|
+
)
|
|
172
|
+
if len(htmls) > 1:
|
|
173
|
+
shown = ", ".join(htmls[:4]) + (", …" if len(htmls) > 4 else "")
|
|
174
|
+
return (
|
|
175
|
+
f"no index.html in {out_dir}, but found {len(htmls)} HTML files ({shown}). deploy "
|
|
176
|
+
"ships a single page — pass entry to choose one."
|
|
177
|
+
)
|
|
178
|
+
return f"entry HTML not found in {out_dir} (looked for index.html); pass entry to specify it."
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def resolve_entry(out_dir: Path, entry_override: str | None = None) -> Path:
|
|
182
|
+
"""Pick the entry HTML in the output dir, trying common single-page names."""
|
|
183
|
+
if entry_override:
|
|
184
|
+
p = out_dir / entry_override
|
|
185
|
+
if p.exists():
|
|
186
|
+
return p
|
|
187
|
+
raise HTMLShipError(f"entry HTML not found: {p}")
|
|
188
|
+
for name in ("index.html", "200.html", "index.htm"):
|
|
189
|
+
p = out_dir / name
|
|
190
|
+
if p.exists():
|
|
191
|
+
return p
|
|
192
|
+
try:
|
|
193
|
+
htmls = sorted(f.name for f in out_dir.iterdir() if f.suffix.lower() == ".html")
|
|
194
|
+
except OSError:
|
|
195
|
+
htmls = []
|
|
196
|
+
if len(htmls) == 1:
|
|
197
|
+
return out_dir / htmls[0]
|
|
198
|
+
raise HTMLShipError(_no_entry_message(out_dir, htmls))
|
|
128
199
|
|
|
129
200
|
|
|
130
201
|
# --- single-file inliner ----------------------------------------------------
|
|
@@ -297,6 +368,125 @@ def format_bytes(n: int) -> str:
|
|
|
297
368
|
return f"{n / (1024 * 1024):.2f} MB"
|
|
298
369
|
|
|
299
370
|
|
|
371
|
+
# --- multi-file site deploy -------------------------------------------------
|
|
372
|
+
|
|
373
|
+
SITE_BASE_PLACEHOLDER = "/__htmlship_base__"
|
|
374
|
+
_NEXT_CONFIG_FILES = ("next.config.mjs", "next.config.js", "next.config.cjs", "next.config.ts")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def is_next_project(directory: Path) -> bool:
|
|
378
|
+
if any((directory / f).exists() for f in _NEXT_CONFIG_FILES):
|
|
379
|
+
return True
|
|
380
|
+
pkg = directory / "package.json"
|
|
381
|
+
if not pkg.exists():
|
|
382
|
+
return False
|
|
383
|
+
try:
|
|
384
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
385
|
+
except Exception:
|
|
386
|
+
return False
|
|
387
|
+
deps = {**(data.get("dependencies") or {}), **(data.get("devDependencies") or {})}
|
|
388
|
+
return "next" in deps
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _inject_next_base(directory: Path) -> Callable[[], None]:
|
|
392
|
+
"""Make a Next build emit a static export based at the placeholder; return cleanup()."""
|
|
393
|
+
existing = next((f for f in _NEXT_CONFIG_FILES if (directory / f).exists()), None)
|
|
394
|
+
if existing and existing.endswith(".ts"):
|
|
395
|
+
raise HTMLShipError(
|
|
396
|
+
"Next .ts config isn't auto-configured yet. Temporarily set basePath and "
|
|
397
|
+
f'assetPrefix to "{SITE_BASE_PLACEHOLDER}" and output: "export" in '
|
|
398
|
+
"next.config.ts, or convert it to next.config.mjs, then re-run."
|
|
399
|
+
)
|
|
400
|
+
wrapper = directory / "next.config.mjs"
|
|
401
|
+
base = SITE_BASE_PLACEHOLDER
|
|
402
|
+
|
|
403
|
+
if not existing:
|
|
404
|
+
wrapper.write_text(
|
|
405
|
+
f"export default {{ output: 'export', basePath: '{base}', assetPrefix: '{base}', "
|
|
406
|
+
f"images: {{ unoptimized: true }}, trailingSlash: true }};\n",
|
|
407
|
+
encoding="utf-8",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def cleanup_new() -> None:
|
|
411
|
+
wrapper.unlink(missing_ok=True)
|
|
412
|
+
|
|
413
|
+
return cleanup_new
|
|
414
|
+
|
|
415
|
+
ext = Path(existing).suffix
|
|
416
|
+
backup = directory / f"next.config.__hsorig__{ext}"
|
|
417
|
+
(directory / existing).rename(backup)
|
|
418
|
+
wrapper.write_text(
|
|
419
|
+
f"import orig from './{backup.name}';\n"
|
|
420
|
+
f"const base = '{base}';\n"
|
|
421
|
+
"const over = { output: 'export', basePath: base, assetPrefix: base, "
|
|
422
|
+
"images: { ...(typeof orig === 'object' && orig ? orig.images : {}), unoptimized: true }, "
|
|
423
|
+
"trailingSlash: true };\n"
|
|
424
|
+
"export default typeof orig === 'function' "
|
|
425
|
+
"? ((...a) => ({ ...orig(...a), ...over })) : { ...orig, ...over };\n",
|
|
426
|
+
encoding="utf-8",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def cleanup_wrap() -> None:
|
|
430
|
+
wrapper.unlink(missing_ok=True)
|
|
431
|
+
if backup.exists():
|
|
432
|
+
backup.rename(directory / existing)
|
|
433
|
+
|
|
434
|
+
return cleanup_wrap
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def package_site(out_dir: Path, max_bytes: int = 10 * 1024 * 1024) -> tuple[list[dict], int]:
|
|
438
|
+
"""Walk an output dir into a base64 file manifest, enforcing the size cap."""
|
|
439
|
+
files: list[dict] = []
|
|
440
|
+
total = 0
|
|
441
|
+
for p in sorted(out_dir.rglob("*")):
|
|
442
|
+
if p.is_file():
|
|
443
|
+
data = p.read_bytes()
|
|
444
|
+
total += len(data)
|
|
445
|
+
if total > max_bytes:
|
|
446
|
+
raise HTMLShipError(f"site exceeds the {max_bytes // (1024 * 1024)} MB limit")
|
|
447
|
+
files.append(
|
|
448
|
+
{
|
|
449
|
+
"path": p.relative_to(out_dir).as_posix(),
|
|
450
|
+
"content": base64.b64encode(data).decode("ascii"),
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
return files, total
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def build_and_package_site(
|
|
457
|
+
directory: Path,
|
|
458
|
+
*,
|
|
459
|
+
build_cmd: str | None = None,
|
|
460
|
+
out: str | None = None,
|
|
461
|
+
entry: str | None = None,
|
|
462
|
+
install: bool = False,
|
|
463
|
+
timeout: int = DEFAULT_BUILD_TIMEOUT,
|
|
464
|
+
log: LogFn = _noop,
|
|
465
|
+
) -> tuple[list[dict], int, str]:
|
|
466
|
+
"""Detect, build (with Next base injection), and package a multi-file site."""
|
|
467
|
+
project = detect_project(directory, build_cmd)
|
|
468
|
+
is_next = not build_cmd and is_next_project(directory)
|
|
469
|
+
log(f"project: {directory}")
|
|
470
|
+
log(f"pkg mgr: {project.package_manager}")
|
|
471
|
+
log(f"framework: {'next.js (static export)' if is_next else 'multi-file'}")
|
|
472
|
+
|
|
473
|
+
cleanup = _inject_next_base(directory) if is_next else (lambda: None)
|
|
474
|
+
try:
|
|
475
|
+
if is_next:
|
|
476
|
+
log(f"base: {SITE_BASE_PLACEHOLDER} (injected for path hosting)")
|
|
477
|
+
run_build(project, install=install, build_cmd=build_cmd, timeout=timeout, log=log)
|
|
478
|
+
finally:
|
|
479
|
+
cleanup()
|
|
480
|
+
|
|
481
|
+
out_dir = resolve_output_dir(directory, out)
|
|
482
|
+
entry_file = resolve_entry(out_dir, entry)
|
|
483
|
+
entry_rel = entry_file.relative_to(out_dir).as_posix()
|
|
484
|
+
files, total = package_site(out_dir)
|
|
485
|
+
log(f"output: {out_dir}")
|
|
486
|
+
log(f"packaged: {len(files)} files, {format_bytes(total)}")
|
|
487
|
+
return files, total, entry_rel
|
|
488
|
+
|
|
489
|
+
|
|
300
490
|
def build_and_inline(
|
|
301
491
|
directory: Path,
|
|
302
492
|
*,
|
|
@@ -316,7 +506,7 @@ def build_and_inline(
|
|
|
316
506
|
run_build(project, install=install, build_cmd=build_cmd, timeout=timeout, log=log)
|
|
317
507
|
|
|
318
508
|
out_dir = resolve_output_dir(directory, out)
|
|
319
|
-
entry_file = out_dir
|
|
509
|
+
entry_file = resolve_entry(out_dir, entry)
|
|
320
510
|
result = inline_html(out_dir, entry_file)
|
|
321
511
|
log(f"output: {out_dir}")
|
|
322
512
|
log(f"inlined: {format_bytes(result.bytes)} single HTML")
|
|
@@ -179,10 +179,12 @@ def publish(
|
|
|
179
179
|
@main.command()
|
|
180
180
|
@click.argument("dir", required=False, default=".")
|
|
181
181
|
@click.option("--build-cmd", default=None, help="Override the build command (default: detected from package.json).")
|
|
182
|
-
@click.option("--out", default=None, help="Build output dir (default: auto-detect dist
|
|
182
|
+
@click.option("--out", default=None, help="Build output dir (default: auto-detect dist/, build/, out/).")
|
|
183
183
|
@click.option("--entry", default=None, help="Entry HTML within the output dir (default: index.html).")
|
|
184
|
+
@click.option("--site", is_flag=True, help="Force multi-file site hosting (auto-detected for Next.js).")
|
|
185
|
+
@click.option("--single-file", is_flag=True, help="Force single-file inlining (one self-contained page).")
|
|
184
186
|
@click.option("--install", is_flag=True, help="Run dependency install before building.")
|
|
185
|
-
@click.option("--dry-run", is_flag=True, help="Build
|
|
187
|
+
@click.option("--dry-run", is_flag=True, help="Build, but report a summary instead of publishing.")
|
|
186
188
|
@click.option("--title", default=None, help="Optional title.")
|
|
187
189
|
@click.option("--password", default=None, help="Password-protect the page.")
|
|
188
190
|
@click.option(
|
|
@@ -200,6 +202,8 @@ def deploy(
|
|
|
200
202
|
build_cmd: str | None,
|
|
201
203
|
out: str | None,
|
|
202
204
|
entry: str | None,
|
|
205
|
+
site: bool,
|
|
206
|
+
single_file: bool,
|
|
203
207
|
install: bool,
|
|
204
208
|
dry_run: bool,
|
|
205
209
|
title: str | None,
|
|
@@ -208,56 +212,76 @@ def deploy(
|
|
|
208
212
|
no_clipboard: bool,
|
|
209
213
|
quiet: bool,
|
|
210
214
|
) -> None:
|
|
211
|
-
"""Build a frontend project and
|
|
215
|
+
"""Build a frontend project and deploy it.
|
|
212
216
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
217
|
+
Single-page apps (Vite/CRA) are inlined into one self-contained page;
|
|
218
|
+
multi-file sites (Next.js static export, auto-detected) are hosted at
|
|
219
|
+
view.htmlship.com/{slug}/. Either way the build runs locally and the result
|
|
220
|
+
runs with relaxed sandboxing (isolated origin).
|
|
216
221
|
|
|
217
222
|
Examples:
|
|
218
223
|
|
|
219
224
|
htmlship deploy ./my-app
|
|
220
|
-
htmlship deploy
|
|
225
|
+
htmlship deploy ./my-next-app # multi-file, auto-detected
|
|
226
|
+
htmlship deploy --site --build-cmd "astro build" --out dist
|
|
221
227
|
"""
|
|
222
228
|
from pathlib import Path
|
|
223
229
|
|
|
224
|
-
from .build import
|
|
230
|
+
from .build import (
|
|
231
|
+
MAX_PAYLOAD_BYTES,
|
|
232
|
+
build_and_inline,
|
|
233
|
+
build_and_package_site,
|
|
234
|
+
format_bytes,
|
|
235
|
+
is_next_project,
|
|
236
|
+
)
|
|
225
237
|
|
|
226
238
|
project_dir = Path(dir).resolve()
|
|
227
239
|
log = (lambda _m: None) if quiet else (lambda m: click.echo(m, err=True))
|
|
228
|
-
|
|
229
|
-
try:
|
|
230
|
-
result = build_and_inline(
|
|
231
|
-
project_dir,
|
|
232
|
-
build_cmd=build_cmd,
|
|
233
|
-
out=out,
|
|
234
|
-
entry=entry,
|
|
235
|
-
install=install,
|
|
236
|
-
log=log,
|
|
237
|
-
)
|
|
238
|
-
except HTMLShipError as exc:
|
|
239
|
-
raise click.ClickException(str(exc)) from exc
|
|
240
|
-
|
|
241
|
-
if result.bytes > MAX_PAYLOAD_BYTES:
|
|
242
|
-
raise click.ClickException(
|
|
243
|
-
f"inlined page is {format_bytes(result.bytes)}, exceeds the 10 MB limit"
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
if dry_run:
|
|
247
|
-
log("dry-run: built and inlined OK; not published")
|
|
248
|
-
return
|
|
240
|
+
use_site = False if single_file else (site or is_next_project(project_dir))
|
|
249
241
|
|
|
250
242
|
client = _make_client(ctx.obj["api_url"])
|
|
251
243
|
try:
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
244
|
+
if use_site:
|
|
245
|
+
try:
|
|
246
|
+
files, _bytes, entry_rel = build_and_package_site(
|
|
247
|
+
project_dir, build_cmd=build_cmd, out=out, entry=entry, install=install, log=log
|
|
248
|
+
)
|
|
249
|
+
except HTMLShipError as exc:
|
|
250
|
+
raise click.ClickException(str(exc)) from exc
|
|
251
|
+
if dry_run:
|
|
252
|
+
log("dry-run: built and packaged OK; not published")
|
|
253
|
+
return
|
|
254
|
+
try:
|
|
255
|
+
page = client.deploy_site(
|
|
256
|
+
files, entry=entry_rel, title=title, password=password, expires_in=expires_in
|
|
257
|
+
)
|
|
258
|
+
except HTMLShipError as exc:
|
|
259
|
+
raise click.ClickException(str(exc)) from exc
|
|
260
|
+
else:
|
|
261
|
+
try:
|
|
262
|
+
result = build_and_inline(
|
|
263
|
+
project_dir, build_cmd=build_cmd, out=out, entry=entry, install=install, log=log
|
|
264
|
+
)
|
|
265
|
+
except HTMLShipError as exc:
|
|
266
|
+
raise click.ClickException(str(exc)) from exc
|
|
267
|
+
if result.bytes > MAX_PAYLOAD_BYTES:
|
|
268
|
+
raise click.ClickException(
|
|
269
|
+
f"inlined page is {format_bytes(result.bytes)}, exceeds the 10 MB limit "
|
|
270
|
+
"— try --site for multi-file hosting"
|
|
271
|
+
)
|
|
272
|
+
if dry_run:
|
|
273
|
+
log("dry-run: built and inlined OK; not published")
|
|
274
|
+
return
|
|
275
|
+
try:
|
|
276
|
+
page = client.publish(
|
|
277
|
+
result.html,
|
|
278
|
+
title=title,
|
|
279
|
+
password=password,
|
|
280
|
+
expires_in=expires_in,
|
|
281
|
+
sandbox_mode="relaxed",
|
|
282
|
+
)
|
|
283
|
+
except HTMLShipError as exc:
|
|
284
|
+
raise click.ClickException(str(exc)) from exc
|
|
261
285
|
finally:
|
|
262
286
|
client.close()
|
|
263
287
|
|
|
@@ -270,7 +294,8 @@ def deploy(
|
|
|
270
294
|
click.echo(page.url)
|
|
271
295
|
click.echo(f"slug: {page.slug}", err=True)
|
|
272
296
|
click.echo(f"owner_key: {page.owner_key} (saved to {KEYS_FILE})", err=True)
|
|
273
|
-
|
|
297
|
+
suffix = " · multi-file site" if use_site else ""
|
|
298
|
+
click.echo(f"sandbox: relaxed{suffix} (scripts run in an isolated origin)", err=True)
|
|
274
299
|
if page.expires_at:
|
|
275
300
|
click.echo(f"expires: {page.expires_at.isoformat()}", err=True)
|
|
276
301
|
if not no_clipboard and _try_clipboard(page.url):
|
|
@@ -132,6 +132,74 @@ class HTMLShipClient:
|
|
|
132
132
|
sandbox_mode="relaxed",
|
|
133
133
|
)
|
|
134
134
|
|
|
135
|
+
def deploy_site(
|
|
136
|
+
self,
|
|
137
|
+
files: list[dict],
|
|
138
|
+
*,
|
|
139
|
+
entry: str = "index.html",
|
|
140
|
+
title: str | None = None,
|
|
141
|
+
password: str | None = None,
|
|
142
|
+
expires_in: int | None = None,
|
|
143
|
+
) -> Page:
|
|
144
|
+
"""Upload a multi-file static site (built locally). Returns a Page."""
|
|
145
|
+
body: dict[str, Any] = {"files": files, "entry": entry}
|
|
146
|
+
if title is not None:
|
|
147
|
+
body["title"] = title
|
|
148
|
+
if password is not None:
|
|
149
|
+
body["password"] = password
|
|
150
|
+
if expires_in is not None:
|
|
151
|
+
body["expires_in"] = expires_in
|
|
152
|
+
r = self._http.post("/api/v1/sites", json=body)
|
|
153
|
+
data = self._handle(r)
|
|
154
|
+
return Page(
|
|
155
|
+
slug=data["slug"],
|
|
156
|
+
url=data["url"],
|
|
157
|
+
owner_key=data["owner_key"],
|
|
158
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
159
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
160
|
+
_client=self,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def deploy_project(
|
|
164
|
+
self,
|
|
165
|
+
project_dir: str | Any,
|
|
166
|
+
*,
|
|
167
|
+
site: bool | None = None,
|
|
168
|
+
single_file: bool = False,
|
|
169
|
+
build_cmd: str | None = None,
|
|
170
|
+
out: str | None = None,
|
|
171
|
+
entry: str | None = None,
|
|
172
|
+
install: bool = False,
|
|
173
|
+
title: str | None = None,
|
|
174
|
+
password: str | None = None,
|
|
175
|
+
expires_in: int | None = None,
|
|
176
|
+
) -> Page:
|
|
177
|
+
"""Build a local project and deploy it, auto-choosing single-file inlining
|
|
178
|
+
(SPA) vs. multi-file site hosting (Next.js, or when ``site`` is set)."""
|
|
179
|
+
from pathlib import Path
|
|
180
|
+
|
|
181
|
+
from .build import build_and_package_site, is_next_project
|
|
182
|
+
|
|
183
|
+
directory = Path(project_dir)
|
|
184
|
+
use_site = False if single_file else (site if site is not None else is_next_project(directory))
|
|
185
|
+
if use_site:
|
|
186
|
+
files, _bytes, entry_rel = build_and_package_site(
|
|
187
|
+
directory, build_cmd=build_cmd, out=out, entry=entry, install=install
|
|
188
|
+
)
|
|
189
|
+
return self.deploy_site(
|
|
190
|
+
files, entry=entry_rel, title=title, password=password, expires_in=expires_in
|
|
191
|
+
)
|
|
192
|
+
return self.deploy(
|
|
193
|
+
project_dir,
|
|
194
|
+
build_cmd=build_cmd,
|
|
195
|
+
out=out,
|
|
196
|
+
entry=entry,
|
|
197
|
+
install=install,
|
|
198
|
+
title=title,
|
|
199
|
+
password=password,
|
|
200
|
+
expires_in=expires_in,
|
|
201
|
+
)
|
|
202
|
+
|
|
135
203
|
def get(self, slug: str) -> Page:
|
|
136
204
|
"""Fetch metadata for a page. owner_key is None on the returned object."""
|
|
137
205
|
r = self._http.get(f"/api/v1/pages/{slug}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -71,22 +71,25 @@ def deploy_project(
|
|
|
71
71
|
dir: str,
|
|
72
72
|
build_cmd: str | None = None,
|
|
73
73
|
out: str | None = None,
|
|
74
|
+
site: bool | None = None,
|
|
74
75
|
install: bool = False,
|
|
75
76
|
title: str | None = None,
|
|
76
77
|
password: str | None = None,
|
|
77
78
|
expires_in: int | None = None,
|
|
78
79
|
) -> dict[str, Any]:
|
|
79
|
-
"""Build a local frontend project and
|
|
80
|
+
"""Build a local frontend project and deploy the compiled app.
|
|
80
81
|
|
|
81
82
|
Runs the project's build script ON THIS MACHINE (npm/pnpm/yarn/bun,
|
|
82
|
-
auto-detected)
|
|
83
|
-
file
|
|
84
|
-
|
|
83
|
+
auto-detected). Single-page apps (Vite/CRA) are inlined into one self-contained
|
|
84
|
+
page; multi-file sites (Next.js static export, auto-detected) are hosted at
|
|
85
|
+
view.htmlship.com/{slug}/. Both run with relaxed sandboxing so their JavaScript
|
|
86
|
+
runs in an isolated origin (no cookies, no same-origin fetch, no network egress).
|
|
85
87
|
|
|
86
88
|
Args:
|
|
87
89
|
dir: Path to the project directory (must contain package.json).
|
|
88
90
|
build_cmd: Override the build command (default: detected build/build:prod script).
|
|
89
|
-
out: Build output directory (default: auto-detect dist
|
|
91
|
+
out: Build output directory (default: auto-detect dist/, build/, out/).
|
|
92
|
+
site: Force multi-file site hosting (auto-detected for Next.js).
|
|
90
93
|
install: Run dependency install before building.
|
|
91
94
|
title: Optional human-readable title.
|
|
92
95
|
password: Optional password required before viewing the page.
|
|
@@ -94,13 +97,14 @@ def deploy_project(
|
|
|
94
97
|
|
|
95
98
|
Returns:
|
|
96
99
|
A dict with keys: url, slug, owner_key, expires_at.
|
|
97
|
-
IMPORTANT: owner_key is the only credential to update or delete
|
|
98
|
-
|
|
100
|
+
IMPORTANT: owner_key is the only credential to update or delete it
|
|
101
|
+
later. Save it.
|
|
99
102
|
"""
|
|
100
103
|
with _client() as c:
|
|
101
104
|
try:
|
|
102
|
-
page = c.
|
|
105
|
+
page = c.deploy_project(
|
|
103
106
|
dir,
|
|
107
|
+
site=site,
|
|
104
108
|
build_cmd=build_cmd,
|
|
105
109
|
out=out,
|
|
106
110
|
install=install,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -35,6 +35,8 @@ class Settings(BaseSettings):
|
|
|
35
35
|
secret_key: str = "dev-secret-do-not-use-in-prod"
|
|
36
36
|
|
|
37
37
|
max_payload_bytes: int = 10 * 1024 * 1024
|
|
38
|
+
max_site_bytes: int = 10 * 1024 * 1024 # total uncompressed size of a multi-file site
|
|
39
|
+
max_site_files: int = 2000
|
|
38
40
|
default_expires_in_minutes: int | None = None
|
|
39
41
|
|
|
40
42
|
@field_validator("default_expires_in_minutes", mode="before")
|
|
@@ -29,6 +29,12 @@ class Page(Base):
|
|
|
29
29
|
sandbox_mode: Mapped[str] = mapped_column(
|
|
30
30
|
String(20), nullable=False, default="strict", server_default="strict"
|
|
31
31
|
)
|
|
32
|
+
# 'page' = single-file blob (blob_key points at one .html); 'site' = multi-file
|
|
33
|
+
# static tree stored under the blob_key prefix, served at /{slug}/{path}.
|
|
34
|
+
kind: Mapped[str] = mapped_column(
|
|
35
|
+
String(8), nullable=False, default="page", server_default="page"
|
|
36
|
+
)
|
|
37
|
+
entry_path: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
32
38
|
expires_at: Mapped[datetime | None] = mapped_column(
|
|
33
39
|
DateTime(timezone=True), nullable=True
|
|
34
40
|
)
|
|
@@ -52,6 +58,7 @@ class Page(Base):
|
|
|
52
58
|
|
|
53
59
|
__table_args__ = (
|
|
54
60
|
CheckConstraint("sandbox_mode IN ('strict', 'relaxed')", name="ck_sandbox_mode"),
|
|
61
|
+
CheckConstraint("kind IN ('page', 'site')", name="ck_kind"),
|
|
55
62
|
CheckConstraint("size_bytes <= 10485760", name="ck_size_bytes"),
|
|
56
63
|
Index("idx_pages_slug", "slug"),
|
|
57
64
|
Index("idx_pages_expires_at", "expires_at"),
|
|
@@ -93,6 +93,14 @@ class HostRoutingMiddleware:
|
|
|
93
93
|
# API allowed on landing/api hosts; blocked on view host.
|
|
94
94
|
return kind != "view"
|
|
95
95
|
|
|
96
|
+
# Multi-file sites are served at /{slug}/{path} on the view host. Allow any
|
|
97
|
+
# slug-rooted sub-path there (the route handler enforces view-host-only).
|
|
98
|
+
stripped_path = path.strip("/")
|
|
99
|
+
if kind == "view" and "/" in stripped_path:
|
|
100
|
+
first = stripped_path.split("/", 1)[0]
|
|
101
|
+
if 6 <= len(first) <= 12 and all(c in SLUG_CHARS for c in first):
|
|
102
|
+
return True
|
|
103
|
+
|
|
96
104
|
# Non-API paths: anything that looks like /{slug} is reserved for the view host.
|
|
97
105
|
# A slug is 8-12 chars from the slug alphabet only — this avoids matching
|
|
98
106
|
# static-asset paths like /styles.css or /favicon.ico.
|