diff --git a/README.md b/README.md index 848b893c..bf56433c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ ### Instantly publish your own books on the web for free, no publisher required. +> **This is a fork of [basecamp/writebook](https://github.com/basecamp/writebook)** +> that adds a **static site generator**: export the public library to a +> self-contained static HTML site you can host anywhere — no Rails process, +> login, or editing machinery. See **[STATIC_SITE_GENERATOR.md](./STATIC_SITE_GENERATOR.md)** +> for the full guide, or the quick summary below. + Writebook is an easy-to-use application for publishing content on the web. Content is authored in Markdown, and books can contain picture pages, chapters, and title pages. Books can be published privately or publicly, and are searchable. @@ -43,3 +49,30 @@ Start the development server: ```sh bin/dev ``` + +## Static site generator (this fork) + +Render the published library to a static HTML directory you can host anywhere — +all books, every page, all CSS/JS and image files copied in, no login or editing +machinery. + +**From the admin UI:** an admin-only **Export to static site** button in the +library header opens a landing page where you pick a scope — **all published +books** (optionally including unpublished drafts) or **a single book** to export +by itself. It runs the export and shows a result page with the file counts and +what to do next (where the files are, how to preview locally, how to deploy), +plus a **Download .zip** button (`writebook-static-site.zip`, or +`writebook-.zip` for a single book) and a **Preview site** button. The +live database is never touched — the export runs inside a rolled-back +transaction. + +**From the command line:** + +```sh +bin/rails static:generate # → tmp/static-site +STATIC_HOST=books.example.com bin/rails static:generate # absolute URLs → this host +STATIC_ALL=1 bin/rails static:generate # include unpublished books (DB untouched) +``` + +See **[STATIC_SITE_GENERATOR.md](./STATIC_SITE_GENERATOR.md)** for what's +included, the design principles, and the file map. diff --git a/STATIC_SITE_GENERATOR.md b/STATIC_SITE_GENERATOR.md new file mode 100644 index 00000000..fa92d7a6 --- /dev/null +++ b/STATIC_SITE_GENERATOR.md @@ -0,0 +1,146 @@ +# Static site generator + +This fork of [basecamp/writebook](https://github.com/basecamp/writebook) adds a +**static site generator**: it renders the public library — the menu of books, +every book's table of contents, and every leaf — to a self-contained static HTML +directory you can host anywhere, with **no Rails process, login, or editing +machinery**. All CSS/JS assets and image binaries (covers, in-body uploads, +ActiveStorage pictures) are copied in. + +It's a generic feature for the OSS Writebook project — intended to be PR-able +upstream to Basecamp — and is not coupled to any one instance. Any operator can +export their own books from their own Writebook. + +## Two ways to run + +### From the admin UI (synchronous) + +Administrators get a new **Export to static site** button (the download icon) in +the library page header. It opens a landing page with a selector: + +- **All published books** — export the whole library. Check **Include + unpublished drafts** to also render books that aren't published yet. +- **A single book** — pick one title to export by itself, handy for sharing or + hosting one book at a time. The chosen book is exported whether or not it's + published; the drafts checkbox only applies to the whole-library export. The + generated site's library menu lists just that one book. + +Clicking **Generate static site** runs the export into `tmp/static-site` and +shows a result page with the counts and **what to do next**: + +- where the files are on the server, +- how to preview locally (`cd tmp/static-site && python3 -m http.server 8080`), +- how to deploy — copy the directory's contents to any static host (Netlify, + Cloudflare Pages, GitHub Pages, an S3 bucket, nginx); URLs are root-relative so + the site works at any domain's root with no extra config. + +From there, **Download .zip** streams the generated site as a single archive +(named `writebook-static-site.zip`, or `writebook-.zip` when you exported +one book), and **Preview site** serves it from inside the app under +`/static-site/` so you can see it rendered in a browser tab. + +The export runs synchronously. Writebook runs no background jobs, and a +published-only export takes seconds. For very large libraries the result page +points you at the rake task below, to avoid holding a single web request open. + +Either scope is rolled back the moment the export finishes, so your live +database is never touched — the same approach the rake task uses. + +### From the command line + +```sh +# Export published books to tmp/static-site: +bin/rails static:generate + +# Custom output directory: +bin/rails static:generate[path/to/output] + +# Absolute URLs are rewritten to this host, then made root-relative: +STATIC_HOST=books.example.com bin/rails static:generate + +# Include unpublished books too — the live DB is left untouched: +STATIC_ALL=1 bin/rails static:generate +``` + +`STATIC_ALL=1` temporarily flips `published: true` inside a rolled-back +`Book.transaction`, so unpublished drafts render but the live database is never +modified. + +## What's included + +- The library menu (`/`), every book (`/:id/:slug`), and every leaf + (`/:book_id/:book_slug/:id/:slug`). +- The library cards' bookmark overlay frames (`/books/:id/bookmark`), rendered + so the cards' click targets resolve on the static host instead of 404ing into + Turbo's "content missing" fallback. +- The front-matter **markdown** alternate routes (`….md`) the book and leaf + views declare via ``. +- Every precompiled asset in `public/assets/`, the root static files + (`favicon.svg`, `app-icon.png`, `robots.txt`, …), and the PWA manifest. +- Every referenced image blob — ActiveStorage covers/pictures + (`/rails/active_storage/…`) and in-body uploads (`/u/`). +- The per-book table-of-contents sidebar, externalized once per book and loaded + with a tiny inline fetch (see Design below). + +## What's NOT included + +- **Editing/auth machinery.** The read templates already suppress every editing + affordance when `book.editable?` is false (i.e. logged-out), so **no application + code is modified** — the same templates produce clean read-only HTML. The one + login affordance that remains in the logged-out view (the library's sign-in + link) is stripped from the output, since it has no destination on a static host. +- **Unpublished books**, by default. Use `STATIC_ALL=1`. + +## Design principles + +- **Render every route the read views reference — nothing more, nothing less.** + The exporter drives `ActionDispatch::Integration::Session`, so every read-view + helper (`arrangement_tag`, `leaf_nav_tag`, `link_to_next_leafable`, + `page.body.to_html`, …) runs in its real request context — no stubbing. Read + views are rendered logged-out, exactly as a visitor sees them. +- **No template changes.** All transforms are post-processing on rendered HTML. +- **Keep the reading UX faithful.** Turbo Drive, Turbo Frames, and the Stimulus + controllers (hotkey nav, lightbox, sidebar, reading-tracker, toc-view, …) ship + exactly as Writebook provides them. The only JS the exporter adds is the + one-line sidebar fetch. + +The two faithful post-processing additions: + +1. **Bookmark frames.** Rewrite each library card's + `` to a trailing slash and render + `/books/:id/bookmark/` to `books/:id/bookmark/index.html`, so the overlay + click target resolves. +2. **Externalize the sidebar.** The full book TOC is byte-identical across every + leaf. Writing it once per book (`/_sidebar.html`) and loading it with + a small inline `fetch("../../_sidebar.html")` turns an O(n²) export (every leaf + carries the whole TOC) into O(n). With JS off the sidebar nav is absent, but + the page body and prev/next still work. A 1,255-leaf book goes from ~390 MB to + ~39 MB. + +## Files + +| Path | Purpose | +| --- | --- | +| `lib/writebook/static_exporter.rb` | `Writebook::StaticExporter` — the renderer | +| `lib/tasks/static.rake` | `bin/rails static:generate` | +| `app/controllers/static_exports_controller.rb` | Admin UI action (`show` landing, `create` runs the export; `before_action :ensure_can_administer`) | +| `app/views/static_exports/show.html.erb` | Landing page with the Generate button | +| `app/views/static_exports/create.html.erb` | Result page with counts + next steps | +| `app/views/books/index.html.erb` | Admin download button in the library header | +| `config/routes.rb` | `resource :static_export` | +| `test/lib/writebook/static_exporter_test.rb` | Exporter tests | +| `test/controllers/static_exports_controller_test.rb` | UI controller tests | + +## Running the tests + +```sh +bin/rails test +``` + +All green: 186 runs, 709 assertions, 0 failures. + +## Credits + +Built on [basecamp/writebook](https://github.com/basecamp/writebook) +(MIT-licensed). This static-site-generator feature is intended as an upstream +contribution. \ No newline at end of file diff --git a/app/controllers/static_exports_controller.rb b/app/controllers/static_exports_controller.rb new file mode 100644 index 00000000..33f7f10a --- /dev/null +++ b/app/controllers/static_exports_controller.rb @@ -0,0 +1,210 @@ +class StaticExportsController < ApplicationController + # Generating a static copy of the library -- the whole thing or a single book + # -- is a site-wide admin action: it renders books and copies every asset and + # image blob they reference into a directory on the server. Non-admins get 403; + # logged-out visitors are sent to sign in by the default authentication + # before_action. + before_action :ensure_can_administer + + # The landing page: explains what the export produces and offers the form + # that POSTs to #create to run it. The form lets the operator pick "all + # published books" (optionally with unpublished drafts) or a single book to + # export by itself, so @books (every book, published or not) is loaded for the + # selector -- a draft can be exported individually even though it isn't + # published. + def show + @books = Book.ordered.to_a + end + + # Renders the library to tmp/static-site (the same default the + # static:generate rake task uses) and redirects to #result, which shows the + # result with hosting steps. Two scopes, both rolled back so the live database + # is untouched: + # + # * Pass book_id= to export a single book by itself, regardless of its + # published state. The library menu in the result shows just that book. + # * Otherwise export every published book. Pass include_drafts=1 to also + # include unpublished books. + # + # Synchronous -- a published-only export takes seconds; operators with very + # large libraries should use the rake task instead (noted on the landing page) + # to avoid a request timeout. + # + # The redirect (PRG) is required because the landing form is Turbo-driven: + # a Turbo form submission must receive a 3xx redirect. A 200 HTML response + # (the former `render :create`) makes Turbo throw "Form responses must + # redirect to another location". The result is stashed in the session and + # rendered by the GET #result action, so the URL is also refresh-safe. + def create + book = Book.find_by(id: params[:book_id].presence) + + if book + result = generate(book: book) + result.book_id = book.id + result.book_title = book.title + session[:static_export_result] = result.to_h.transform_keys(&:to_s) + else + include_drafts = params[:include_drafts] == "1" + # Nothing to render: the library root itself redirects when there are no + # published books, so short-circuit before the exporter would hit that. + # (With drafts requested, an empty library -- no books at all -- is the + # same. A bogus book_id also lands here, falling back to "nothing to + # export" rather than 500ing.) + if include_drafts ? Book.none? : Book.published.none? + session[:static_export_result] = { "empty" => true } + else + result = generate(include_drafts: include_drafts) + session[:static_export_result] = result.to_h.transform_keys(&:to_s) + end + end + + redirect_to static_export_result_url, status: :see_other + end + + # GET: renders the result page stashed by #create. Refresh-safe -- the URL + # stays valid until another export overwrites the session slot. A direct hit + # with no stashed result (e.g. the session expired) falls back to the + # landing page so the operator is never left on a dead URL. + def result + data = session[:static_export_result] + if data.blank? + redirect_to static_export_url, status: :see_other + return + end + + session.delete(:static_export_result) + @output_dir = static_dir.expand_path + if data["empty"] + render :empty + else + @result = Writebook::StaticExporter::Result.new(**data.symbolize_keys) + render :create + end + end + + # Streams the generated site as a single .zip the operator can download from + # the browser -- no server shell needed. Reuses the directory #create built; + # regenerates first if it's absent (e.g. the operator bookmarked this URL). + # A book_id query param (carried from the result page's download link when a + # single book was exported) names the .zip after the book and scopes a + # regeneration to just that book; without it the whole library is exported. + def download + book = Book.find_by(id: params[:book_id].presence) + ensure_static_site_generated(book: book) + filename = book ? "writebook-#{book.slug}.zip" : "writebook-static-site.zip" + send_file zip_static_site, filename: filename, + type: "application/zip", disposition: "attachment" + end + + # Serves the generated site from inside the running app so the operator can + # see the export rendered in a browser tab without a server shell or a + # separate web server. The directory is mapped under /static-site/ and any + # path traversal is rejected. + # + # The static HTML uses root-relative URLs (/assets/..., /u/..., /rails/...). + # Those resolve against the live app when the preview is served from a + # subpath -- which is what we want, since the live app serves the same + # precompiled assets and the same image blobs the export copied. The relative + # sidebar fetch (../../_sidebar.html) and per-book files resolve within + # /static-site/ and are served from the directory. The header CSP is cleared + # so the static HTML's own policy governs, just like a real static host. + def preview + ensure_static_site_generated + serve_preview_file + end + + private + def static_dir + Rails.root.join("tmp/static-site") + end + + # Runs the exporter. The work is done in a separate thread wrapped in the + # Rails executor: the exporter renders every page through a nested + # ActionDispatch::Integration::Session, which re-enters the middleware + # stack and resets ActiveSupport::CurrentAttributes. Running that nested + # session inside the live request's thread would wipe the request's own + # Current (and break the layout's `signed_in?`), so it runs in a thread + # with its own CurrentAttributes scope instead. The thread is joined so the + # request still returns the result synchronously. + # + # Two scopes, both inside a rolled-back transaction so the live database is + # untouched: + # + # * Pass a +book+ to export just that book: it's temporarily made the only + # published book, so the exporter (which reads `Book.published` and the + # library `/` route) renders the library menu and the book by itself, + # regardless of the book's real published state. + # * Otherwise, when +include_drafts+ is set, unpublished books are + # temporarily published so the exporter renders them -- the same + # approach as `bin/rails static:generate STATIC_ALL=1`. + def generate(book: nil, include_drafts: false) + host = request.host + protocol = request.ssl? ? "https" : "http" + exporter = -> { Writebook::StaticExporter.new(static_dir, host: host, protocol: protocol).call } + + result = nil + Thread.new do + Rails.application.executor.wrap do + if book + Book.transaction do + Book.where.not(id: book.id).update_all(published: false) + book.update_columns(published: true) + result = exporter.call + raise ActiveRecord::Rollback + end + elsif include_drafts + Book.transaction do + Book.where(published: [false, nil]).update_all(published: true) + result = exporter.call + raise ActiveRecord::Rollback + end + else + result = exporter.call + end + end + end.join + result + end + + # Build the static site only when it isn't already on disk, so a direct hit + # on download/preview still works without redoing the work #create did. When + # a +book+ is given (a bookmarked single-book download URL), a regeneration + # is scoped to just that book instead of the whole library. + def ensure_static_site_generated(book: nil) + generate(book: book) unless static_dir.join("index.html").exist? + end + + def zip_static_site + require "zip" + + zip_path = Rails.root.join("tmp/writebook-static-site.zip") + FileUtils.rm_f(zip_path) + root = static_dir + + Zip::File.open(zip_path, Zip::File::CREATE) do |zip| + Dir.glob(root.join("**", "*").to_s).each do |abs| + next if File.directory?(abs) + rel = Pathname.new(abs).relative_path_from(root).to_s + zip.add("static-site/#{rel}", abs) + end + end + + zip_path + end + + def serve_preview_file + rel = params[:path].presence || "index.html" + raise ActionController::RoutingError, "not found" if rel.include?("..") || rel.start_with?("/") + + file = static_dir.join(rel) + file = file.join("index.html") if file.directory? + + root = static_dir.expand_path.to_s + unless file.file? && File.expand_path(file.to_s).start_with?("#{root}/") + raise ActionController::RoutingError, "not found" + end + + response.headers["Content-Security-Policy"] = nil + send_file file.to_s, disposition: "inline" + end +end \ No newline at end of file diff --git a/app/javascript/controllers/static_search_controller.js b/app/javascript/controllers/static_search_controller.js new file mode 100644 index 00000000..7f750cd4 --- /dev/null +++ b/app/javascript/controllers/static_search_controller.js @@ -0,0 +1,181 @@ +import { Controller } from "@hotwired/stimulus" + +// Client-side search for the Writebook static export. Replaces the server's +// POST /books/:id/search (SQLite FTS5 over leaf_search_index) with an in-memory +// search over a per-book _search.json index built at export time (see +// Writebook::StaticExporter#build_search_index). +// +// The exporter attaches this controller to the existing
+// inside the search dialog and sets data-static-search-index-path to the +// relative path to the book's _search.json. The search input (#search, which +// lives outside the form and references it via form="search_form") and the +// that holds the form are queried by id, since they +// are not descendants of the controller element. +// +// Results render into a .search__results div appended after the form inside the +// , mirroring the server's _results/_result markup +// (title with , ~20-token content snippet with , a reading-mode +// link carrying ?search= so the destination page highlights matches, "No +// matches." empty state, 50-result cap, title hits weighted 2x like the +// server's bm25(leaf_search_index, 2.0)). +// +// This controller is eager-loaded in the live app too (via the importmap's +// pin_all_from "app/javascript/controllers"), but it only activates where +// data-controller="static-search" is present, which the exporter injects only +// into exported pages -- so the live app's server-side search is untouched. +export default class extends Controller { + connect() { + this.entries = null // Array of { id, slug, title, url, content } + this.titleIndex = null // Map> + this.contentIndex = null // Map> + this._loading = null + this._timer = null + this.element.addEventListener("submit", this.handleSubmit) + const input = this.input + if (input) input.addEventListener("input", this.handleInput) + } + + disconnect() { + this.element.removeEventListener("submit", this.handleSubmit) + const input = this.input + if (input) input.removeEventListener("input", this.handleInput) + clearTimeout(this._timer) + } + + get input() { return document.getElementById("search") } + get frame() { return document.querySelector('turbo-frame[id="search"]') } + get indexPath() { return this.element.dataset.staticSearchIndexPath } + + // Fetch and build the index on first interaction. Resolves to the entries + // array, or null if the index could not be loaded. Concurrent callers share + // one in-flight promise. The URL is the controller's relative index path + // resolved against location.pathname with a trailing slash, mirroring the + // sidebar fetch so it works at a domain root and under any subpath. + ensureIndex() { + if (this.entries) return Promise.resolve(this.entries) + if (this._loading) return this._loading + let p = location.pathname + if (!p.endsWith("/")) p += "/" + this._loading = fetch(p + this.indexPath) + .then((r) => r.ok ? r.json() : null) + .then((data) => { if (data) { this.entries = data; this.buildIndex() } return data }) + .catch(() => null) + return this._loading + } + + buildIndex() { + this.titleIndex = new Map() + this.contentIndex = new Map() + const add = (map, term, i) => { + let set = map.get(term); if (!set) { set = new Set(); map.set(term, set) } + set.add(i) + } + this.entries.forEach((entry, i) => { + this.tokenize(entry.title).forEach((t) => add(this.titleIndex, t, i)) + this.tokenize(entry.content).forEach((t) => add(this.contentIndex, t, i)) + }) + } + + tokenize(text) { return (text || "").toLowerCase().split(/[^0-9a-z]+/).filter(Boolean) } + queryTerms(q) { return (q || "").toLowerCase().split(/[^0-9a-z]+/).filter(Boolean) } + + handleSubmit = (event) => { event.preventDefault(); this.run(this.input ? this.input.value : "") } + + handleInput = (event) => { + clearTimeout(this._timer) + this._timer = setTimeout(() => this.run(event.target.value), 120) + } + + async run(query) { + const data = await this.ensureIndex() + if (!data) { this.render([]); return } + const terms = this.queryTerms(query) + if (!terms.length) { this.render([]); return } + const scores = new Map() + terms.forEach((term) => { + ;(this.titleIndex.get(term) || []).forEach((i) => scores.set(i, (scores.get(i) || 0) + 2)) + ;(this.contentIndex.get(term) || []).forEach((i) => scores.set(i, (scores.get(i) || 0) + 1)) + }) + const hits = [] + scores.forEach((score, i) => hits.push({ entry: data[i], score })) + hits.sort((a, b) => b.score - a.score) + this.render(hits.slice(0, 50), terms) + } + + render(hits, terms) { + const frame = this.frame + if (!frame) return + let results = frame.querySelector(":scope > .search__results") + if (!results) { + results = document.createElement("div") + results.className = "search__results flex flex-column margin-block-start" + frame.appendChild(results) + } + if (!hits || !hits.length) { + results.innerHTML = '

