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.
- {htmlship-0.2.0 → htmlship-0.3.1}/PKG-INFO +27 -12
- {htmlship-0.2.0 → htmlship-0.3.1}/README.md +26 -11
- {htmlship-0.2.0 → htmlship-0.3.1}/pyproject.toml +1 -1
- htmlship-0.3.1/src/htmlship/_version.py +1 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/build.py +193 -3
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/cli.py +64 -39
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/client.py +68 -0
- htmlship-0.3.1/src/htmlship_mcp/__init__.py +1 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_mcp/server.py +12 -8
- htmlship-0.3.1/src/htmlship_server/__init__.py +1 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/config.py +2 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/db_models/page.py +7 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/middleware.py +8 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/pages.py +138 -1
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/view.py +131 -4
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/schemas/pages.py +29 -0
- htmlship-0.3.1/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.1}/.gitignore +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/LICENSE +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/exceptions.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship/models.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/database.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/db_models/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/exceptions.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/main.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/routers/meta.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/schemas/__init__.py +0 -0
- {htmlship-0.2.0 → htmlship-0.3.1}/src/htmlship_server/security.py +0 -0
- {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.
|
|
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,
|
|
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
|
|
143
|
-
htmlship deploy
|
|
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
|
|
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.
|
|
152
|
-
3.
|
|
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
|
-
#
|
|
160
|
-
page = htmlship.HTMLShipClient().
|
|
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
|
|
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-
|
|
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
|
|
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,
|
|
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
|
|
98
|
-
htmlship deploy
|
|
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
|
|
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.
|
|
107
|
-
3.
|
|
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
|
-
#
|
|
115
|
-
page = htmlship.HTMLShipClient().
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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.1"
|