Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 41 additions & 16 deletions src/site/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port> until interrupted."
"Builds, then serves output-dir at http://localhost:<port><base-path>
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)))
49 changes: 43 additions & 6 deletions test/site/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down