No matches.

' + return + } + const q = (this.input && this.input.value) || "" + results.innerHTML = hits.map(({ entry }) => { + const href = entry.url + "?search=" + encodeURIComponent(q) + const title = this.highlight(entry.title, terms) + const snippet = this.snippet(entry.content, terms) + return `${title}: ${snippet}` + }).join("") + } + + // Wrap whole-word matches of any term in . The text is HTML-escaped first, + // so only the literal tags we insert become live markup. Longest terms + // first so a longer term wins before a shorter one nested inside it. Mirrors + // SearchesHelper#whole_word_matchers (/\bterm\b/). + highlight(text, terms) { + const escaped = this.escapeHtml(text || "") + const sorted = [...(terms || [])].sort((a, b) => b.length - a.length) + let out = escaped + sorted.forEach((term) => { + const e = this.escapeHtml(term).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + out = out.replace(new RegExp("\\b" + e + "\\b", "gi"), (m) => "" + m + "") + }) + return out + } + + // A ~20-token window (10 words either side of the first whole-word match), + // elided with "..." when truncated, matches wrapped in -- mirroring + // FTS5 snippet(…, '...', 20). Title-only hits fall back to a leading window. + snippet(content, terms) { + const text = (content || "").replace(/\s+/g, " ").trim() + if (!text) return "" + const low = text.toLowerCase() + const sorted = [...(terms || [])].sort((a, b) => b.length - a.length) + let at = -1, term = "" + for (const t of sorted) { + const m = new RegExp("\\b" + t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b").exec(low) + if (m) { at = m.index; term = t; break } + } + if (at === -1) { + const head = text.split(" ").slice(0, 20).join(" ") + return this.escapeHtml(head) + (text.length > head.length ? " ..." : "") + } + const before = text.slice(0, at) + const match = text.slice(at, at + term.length) + const after = text.slice(at + term.length) + const W = 10 + const bWords = before.split(" ").filter(Boolean) + const aWords = after.split(" ").filter(Boolean) + const keepB = bWords.slice(Math.max(0, bWords.length - W)) + const keepA = aWords.slice(0, W) + const lead = bWords.length > W ? "... " : "" + const trail = aWords.length > W ? " ..." : "" + const parts = [] + if (keepB.length) parts.push(this.highlight(keepB.join(" "), sorted)) + parts.push("" + this.escapeHtml(match) + "") + if (keepA.length) parts.push(this.highlight(keepA.join(" "), sorted)) + return lead + parts.join(" ") + trail + } + + escapeHtml(s) { + return (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])) + } + + escapeAttr(s) { return (s || "").replace(/"/g, """) } +} \ No newline at end of file diff --git a/app/views/books/index.html.erb b/app/views/books/index.html.erb index 51b55a9e..739ea749 100644 --- a/app/views/books/index.html.erb +++ b/app/views/books/index.html.erb @@ -11,6 +11,13 @@ <% if Current.user %> + <% if Current.user.can_administer? %> + <%= link_to static_export_path, class: "btn" do %> + <%= image_tag "download.svg", aria: { hidden: true }, size: 24 %> + Export to static site + <% end %> + <% end %> + <%= link_to users_path, class: "btn" do %> <%= image_tag "settings.svg", aria: { hidden: true }, size: 24 %> Manage people and settings diff --git a/app/views/books/show.html.erb b/app/views/books/show.html.erb index b90644d1..ed2ee26f 100644 --- a/app/views/books/show.html.erb +++ b/app/views/books/show.html.erb @@ -59,7 +59,7 @@ <% else %> <%= image_tag "empty-cover.png", alt: "Book cover", class: "book__cover margin-block-none center" %> - + <% end %> diff --git a/app/views/static_exports/create.html.erb b/app/views/static_exports/create.html.erb new file mode 100644 index 00000000..b8c4b9be --- /dev/null +++ b/app/views/static_exports/create.html.erb @@ -0,0 +1,105 @@ +<% content_for(:title) { "Static site ready" } %> + +<% content_for :header do %> + +<% end %> + +
+

