@pracht/adapter-static 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,15 +37,32 @@ A static export has no server, so the build fails closed on anything that needs
37
37
  - API routes are build errors.
38
38
  - Manifest-registered capabilities exposed over HTTP/MCP/WebMCP are build errors (server-only capabilities invoked from build-time loaders are fine). Registered capability modules must load successfully so validation can fail closed; unused files in the capabilities directory are ignored.
39
39
  - Route patterns and concrete paths returned by `getStaticPaths()` may not write under the reserved `/_pracht/` namespace. Concrete output is preflighted before any page is written.
40
- - A Vite `base` other than `/` is a build error: prerendered documents reference `/assets/…` and `/_pracht/state/…` from the origin root, so a sub-path deploy (GitHub Pages project site, S3 key prefix) would build cleanly and serve a site whose every asset 404s.
40
+ - A Vite `base` that is not a root-absolute path is a build error. CDN bases (`https://cdn.example.com/`, or protocol-relative `//cdn…`) relocate only assets while documents and `/_pracht/state/…` stay at the origin root. Document-relative bases (`""` and `"./"`) resolve assets beneath each nested prerendered page directory. Use `/` or a root-absolute path base — see "Sub-path deploys" below.
41
41
 
42
42
  The build also warns — without failing — on a `fallback` document in an app with no `notFound` page and no unshadowed client-routable SPA catch-all (unknown URLs would render blank).
43
43
 
44
44
  Non-ASCII `getStaticPaths()` params are written to their decoded output path (`/posts/caf%C3%A9` → `posts/café/index.html`), matching how static hosts resolve requests. Escapes that would decode into a path separator, a relative segment, or the reserved `_pracht/` namespace fail the build.
45
45
 
46
+ ## Sub-path deploys
47
+
48
+ Set Vite's `base` to deploy under a path instead of an origin root — a GitHub Pages *project* site, an S3 key prefix, a reverse-proxy mount:
49
+
50
+ ```ts
51
+ export default defineConfig({
52
+ base: "/my-project/",
53
+ plugins: [pracht({ adapter: staticAdapter() })],
54
+ });
55
+ ```
56
+
57
+ The output tree is unchanged (`dist/client/about/index.html`); the base is where that directory is served. Every emitted URL carries it: scripts, styles, modulepreloads, `/_pracht/state/…` fetches, `llms.txt` links, and hrefs from `<Link route>`, `href()`, `useNavigate()`, and `prefetch()`. `pracht preview` serves the export under the same base.
58
+
59
+ Hand-written root-absolute links are not rewritten — `<a href="/about">` means the origin root, as in Next's `basePath` and SvelteKit's `base`. Use `<Link route="about">` or `href("about")` for internal navigation.
60
+
61
+ CDN bases and document-relative bases (`""` or `"./"`) are not supported by static exports; use `/` or a safe root-absolute path such as `/my-project/`. Repeated slashes, malformed percent escapes, and segments that decode to a path separator, `.`, `..`, NUL, or a control character fail the build.
62
+
46
63
  ## Client-side navigation
47
64
 
48
- SSG loaders run at build time. For each full-hydration SSG route whose loader or route/shell `head()` metadata participates in client navigation, the build serializes route-state JSON under `dist/client/_pracht/state/` using bounded, collision-safe opaque path components, and the client router (compiled with `__PRACHT_STATIC_TARGET__`) fetches that file instead of the live route-state endpoint. Equivalent URL segment spellings (raw Unicode, lowercase percent escapes, and escaped unreserved characters) resolve to the same state file. Explicitly loaderless and headless routes fetch no Pracht state; loaderless routes with head metadata fetch static state for font-head fragments while their components and data remain browser-only. Navigation therefore stays client-side on a dumb static host.
65
+ SSG loaders run at build time. For each full-hydration SSG route whose loader or route/shell `head()` metadata participates in client navigation, the build serializes route-state JSON under `dist/client/_pracht/state/` using bounded, collision-safe opaque path components, and the client router (compiled with `__PRACHT_STATIC_TARGET__`) fetches that file instead of the live route-state endpoint. Equivalent URL segment spellings (raw Unicode, lowercase percent escapes, and escaped unreserved characters) resolve to the same state file. Explicitly loaderless and headless routes fetch no Pracht state; loaderless routes with head metadata fetch static state for font-head fragments while their components and data remain browser-only. Dynamic routes with no `getStaticPaths()` export — the usual dynamic `render: "spa"` shape — are prerendered for no path, so the client skips a request that could never hit a file. Navigation therefore stays client-side on a dumb static host.
49
66
 
50
67
  ## Output conventions
51
68
 
