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 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..b34054a 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,43 @@ (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-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 "")] + (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