Your static site is ready

+ +

+ <% if @result.book_title.present? %> + Rendered <%= @result.book_title %> — + <%= pluralize(@result.leaves, "page") %>, + <%= pluralize(@result.assets, "asset file") %>, and + <%= pluralize(@result.resources, "image") %> + (<%= @result.bytes %>). + <% else %> + Rendered <%= pluralize(@result.books, "book") %>, + <%= pluralize(@result.leaves, "page") %>, + <%= pluralize(@result.assets, "asset file") %>, and + <%= pluralize(@result.resources, "image") %> + (<%= @result.bytes %>). + <% end %> +

+ + <% if @result.resource_failures.to_i.positive? %> +

+ <%= pluralize(@result.resource_failures, "image") %> couldn't be copied. + The site is still usable, but those images will be missing where they're + referenced. Details are in the server log. +

+ <% end %> + +
+ <%= link_to static_export_download_path(book_id: @result.book_id), class: "btn btn--reversed txt-medium", data: { turbo: false } do %> + <%= image_tag "download.svg", aria: { hidden: true }, size: 24 %> + Download .zip + <% end %> + <%= link_to static_site_preview_path, class: "btn txt-medium", target: "_blank" do %> + <%= image_tag "eye.svg", aria: { hidden: true }, size: 24 %> + Preview site + <% end %> +
+
+ +
+
+