package/dist/index.d.mts CHANGED
@@ -49,6 +49,13 @@ interface StaticPreviewHandlerOptions {
49
49
  * are answered with `404.html` (status 404) when it exists.
50
50
  */
51
51
  fallback?: string | null;
52
+ /**
53
+ * Deploy base (Vite `base`), e.g. `"/my-project/"`. Requests below it are
54
+ * served from `staticDir`; the bare base redirects to it with a trailing
55
+ * slash, and anything outside it 404s — which is what a host serving the
56
+ * export under a sub-path does.
57
+ */
58
+ base?: string | null;
52
59
  }
53
60
  /**
54
61
  * A deliberately tiny static file server for `pracht preview` of static
package/dist/index.mjs CHANGED
@@ -46,7 +46,7 @@ function createStaticServerEntryModule(options = {}) {
46
46
  " const response = await handlePrachtRequest({",
47
47
  " app: staticNotFoundApp,",
48
48
  " registry,",
49
- " request: new Request(\"http://localhost/404.html\", { method: \"GET\" }),",
49
+ " request: new Request(new URL(\"404.html\", new URL(buildBase, \"http://localhost\")), { method: \"GET\" }),",
50
50
  " clientEntryUrl: clientEntryUrl ?? undefined,",
51
51
  " islandsEntryUrl: islandsEntryUrl ?? undefined,",
52
52
  " islandsBootstrapRequired,",
@@ -87,6 +87,7 @@ function createStaticServerEntryModule(options = {}) {
87
87
  " const handler = createStaticPreviewHandler({",
88
88
  " staticDir: resolve(serverDir, \"../client\"),",
89
89
  " fallback: staticExportConfig.fallback,",
90
+ " base: buildBase,",
90
91
  " });",
91
92
  " const server = createServer(handler);",
92
93
  ` const port = Number(process.env.PORT ?? ${port});`,
@@ -141,6 +142,8 @@ function staticAdapter(options = {}) {
141
142
  function createStaticPreviewHandler(options) {
142
143
  const staticDir = resolve(options.staticDir);
143
144
  const fallback = options.fallback ?? null;
145
+ const base = normalizePreviewBase(options.base);
146
+ const decodedBase = decodeStaticPreviewPathname(new URL(base, "http://localhost").pathname);
144
147
  return async function handleStaticPreviewRequest(req, res) {
145
148
  const method = req.method ?? "GET";
146
149
  if (method !== "GET" && method !== "HEAD") {
@@ -152,14 +155,29 @@ function createStaticPreviewHandler(options) {
152
155
  return;
153
156
  }
154
157
  let pathname;
158
+ let search;
155
159
  try {
156
- const encodedPathname = new URL(req.url ?? "/", "http://localhost").pathname;
157
- pathname = decodeStaticPreviewPathname(encodedPathname);
160
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
161
+ pathname = decodeStaticPreviewPathname(requestUrl.pathname);
162
+ search = requestUrl.search;
158
163
  } catch {
159
164
  res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
160
165
  res.end("Bad request");
161
166
  return;
162
167
  }
168
+ if (decodedBase !== "/") {
169
+ if (pathname === decodedBase.slice(0, -1)) {
170
+ res.writeHead(301, { location: `${base}${search}` });
171
+ res.end();
172
+ return;
173
+ }
174
+ if (!pathname.startsWith(decodedBase)) {
175
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
176
+ res.end("Not found");
177
+ return;
178
+ }
179
+ pathname = `/${pathname.slice(decodedBase.length)}`;
180
+ }
163
181
  const file = await resolveStaticFile(staticDir, pathname);
164
182
  if (file) {
165
183
  await sendFile(res, method, file.filePath, file.contentType, file.cacheControl, 200);
@@ -208,5 +226,11 @@ async function sendFile(res, method, filePath, contentType, cacheControl, status
208
226
  });
209
227
  res.end(method === "HEAD" ? void 0 : body);
210
228
  }
229
+ /** Vite normalizes `base` to leading and trailing slashes; be defensive anyway. */
230
+ function normalizePreviewBase(raw) {
231
+ if (typeof raw !== "string" || raw === "" || raw === "/") return "/";
232
+ const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
233
+ return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`;
234
+ }
211
235
  //#endregion
212
236
  export { createStaticPreviewHandler, createStaticServerEntryModule, staticAdapter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/adapter-static",
3
- "version": "0.0.0",
3
+ "version": "0.1.1",
4
4
  "description": "Static-export adapter for Pracht: prerender every route and deploy dist/client to any static host with zero server.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -33,12 +33,11 @@
33
33
  }
34
34
  },
35
35
  "publishConfig": {
36
- "provenance": false,
37
- "access": "public"
36
+ "provenance": true
38
37
  },
39
38
  "dependencies": {
40
- "@pracht/adapter-node": "0.3.9",
41
- "@pracht/core": "0.13.0"
39
+ "@pracht/adapter-node": "0.4.1",
40
+ "@pracht/core": "0.15.0"
42
41
  },
43
42
  "scripts": {
44
43
  "build": "tsdown"