htmlship 0.2.0__tar.gz → 0.3.1__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.1}/PKG-INFO +27 -12
  2. {htmlship-0.2.0 → htmlship-0.3.1}/README.md +26 -11
  3. {htmlship-0.2.0 → htmlship-0.3.1}/pyproject.toml +1 -1
  4. htmlship-0.3.1/src/htmlship/_version.py +1 -0
  5. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/build.py +193 -3
  6. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/cli.py +64 -39
  7. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/client.py +68 -0
  8. htmlship-0.3.1/src/htmlship_mcp/__init__.py +1 -0
  9. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_mcp/server.py +12 -8
  10. htmlship-0.3.1/src/htmlship_server/__init__.py +1 -0
  11. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/config.py +2 -0
  12. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/db_models/page.py +7 -0
  13. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/middleware.py +8 -0
  14. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/pages.py +138 -1
  15. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/view.py +131 -4
  16. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/schemas/pages.py +29 -0
  17. htmlship-0.3.1/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.1}/.gitignore +0 -0
  23. {htmlship-0.2.0 → htmlship-0.3.1}/LICENSE +0 -0
  24. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/__init__.py +0 -0
  25. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/exceptions.py +0 -0
  26. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/models.py +0 -0
  27. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/database.py +0 -0
  28. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/db_models/__init__.py +0 -0
  29. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/exceptions.py +0 -0
  30. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/main.py +0 -0
  31. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/__init__.py +0 -0
  32. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/meta.py +0 -0
  33. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/schemas/__init__.py +0 -0
  34. {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/security.py +0 -0
  35. {htmlship-0.2.0 → htmlship-0.3.1}/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.1
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
@@ -136,32 +136,44 @@ The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and
136
136
 
137
137
  ## Deploy built apps
138
138
 
139
- `deploy` builds a modern frontend project (React, Vite, Svelte, etc.) and publishes the **compiled** app as a single self-contained page — no separate asset hosting required.
139
+ `deploy` builds a modern frontend project (React, Vite, Svelte, Next.js, …) **locally** and publishes the compiled output. Single-page apps are inlined into one self-contained page; multi-file sites (Next.js static export auto-detected — Astro, Docusaurus, VitePress, …) are hosted as a file tree at `view.htmlship.com/{slug}/`. No separate asset hosting required either way.
140
140
 
141
141
  ```bash
142
- htmlship deploy ./my-app # detect build script, build, inline, publish
143
- htmlship deploy --dry-run # build + inline, but don't publish (inspect first)
142
+ htmlship deploy ./my-app # SPA -> one inlined page
143
+ htmlship deploy ./my-next-app # Next.js -> multi-file site (auto-detected)
144
+ htmlship deploy ./my-app --site --out dist # force multi-file hosting (Astro, etc.)
145
+ htmlship deploy ./my-app --single-file # force single-file inlining
146
+ htmlship deploy ./my-app --password "demo-pass" # password-protect the deploy
147
+ htmlship deploy --dry-run # build, but don't publish (inspect first)
144
148
  htmlship deploy --build-cmd "vite build" --out dist
145
- npx htmlship deploy ./my-app # same, no install
149
+ npx htmlship deploy ./my-app # same, no install
146
150
  ```
147
151
 
148
152
  How it works:
149
153
 
150
154
  1. **The build runs locally, on your machine — never on the server.** `deploy` detects your package manager (npm/pnpm/yarn/bun) and runs the `build` script (falling back to `build:prod`, or `--build-cmd` to override). This is what keeps the service from ever executing untrusted build steps.
151
- 2. The build output (`dist/` or `build/`, or `--out`) is **inlined into one HTML file**: scripts and stylesheets are embedded, and referenced images/icons/fonts become data URIs. The result must be 10 MB.
152
- 3. It's published with `sandbox_mode: "relaxed"` so the app's JavaScript can run (see [Rendering](#rendering)).
155
+ 2. **Single-file** (default for SPAs): the output (`dist/`, `build/`, or `--out`) is inlined into one HTML file scripts and stylesheets embedded, images/icons/fonts as data URIs and published with `sandbox_mode: "relaxed"`.
156
+ 3. **Multi-file** (Next.js auto-detected, or `--site`): the whole output tree is uploaded as a base64 file manifest to `POST /api/v1/sites` and served at `view.htmlship.com/{slug}/`, each response carrying a path-scoped per-slug sandbox CSP.
157
+
158
+ Both kinds run with relaxed sandboxing (see [Rendering](#rendering)) and are capped at 10 MB. `--single-file` and `--site` force a mode when you don't want the auto-detected one.
153
159
 
154
160
  The same flow is available programmatically (Python library) and via the `deploy_project` MCP tool:
155
161
 
156
162
  ```python
157
163
  import htmlship
158
164
 
159
- # Library: build ./my-app locally and publish the compiled app
160
- page = htmlship.HTMLShipClient().deploy("./my-app", expires_in=1440)
165
+ # build ./my-app locally and publish (auto-detects single-file vs. multi-file site)
166
+ page = htmlship.HTMLShipClient().deploy_project("./my-app", password="demo-pass", expires_in=1440)
161
167
  print(page.url)
162
168
  ```
163
169
 
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.
170
+ **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`. For a multi-file Next.js site that means client-side data fetches fail closed and Next falls back to full-page navigation — links still work, runtime API calls don't. It's intended for self-contained client-side apps, demos, and static content — not for apps that call external APIs at runtime.
171
+
172
+ **What `deploy` supports:**
173
+
174
+ - **Single-page apps** that build to one `index.html` plus assets — Vite, Create React App, SvelteKit (`adapter-static`), Vue CLI, plain static-site generators. Auto-detects `dist/`, `build/`, and `out/` with no flags.
175
+ - **Next.js** (auto-detected): the CLI transparently builds a **static export based at the slug path** — you don't edit `next.config`. The app must be statically exportable (no middleware, SSR, or server features). One exception: a `next.config.ts` can't be rewritten automatically yet, so `deploy` asks you to set `output: "export"` plus `basePath`/`assetPrefix = "/__htmlship_base__"` yourself, or convert the config to `next.config.mjs`.
176
+ - **Other multi-file frameworks** via `--site` (Astro, Docusaurus, VitePress, multi-page builds): build with your framework's base path set to `/__htmlship_base__` (e.g. Astro's `base`, or `vite build --base=/__htmlship_base__/`) so root-absolute asset URLs resolve under `/{slug}/`; the server rewrites that placeholder to the real slug at upload. Automatic base injection is currently Next.js-only.
165
177
 
166
178
  ## API
167
179
 
@@ -172,6 +184,7 @@ Base URL: `https://api.htmlship.com`.
172
184
  | `GET` | `/health` | Health check with service version. |
173
185
  | `GET` | `/version` | Service version. |
174
186
  | `POST` | `/api/v1/pages` | Create a page. |
187
+ | `POST` | `/api/v1/sites` | Upload a multi-file static site (base64 file manifest); served at `view.htmlship.com/{slug}/`. |
175
188
  | `GET` | `/api/v1/pages/{slug}` | Fetch page metadata. |
176
189
  | `PATCH` | `/api/v1/pages/{slug}` | Replace HTML or title. Requires `X-Owner-Key`. |
177
190
  | `DELETE` | `/api/v1/pages/{slug}` | Soft-delete a page. Requires `X-Owner-Key`. |
@@ -198,7 +211,7 @@ Notes:
198
211
  - `sandbox_mode` accepts `strict` (default) or `relaxed`. `strict` blocks all scripts; `relaxed` lets the page's own inline scripts run inside an isolated, opaque origin (used by `deploy` for compiled apps). See [Rendering](#rendering).
199
212
  - Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
200
213
 
201
- There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-inlined HTML, so deploying via the API is simply a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`.
214
+ There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-built output single-file deploys are a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`; multi-file sites are a `POST /api/v1/sites` with a base64 file manifest, served per-slug at `view.htmlship.com/{slug}/`.
202
215
 
203
216
  ## Rendering
204
217
 
@@ -214,11 +227,13 @@ Rendered HTML is served from `view.htmlship.com/{slug}`. **Strict** pages (the d
214
227
 
215
228
  **Relaxed** pages (`sandbox_mode: "relaxed"`, used by `deploy`) let the page's own inline scripts run but isolate them with `Content-Security-Policy: sandbox allow-scripts` — deliberately *without* `allow-same-origin`. That forces an opaque origin, so a deployed app cannot read cookies, touch `localStorage`, or fetch other pages same-origin. `connect-src 'none'` additionally blocks network egress. The body is still served verbatim; isolation is enforced entirely by response headers, with no change to the single-file storage model.
216
229
 
230
+ **Multi-file sites** (`deploy` of a Next.js export or `--site`) are served at `view.htmlship.com/{slug}/` from a per-slug file tree. Each response carries the same opaque-origin `sandbox` CSP, additionally path-scoped to `/{slug}/` (`script-src`/`style-src`/`img-src`/`font-src` are pinned to that prefix). Per-slug isolation therefore comes from the CSP, not from the URL path — one slug's scripts can't read another's, and `connect-src 'none'` blocks egress just as for single-file pages.
231
+
217
232
  The app routes by `Host` header:
218
233
 
219
234
  - `htmlship.com` serves the landing page
220
235
  - `api.htmlship.com` serves the API and landing assets
221
- - `view.htmlship.com/{slug}` serves sandboxed HTML
236
+ - `view.htmlship.com/{slug}` serves a sandboxed single page; `view.htmlship.com/{slug}/…` serves a multi-file site's tree
222
237
 
223
238
  For local development without DNS, append `?_host=view.htmlship.com` (or your configured view host) to spoof the host header, for example:
224
239
 
@@ -91,32 +91,44 @@ The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and
91
91
 
92
92
  ## Deploy built apps
93
93
 
94
- `deploy` builds a modern frontend project (React, Vite, Svelte, etc.) and publishes the **compiled** app as a single self-contained page — no separate asset hosting required.
94
+ `deploy` builds a modern frontend project (React, Vite, Svelte, Next.js, …) **locally** and publishes the compiled output. Single-page apps are inlined into one self-contained page; multi-file sites (Next.js static export auto-detected — Astro, Docusaurus, VitePress, …) are hosted as a file tree at `view.htmlship.com/{slug}/`. No separate asset hosting required either way.
95
95
 
96
96
  ```bash
97
- htmlship deploy ./my-app # detect build script, build, inline, publish
98
- htmlship deploy --dry-run # build + inline, but don't publish (inspect first)
97
+ htmlship deploy ./my-app # SPA -> one inlined page
98
+ htmlship deploy ./my-next-app # Next.js -> multi-file site (auto-detected)
99
+ htmlship deploy ./my-app --site --out dist # force multi-file hosting (Astro, etc.)
100
+ htmlship deploy ./my-app --single-file # force single-file inlining
101
+ htmlship deploy ./my-app --password "demo-pass" # password-protect the deploy
102
+ htmlship deploy --dry-run # build, but don't publish (inspect first)
99
103
  htmlship deploy --build-cmd "vite build" --out dist
100
- npx htmlship deploy ./my-app # same, no install
104
+ npx htmlship deploy ./my-app # same, no install
101
105
  ```
102
106
 
103
107
  How it works:
104
108
 
105
109
  1. **The build runs locally, on your machine — never on the server.** `deploy` detects your package manager (npm/pnpm/yarn/bun) and runs the `build` script (falling back to `build:prod`, or `--build-cmd` to override). This is what keeps the service from ever executing untrusted build steps.
106
- 2. The build output (`dist/` or `build/`, or `--out`) is **inlined into one HTML file**: scripts and stylesheets are embedded, and referenced images/icons/fonts become data URIs. The result must be 10 MB.
107
- 3. It's published with `sandbox_mode: "relaxed"` so the app's JavaScript can run (see [Rendering](#rendering)).
110
+ 2. **Single-file** (default for SPAs): the output (`dist/`, `build/`, or `--out`) is inlined into one HTML file scripts and stylesheets embedded, images/icons/fonts as data URIs and published with `sandbox_mode: "relaxed"`.
111
+ 3. **Multi-file** (Next.js auto-detected, or `--site`): the whole output tree is uploaded as a base64 file manifest to `POST /api/v1/sites` and served at `view.htmlship.com/{slug}/`, each response carrying a path-scoped per-slug sandbox CSP.
112
+
113
+ Both kinds run with relaxed sandboxing (see [Rendering](#rendering)) and are capped at 10 MB. `--single-file` and `--site` force a mode when you don't want the auto-detected one.
108
114
 
109
115
  The same flow is available programmatically (Python library) and via the `deploy_project` MCP tool:
110
116
 
111
117
  ```python
112
118
  import htmlship
113
119
 
114
- # Library: build ./my-app locally and publish the compiled app
115
- page = htmlship.HTMLShipClient().deploy("./my-app", expires_in=1440)
120
+ # build ./my-app locally and publish (auto-detects single-file vs. multi-file site)
121
+ page = htmlship.HTMLShipClient().deploy_project("./my-app", password="demo-pass", expires_in=1440)
116
122
  print(page.url)
117
123
  ```
118
124
 
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.
125
+ **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`. For a multi-file Next.js site that means client-side data fetches fail closed and Next falls back to full-page navigation — links still work, runtime API calls don't. It's intended for self-contained client-side apps, demos, and static content — not for apps that call external APIs at runtime.
126
+
127
+ **What `deploy` supports:**
128
+
129
+ - **Single-page apps** that build to one `index.html` plus assets — Vite, Create React App, SvelteKit (`adapter-static`), Vue CLI, plain static-site generators. Auto-detects `dist/`, `build/`, and `out/` with no flags.
130
+ - **Next.js** (auto-detected): the CLI transparently builds a **static export based at the slug path** — you don't edit `next.config`. The app must be statically exportable (no middleware, SSR, or server features). One exception: a `next.config.ts` can't be rewritten automatically yet, so `deploy` asks you to set `output: "export"` plus `basePath`/`assetPrefix = "/__htmlship_base__"` yourself, or convert the config to `next.config.mjs`.
131
+ - **Other multi-file frameworks** via `--site` (Astro, Docusaurus, VitePress, multi-page builds): build with your framework's base path set to `/__htmlship_base__` (e.g. Astro's `base`, or `vite build --base=/__htmlship_base__/`) so root-absolute asset URLs resolve under `/{slug}/`; the server rewrites that placeholder to the real slug at upload. Automatic base injection is currently Next.js-only.
120
132
 
121
133
  ## API
122
134
 
@@ -127,6 +139,7 @@ Base URL: `https://api.htmlship.com`.
127
139
  | `GET` | `/health` | Health check with service version. |
128
140
  | `GET` | `/version` | Service version. |
129
141
  | `POST` | `/api/v1/pages` | Create a page. |
142
+ | `POST` | `/api/v1/sites` | Upload a multi-file static site (base64 file manifest); served at `view.htmlship.com/{slug}/`. |
130
143
  | `GET` | `/api/v1/pages/{slug}` | Fetch page metadata. |
131
144
  | `PATCH` | `/api/v1/pages/{slug}` | Replace HTML or title. Requires `X-Owner-Key`. |
132
145
  | `DELETE` | `/api/v1/pages/{slug}` | Soft-delete a page. Requires `X-Owner-Key`. |
@@ -153,7 +166,7 @@ Notes:
153
166
  - `sandbox_mode` accepts `strict` (default) or `relaxed`. `strict` blocks all scripts; `relaxed` lets the page's own inline scripts run inside an isolated, opaque origin (used by `deploy` for compiled apps). See [Rendering](#rendering).
154
167
  - Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
155
168
 
156
- There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-inlined HTML, so deploying via the API is simply a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`.
169
+ There is no server-side build endpoint, by design: builds always run on the client (see [Deploy built apps](#deploy-built-apps)). The API only ever receives already-built output single-file deploys are a `POST /api/v1/pages` with `"sandbox_mode": "relaxed"`; multi-file sites are a `POST /api/v1/sites` with a base64 file manifest, served per-slug at `view.htmlship.com/{slug}/`.
157
170
 
158
171
  ## Rendering
159
172
 
@@ -169,11 +182,13 @@ Rendered HTML is served from `view.htmlship.com/{slug}`. **Strict** pages (the d
169
182
 
170
183
  **Relaxed** pages (`sandbox_mode: "relaxed"`, used by `deploy`) let the page's own inline scripts run but isolate them with `Content-Security-Policy: sandbox allow-scripts` — deliberately *without* `allow-same-origin`. That forces an opaque origin, so a deployed app cannot read cookies, touch `localStorage`, or fetch other pages same-origin. `connect-src 'none'` additionally blocks network egress. The body is still served verbatim; isolation is enforced entirely by response headers, with no change to the single-file storage model.
171
184
 
185
+ **Multi-file sites** (`deploy` of a Next.js export or `--site`) are served at `view.htmlship.com/{slug}/` from a per-slug file tree. Each response carries the same opaque-origin `sandbox` CSP, additionally path-scoped to `/{slug}/` (`script-src`/`style-src`/`img-src`/`font-src` are pinned to that prefix). Per-slug isolation therefore comes from the CSP, not from the URL path — one slug's scripts can't read another's, and `connect-src 'none'` blocks egress just as for single-file pages.
186
+
172
187
  The app routes by `Host` header:
173
188
 
174
189
  - `htmlship.com` serves the landing page
175
190
  - `api.htmlship.com` serves the API and landing assets
176
- - `view.htmlship.com/{slug}` serves sandboxed HTML
191
+ - `view.htmlship.com/{slug}` serves a sandboxed single page; `view.htmlship.com/{slug}/…` serves a multi-file site's tree
177
192
 
178
193
  For local development without DNS, append `?_host=view.htmlship.com` (or your configured view host) to spoof the host header, for example:
179
194
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "htmlship"
3
- version = "0.2.0"
3
+ version = "0.3.1"
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.1"
@@ -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.1"