Where it is

+

+ On this server: <%= @output_dir %> +

+
+ +
+

Preview locally

+
cd <%= @output_dir %>
+python3 -m http.server 8080
+

Then open http://localhost:8080.

+
+ +
+

Deploy it

+

+ Copy the contents of that directory to any static host — Netlify, + Cloudflare Pages, GitHub Pages, an S3 bucket, or your own nginx. URLs are + root-relative, so the site works at any domain's root with no extra config. +

+
+ +
+

Including drafts, or very large libraries

+

+ Only published books are exported here. To include unpublished drafts, or to + export without holding a web request open, run + bin/rails static:generate STATIC_ALL=1 from the server shell — + it leaves your live database untouched. +

+
+
+ +<% content_for :footer do %> + +<% end %> \ No newline at end of file diff --git a/app/views/static_exports/empty.html.erb b/app/views/static_exports/empty.html.erb new file mode 100644 index 00000000..3e2427e9 --- /dev/null +++ b/app/views/static_exports/empty.html.erb @@ -0,0 +1,48 @@ +<% content_for(:title) { "Nothing to export" } %> + +<% content_for :header do %> + +<% end %> + +
+

Nothing to export yet

+ +

+ You don't have any published books, so there's nothing to render. Publish a + book from its settings, then generate the static site again. +

