From 4241d0df341f3fb392bf6c4f6cab4e18c8d440c4 Mon Sep 17 00:00:00 2001 From: burinc Date: Sat, 29 Aug 2026 23:26:07 +1000 Subject: [PATCH 1/3] fix(serve): honour the configured base path locally generate! bakes :base-path into every emitted URL, but serve! was a plain static handler rooted at output-dir. A project with a base path therefore previewed as an unstyled page with every link dead: the homepage resolved at /, while the CSS and every nav link it pointed at 404'd under the prefix. serve! now strips the base path before resolving against output-dir, so local preview matches what deploys. Root-hosted projects, where the base path is the empty string, are unaffected. --- src/site/core.clj | 57 +++++++++++++++++++++++++++++------------ test/site/core_test.clj | 31 +++++++++++++++++----- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/site/core.clj b/src/site/core.clj index e376a45..13b9d9a 100644 --- a/src/site/core.clj +++ b/src/site/core.clj @@ -317,28 +317,53 @@ (defn- not-found-response [output-dir] {:status 404 :headers {"Content-Type" "text/html"} :body (slurp (io/file output-dir "404.html"))}) -(defn- make-static-handler [output-dir] +(defn- strip-base-path + "Removes a normalized base (\"\" or \"/name\", see `base-path`) from the + front of a request uri. Returns the base-relative uri, always leading + with \"/\", or nil when uri is not under base at all. + + A root-hosted site (base \"\") is returned unchanged. Otherwise uri + must be exactly base, or base followed by \"/\", so a base of + \"/mcp-tkx\" does not also swallow a sibling like \"/mcp-tkx-other\"." + [uri base] + (cond + (empty? base) uri + (= uri base) "/" + (str/starts-with? uri (str base "/")) (subs uri (count base)) + :else nil)) + +(defn- make-static-handler + "Serves output-dir's files under base (\"\" or \"/name\", see + `base-path`), mirroring the prefix generate! already baked into every + emitted URL. Without this, local preview and the deployed site + disagree: generate! links to /name/..., but a handler rooted straight + at output-dir only ever answers at /..., so the homepage loads at /, + unstyled, with every link it points at 404ing under the prefix." + [output-dir base] (fn [req] - (let [uri (:uri req) - uri (if (= uri "/") "/index.html" uri) - f (io/file output-dir (subs uri 1))] - (if (and (fs/exists? f) - (within-output-dir? output-dir f) - (not (fs/directory? f))) - ;; io/input-stream, not slurp: slurp reads as a String, which - ;; would corrupt a binary asset (images, fonts) via charset - ;; decode/re-encode. - {:status 200 :headers {"Content-Type" (content-type uri)} :body (io/input-stream f)} - (not-found-response output-dir))))) + (if-let [rel (strip-base-path (:uri req) base)] + (let [rel (if (= rel "/") "/index.html" rel) + f (io/file output-dir (subs rel 1))] + (if (and (fs/exists? f) + (within-output-dir? output-dir f) + (not (fs/directory? f))) + ;; io/input-stream, not slurp: slurp reads as a String, which + ;; would corrupt a binary asset (images, fonts) via charset + ;; decode/re-encode. + {:status 200 :headers {"Content-Type" (content-type rel)} :body (io/input-stream f)} + (not-found-response output-dir))) + (not-found-response output-dir)))) (defn serve! - "Builds, then serves output-dir at http://localhost: until interrupted." + "Builds, then serves output-dir at http://localhost: + until interrupted." [project port-str] (generate! project) (let [port (Integer/parseInt (or port-str "3000")) - output-dir (:output-dir project)] - (println (str "Serving " output-dir " at http://localhost:" port)) + output-dir (:output-dir project) + base (base-path (:base-path project))] + (println (str "Serving " output-dir " at http://localhost:" port base "/")) ;; :ip "127.0.0.1" — local-only dev preview server; without an ;; explicit :ip, http-kit binds all network interfaces by default. - (hk/run-server (make-static-handler output-dir) {:port port :ip "127.0.0.1"}) + (hk/run-server (make-static-handler output-dir base) {:port port :ip "127.0.0.1"}) @(promise))) diff --git a/test/site/core_test.clj b/test/site/core_test.clj index c5abef1..95c8c23 100644 --- a/test/site/core_test.clj +++ b/test/site/core_test.clj @@ -68,12 +68,12 @@ (fs/create-dirs (io/file docs "media")) (spit (io/file docs "media" "x.gif") "GIF89a")) (let [site (cond-> {:title "jlt-commons" :description "d" :github-url "https://example.invalid" - :base-path base - :guide-dir guide - :templates-dir templates - :output-dir (io/file (str tmp) "_site") - :home-template (when home-template? "home.html") - :asset-dirs (when assets? [(io/file docs "media")])} + :base-path base + :guide-dir guide + :templates-dir templates + :output-dir (io/file (str tmp) "_site") + :home-template (when home-template? "home.html") + :asset-dirs (when assets? [(io/file docs "media")])} (some? mermaid-override) (assoc :mermaid mermaid-override))] (core/generate! site) {:out (:output-dir site) @@ -98,6 +98,25 @@ (testing "the home page gets the same treatment" (is (str/includes? home "href=\"/some-lib/css/screen.css\""))))) +(deftest static-handler-serves-under-the-configured-base-path + ;; serve! reuses generate!'s output, but generate! bakes /x into every + ;; URL while the handler used to know nothing about it: the homepage + ;; loaded at /, and everything it linked to 404'd under the prefix. + (let [{:keys [out]} (build-fixture-site! "/x") + handler (core/make-static-handler out "/x")] + (testing "a request for the base path plus a file resolves against output-dir" + (is (= 200 (:status (handler {:uri "/x/index.html"}))))) + (testing "a request for the bare base path serves the homepage" + (is (= 200 (:status (handler {:uri "/x/"}))))) + (testing "the same path without the prefix is not found" + (is (= 404 (:status (handler {:uri "/index.html"}))))))) + +(deftest static-handler-is-unaffected-when-root-hosted + (let [{:keys [out]} (build-fixture-site! "") + handler (core/make-static-handler out "")] + (is (= 200 (:status (handler {:uri "/index.html"})))) + (is (= 200 (:status (handler {:uri "/"})))))) + (deftest selected-nav-item-still-matches-after-prefixing ;; write-doc-page! computes active-href separately from nav-items. If the two ;; drift, every nav item silently renders unselected and the bug is cosmetic From 293b25482175fdb64369a0b63a067182dd803805 Mon Sep 17 00:00:00 2001 From: burinc Date: Sat, 29 Aug 2026 23:40:11 +1000 Subject: [PATCH 2/3] docs: bb serve honours the base path, so drop the caveat The README told readers that bb serve ignores :base-path and that a base-pathed site would 404 its own assets locally, with a manual workaround: copy the build into a directory named after the base path and serve its parent. serve! now mounts the build at the configured base path, so the workaround is unnecessary and the caveat is no longer true. --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 869b554..42cf39e 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,21 @@ publish it. ```bash bb test # the test suite bb build ../your-project # generate ../your-project/_site/ -bb serve ../your-project # build, then serve at http://localhost:3000 +bb serve ../your-project # build, then serve, honouring :base-path bb serve ../your-project 4000 bb clean ../your-project # delete the build output ``` `_site/` is generated. Add it to the project's `.gitignore`. -One caveat on `bb serve`: it serves the build at the server's root, so a site -with a `:base-path` will 404 its own assets locally while working perfectly once -deployed. To check a base-pathed site properly, copy the build into a directory -named after the base path and serve its parent. +`bb serve` mounts the build at the project's configured `:base-path`, so the +local URL matches the deployed one. A project with `:base-path "/your-project"` +previews at `http://localhost:3000/your-project/`, and the server prints that +full URL when it starts. A root-hosted project is served at `/` as before. + +That matters because the base path is baked into every generated URL. Serving +the build at the server's root instead would render the homepage unstyled with +every link dead, which is a confusing way to discover that your site is fine. ## Homepages From 9e3844884d42c6115119b3f6e2c6b20eb4c76f2d Mon Sep 17 00:00:00 2001 From: burinc Date: Sun, 30 Aug 2026 00:50:46 +1000 Subject: [PATCH 3/3] test(serve): pin the base path against sibling prefixes strip-base-path requires uri to be exactly base, or base followed by "/". That was the only branch of the cond with no test, and the docstring's claim about siblings rested on it. The input matters more than it looks. Most near-misses are masked by the (subs rel 1) on the next line, which assumes a leading slash: "/x-other" strips to "-other", loses its first character, and 404s under a broken guard too, so it discriminates nothing. "/x_index.html" is the shape that leaks: it strips to "_index.html", the subs drops the underscore, and the handler serves the real homepage at a uri that is not under the base path at all. Verified by breaking the guard: the test fails with 200 where 404 was expected, and passes again once restored. --- test/site/core_test.clj | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/site/core_test.clj b/test/site/core_test.clj index 95c8c23..b34054a 100644 --- a/test/site/core_test.clj +++ b/test/site/core_test.clj @@ -111,6 +111,24 @@ (testing "the same path without the prefix is not found" (is (= 404 (:status (handler {:uri "/index.html"}))))))) +(deftest static-handler-does-not-swallow-a-sibling-of-the-base-path + ;; strip-base-path requires uri to be exactly base, or base followed by + ;; "/". Drop the "/" from that guard and a bare prefix match takes over. + ;; + ;; The input matters. Most near-misses are masked by the (subs rel 1) + ;; on the next line, which assumes a leading slash: "/x-other" would + ;; strip to "-other", then lose its first character, and 404 anyway. + ;; "/x_index.html" is the shape that actually leaks: it strips to + ;; "_index.html", the subs drops the underscore, and the handler serves + ;; the real homepage at a uri that is not under the base path at all. + (let [{:keys [out]} (build-fixture-site! "/x") + handler (core/make-static-handler out "/x")] + (testing "the base path itself and paths under it resolve" + (is (= 200 (:status (handler {:uri "/x"})))) + (is (= 200 (:status (handler {:uri "/x/index.html"}))))) + (testing "a uri merely sharing the base as a string prefix does not" + (is (= 404 (:status (handler {:uri "/x_index.html"}))))))) + (deftest static-handler-is-unaffected-when-root-hosted (let [{:keys [out]} (build-fixture-site! "") handler (core/make-static-handler out "")]