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.
Files changed (35) hide show
  1. {htmlship-0.2.0 → htmlship-0.3.0}/PKG-INFO +5 -1
  2. {htmlship-0.2.0 → htmlship-0.3.0}/README.md +4 -0
  3. {htmlship-0.2.0 → htmlship-0.3.0}/pyproject.toml +1 -1
  4. htmlship-0.3.0/src/htmlship/_version.py +1 -0
  5. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/build.py +193 -3
  6. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/cli.py +64 -39
  7. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/client.py +68 -0
  8. htmlship-0.3.0/src/htmlship_mcp/__init__.py +1 -0
  9. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_mcp/server.py +12 -8
  10. htmlship-0.3.0/src/htmlship_server/__init__.py +1 -0
  11. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/config.py +2 -0
  12. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/db_models/page.py +7 -0
  13. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/middleware.py +8 -0
  14. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/pages.py +138 -1
  15. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/view.py +131 -4
  16. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/schemas/pages.py +29 -0
  17. htmlship-0.3.0/src/htmlship_server/storage.py +237 -0
  18. htmlship-0.2.0/src/htmlship/_version.py +0 -1
  19. htmlship-0.2.0/src/htmlship_mcp/__init__.py +0 -1
  20. htmlship-0.2.0/src/htmlship_server/__init__.py +0 -1
  21. htmlship-0.2.0/src/htmlship_server/storage.py +0 -137
  22. {htmlship-0.2.0 → htmlship-0.3.0}/.gitignore +0 -0
  23. {htmlship-0.2.0 → htmlship-0.3.0}/LICENSE +0 -0
  24. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/__init__.py +0 -0
  25. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/exceptions.py +0 -0
  26. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship/models.py +0 -0
  27. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/database.py +0 -0
  28. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/db_models/__init__.py +0 -0
  29. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/exceptions.py +0 -0
  30. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/main.py +0 -0
  31. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/__init__.py +0 -0
  32. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/routers/meta.py +0 -0
  33. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/schemas/__init__.py +0 -0
  34. {htmlship-0.2.0 → htmlship-0.3.0}/src/htmlship_server/security.py +0 -0
  35. {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.2.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`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "htmlship"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Host and share HTML pages from LLMs and coding agents in one line."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 ["dist", "build"]
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("no build output found (looked for dist/ and build/; use out to specify)")
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 / (entry or "index.html")
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/ or build/).")
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 and inline, but report a summary instead of publishing.")
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 publish the compiled app as one self-contained page.
215
+ """Build a frontend project and deploy it.
212
216
 
213
- Runs the project's build script locally, inlines the output into a single
214
- HTML file, and publishes it with relaxed sandboxing so its JavaScript runs
215
- in an isolated origin.
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 --build-cmd "vite build" --out dist
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 MAX_PAYLOAD_BYTES, build_and_inline, format_bytes
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
- page = client.publish(
253
- result.html,
254
- title=title,
255
- password=password,
256
- expires_in=expires_in,
257
- sandbox_mode="relaxed",
258
- )
259
- except HTMLShipError as exc:
260
- raise click.ClickException(str(exc)) from exc
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
- click.echo("sandbox: relaxed (scripts run in an isolated origin)", err=True)
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 publish the compiled app as one page.
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), inlines the output (dist/ or build/) into a single HTML
83
- file, and publishes it with relaxed sandboxing so its JavaScript runs in an
84
- isolated origin (no cookies, no same-origin fetch, no network egress).
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/ or build/).
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 the
98
- page later. Save it.
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.deploy(
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.