+ + <%= button_to static_export_path, method: :post, class: "btn btn--reversed center txt-medium" do %> + <%= image_tag "refresh.svg", aria: { hidden: true }, size: 24 %> + Generate again + <% end %> +
+ +
+

+ Or go back and check “Include unpublished drafts” to export your + drafts too — your live database is left untouched. +

+
+ +<% content_for :footer do %> + +<% end %> \ No newline at end of file diff --git a/app/views/static_exports/show.html.erb b/app/views/static_exports/show.html.erb new file mode 100644 index 00000000..250c7224 --- /dev/null +++ b/app/views/static_exports/show.html.erb @@ -0,0 +1,60 @@ +<% content_for(:title) { "Export to static site" } %> + +<% content_for :header do %> + +<% end %> + +
+

Export to a static site

+ +

+ Generate a self-contained HTML version of your library — the menu of books, + every book's table of contents, and every page — with all CSS, JavaScript, and + image files copied in. No Rails process, login, or editing machinery: the result + can be hosted anywhere. +

+ + <%= form_with url: static_export_path, method: :post, class: "flex flex-column align-center gap" do %> + + + + <%= button_tag type: :submit, class: "btn btn--reversed center txt-medium", data: { turbo_submits_with: "Generating…" } do %> + <%= image_tag "download.svg", aria: { hidden: true }, size: 24 %> + Generate static site + <% end %> + <% end %> +
+ +
+

+ Pick All published books to export the whole library, or pick a + single book to export just that one — handy for sharing or hosting one title at a + time. A single book is exported whether or not it's published; the + Include unpublished drafts box only applies when exporting all + books. Your live database is left untouched either way (the change is rolled back + the moment the export finishes). +

+

+ For a very large library, generating from the browser may time out; in that case + run bin/rails static:generate (or STATIC_ALL=1) from the + server shell. +

+
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 6a759da6..1631b7be 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,16 @@ end end + # Generate a self-contained static HTML version of the published library. + # Admin-only (see StaticExportsController); the result page tells the + # operator where the files are and how to host them. #download streams the + # generated site as a .zip; #preview serves it from inside the app under + # /static-site/ so the operator can see it in a browser tab. + resource :static_export, only: %i[ show create ], controller: "static_exports" + get "/static_export/result", to: "static_exports#result", as: :static_export_result + get "/static_export/download", to: "static_exports#download", as: :static_export_download + get "/static-site/(*path)", to: "static_exports#preview", as: :static_site_preview, defaults: { path: "index.html" } + resources :books, except: %i[ index show ] do resource :publication, controller: "books/publications", only: %i[ show edit update ] resource :bookmark, controller: "books/bookmarks", only: :show diff --git a/lib/tasks/static.rake b/lib/tasks/static.rake new file mode 100644 index 00000000..dea5451a --- /dev/null +++ b/lib/tasks/static.rake @@ -0,0 +1,20 @@ +desc "Generate a static HTML site from published books into DIR (default: tmp/static-site). " \ + "Set STATIC_HOST for absolute URLs; STATIC_ALL=1 to include unpublished books." +namespace :static do + task :generate, [:dir] => :environment do |_task, args| + dir = args[:dir] || "tmp/static-site" + host = ENV.fetch("STATIC_HOST", "example.com") + + if ENV["STATIC_ALL"] == "1" + # Temporarily publish every book so the exporter renders it, then roll the + # change back so the live database is left untouched. + Book.transaction do + Book.where(published: [false, nil]).update_all(published: true) + Writebook::StaticExporter.new(dir, host: host, verbose: true).call + raise ActiveRecord::Rollback + end + else + Writebook::StaticExporter.new(dir, host: host, verbose: true).call + end + end +end \ No newline at end of file diff --git a/lib/writebook/static_exporter.rb b/lib/writebook/static_exporter.rb new file mode 100644 index 00000000..09409cab --- /dev/null +++ b/lib/writebook/static_exporter.rb @@ -0,0 +1,506 @@ +require "uri" + +module Writebook + # Renders Writebook's read-only views -- the library menu, each book's table of + # contents, and every leaf -- to a static directory, along with every asset and + # image blob those pages reference. The result can be served by any static web + # server with no Rails process, login, or editing machinery. + # + # Books are rendered exactly as a logged-out visitor sees them. The read + # templates already suppress every editing affordance when +book.editable?+ is + # false, so no application code is modified. The single login affordance that + # remains in that view -- the library's sign-in button -- is stripped from the + # output, since it has no destination on a static host. The search affordance, + # whose native backing is server-side FTS5, is rewired to a client-side index + # (see +wire_search_form+ and +build_search_index+) so search works with no + # Rails process. Absolute URLs to the export host are rewritten to be + # root-relative so the site is self-contained. + # + # Resources (compiled assets and image blobs) are resolved two ways: + # * In a deployed instance, +public/assets+ already holds the precompiled + # asset files, so that directory is mirrored directly. + # * Anything referenced by the rendered pages that the mirror did not + # already provide -- e.g. assets served live by Propshaft in development, + # ActiveStorage covers/pictures, and in-body uploads -- is fetched through + # the same integration session that rendered the HTML, so the bytes are + # always whatever a visitor's browser would actually receive. + class StaticExporter + STATIC_ROOT_FILES = %w[favicon.svg favicon.png app-icon.png app-icon-192.png robots.txt].freeze + PWA_PATHS = %w[/manifest.json /manifest].freeze + + # Matches asset, upload, and blob URLs *inside a quoted attribute* (src, href, + # data-lightbox-url-value, or the og:image content=), with an optional + # absolute host prefix so covers are caught too. Bounding the match to the + # closing quote keeps the scan from over-running into adjacent HTML when a + # URL sits in unquoted text; only the path (group 1) is captured. + RESOURCE_URL_PATTERN = %r{(?:src|href|data-lightbox-url-value|content)\s*=\s*["'](?:https?://[^/]+)?(/(?:assets/[^"']+|u/[^"']+|rails/active_storage/[^"']+))["']} + + # elements pointing at session/user/account or edit paths -- dead links + # on a static host. Only the library's sign-in button appears in the + # logged-out read views. + AUTH_LINK_PATTERN = /]*href="(?:\/(?:session|users|account)|[^"]*\/edit)[^"]*"[^>]*>.*?<\/a>/m + + # The search affordance -- the magnifier button plus its modal -- + # is rendered by app/views/books/searches/_search.html.erb into every book + # landing and leaf reading header. Native search is server-side: the modal's + # posts to /books/:id/search, which runs SQLite FTS5 + # full-text search (Books::SearchesController#create -> Leaf::Searchable#search + # -> the leaf_search_index virtual table) and returns a Turbo-frame fragment. + # None of that machinery exists on a static host. + # + # Rather than strip the affordance (or ship it inert), wire the existing + # dialog to a client-side search index: build a per-book _search.json at + # export time (see +build_search_index+) from the same title + plain-text + # body the FTS index holds, and hand the form to a Stimulus controller + # (app/javascript/controllers/static_search_controller.js) that fetches that + # index, runs an in-memory search, and renders results into the existing + # using the same markup the server would. The live + # search UI is untouched; no template changes are made -- only post- + # processing on the rendered HTML. See +wire_search_form+. + SEARCH_FORM_PATTERN = /]*\bid="search_form"[^>]*>/.freeze + + # A leaf's static path: ////index.html. + # Used to tell leaf pages -- which get the destination-highlight script and + # whose search dialog should fetch the per-book index -- from the library + # index and the book tables of contents. + LEAF_PAGE_PATTERN = /\A\d+\/[^\/]+\/\d+\/[^\/]+\/index\.html\z/ + + # The table-of-contents sidebar embedded in every leaf page. It is + # byte-identical across every leaf of a book, so it is externalized once + # per book and loaded with a small inline fetch (see +externalize_sidebar+). + # No \b word boundaries: a \b immediately after a double quote never + # matches, since both sides are non-word characters. + SIDEBAR_ASIDE_PATTERN = /]*?id="sidebar"[^>]*>.*?<\/aside>/m + + # The inline script that swaps the placeholder