From 8d295c310a2fb434d7f24b53d4d3c45577972977 Mon Sep 17 00:00:00 2001 From: Sergey Zhumatiy Date: Mon, 21 Sep 2026 09:00:41 -0700 Subject: [PATCH 1/5] initial theming support --- config/options.cr | 10 ++++++++++ src/theme.cr | 23 +++++++++++++++++++++++ src/views/layouts/_nav.slang | 5 ++++- src/views/layouts/application.slang | 13 +++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/theme.cr diff --git a/config/options.cr b/config/options.cr index f30b625..b6fed9f 100644 --- a/config/options.cr +++ b/config/options.cr @@ -32,6 +32,14 @@ module Fluence # with FLUENCE_PUBLICDIR. @publicdir = ::File.expand_path ENV.fetch("FLUENCE_PUBLICDIR") { default_publicdir } + # Directory of theming overrides: header.html, footer.html, and + # nav_extra.html (raw HTML dropped into the layout as-is), and + # custom.css (appended after the built-in stylesheet). Any file + # that's missing is simply skipped, so a site can override as much + # or as little as it wants without touching the templates. Defaults + # to metadir/theme; override with FLUENCE_THEMEDIR. + @themedir = ::File.expand_path ENV.fetch("FLUENCE_THEMEDIR") { ::File.join(@metadir, "theme") } + # Visible part of URL through which pages are accessed, e.g. /pages/my_page @pages_prefix = "/pages" # Start page - homepage. Defaults to /pages/home @@ -78,6 +86,7 @@ module Fluence Dir.mkdir_p @datadir Dir.mkdir_p @metadir + Dir.mkdir_p @themedir end getter host : String @@ -89,6 +98,7 @@ module Fluence getter datadir : String getter metadir : String getter publicdir : String + getter themedir : String getter homepage : String getter pages_prefix : String getter media_prefix : String diff --git a/src/theme.cr b/src/theme.cr new file mode 100644 index 0000000..9e172a0 --- /dev/null +++ b/src/theme.cr @@ -0,0 +1,23 @@ +# Site-level theming slots: raw HTML/CSS overrides dropped in +# Fluence::OPTIONS.themedir, read fresh on every render so a site admin +# can restyle header, footer, nav, and stylesheet without rebuilding or +# restarting the binary. Missing files render as empty content — the +# built-in layout is the default theme. +module Fluence::Theme + SLOTS = %w[header footer nav_extra] + + # Raw HTML for a named slot ("header", "footer", or "nav_extra"), or "" + # if the site hasn't overridden it. + def self.slot(name : String) : String + read ::File.join(Fluence::OPTIONS.themedir, "#{name}.html") + end + + # Contents of the site's custom stylesheet, or "" if it has none. + def self.custom_css : String + read ::File.join(Fluence::OPTIONS.themedir, "custom.css") + end + + private def self.read(path : String) : String + ::File.exists?(path) ? ::File.read(path) : "" + end +end diff --git a/src/views/layouts/_nav.slang b/src/views/layouts/_nav.slang index b6b5477..6790674 100644 --- a/src/views/layouts/_nav.slang +++ b/src/views/layouts/_nav.slang @@ -45,4 +45,7 @@ nav.navbar.navbar-expand-lg.bg-body-tertiary a class=login_class href="#{Fluence::OPTIONS.users_prefix}/login" | Login - if Fluence::OPTIONS.registration_open? li.navbar-text or - a class=register_class href="#{Fluence::OPTIONS.users_prefix}/register" Register \ No newline at end of file + a class=register_class href="#{Fluence::OPTIONS.users_prefix}/register" Register + - nav_extra = Fluence::Theme.slot "nav_extra" + - if !nav_extra.empty? + == nav_extra \ No newline at end of file diff --git a/src/views/layouts/application.slang b/src/views/layouts/application.slang index 65f4bf8..9a7dbd6 100644 --- a/src/views/layouts/application.slang +++ b/src/views/layouts/application.slang @@ -21,8 +21,16 @@ html lang="en" data-bs-theme=theme_attr link rel="stylesheet" href="/assets/stylesheet/base.css" script src="/assets/javascript/base.js" script == "Fluence.settings = #{current_user.settings.to_json};" + - custom_css = Fluence::Theme.custom_css + - if !custom_css.empty? + style == custom_css body + - theme_header = Fluence::Theme.slot "header" + - if !theme_header.empty? + #theme-header + == theme_header + #navbar == render_template "layouts/_nav.slang" @@ -37,3 +45,8 @@ html lang="en" data-bs-theme=theme_attr .w-100 #main-content == content + + - theme_footer = Fluence::Theme.slot "footer" + - if !theme_footer.empty? + #theme-footer + == theme_footer From 0bac5b5f026cad6c6d24784be368536a6f21b6b1 Mon Sep 17 00:00:00 2001 From: Sergey Zhumatiy Date: Mon, 21 Sep 2026 14:24:31 -0700 Subject: [PATCH 2/5] limit guest view --- config/options.cr | 17 ++++++++++++ spec/controllers_spec.cr | 41 ++++++++++++++++++++++++++--- src/controllers/pages_controller.cr | 18 ++++++++++--- src/page.cr | 20 ++++++++++++-- src/views/pages/show.slang | 16 +++++++++++ src/views/pages/show_guest.slang | 27 +++++++++++++++++++ 6 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 src/views/pages/show_guest.slang diff --git a/config/options.cr b/config/options.cr index b6fed9f..3a01e73 100644 --- a/config/options.cr +++ b/config/options.cr @@ -40,6 +40,21 @@ module Fluence # to metadir/theme; override with FLUENCE_THEMEDIR. @themedir = ::File.expand_path ENV.fetch("FLUENCE_THEMEDIR") { ::File.join(@metadir, "theme") } + # Extra language codes pages can be translated into, e.g. "de,fr". + # A page's default-language content lives at "pages/.md" as + # usual; a translation lives alongside it at "pages/..md" + # and is reached as "?lang=" on the page's normal URL, so ACLs + # (which key off the URL) cover every translation automatically. + # Empty (the default) turns the feature off. Set with FLUENCE_LANGUAGES. + @languages = ENV.fetch("FLUENCE_LANGUAGES", "").split(",").map(&.strip).reject(&.empty?) + + # Label for the untranslated "pages/.md" content in the + # language switcher; purely cosmetic, since that content is always + # what's served when no ?lang= (or an unconfigured one) is given. + # Set with FLUENCE_DEFAULT_LANG. + @default_lang = ENV.fetch("FLUENCE_DEFAULT_LANG", "en") + @languages = @languages.reject { |code| code == @default_lang } + # Visible part of URL through which pages are accessed, e.g. /pages/my_page @pages_prefix = "/pages" # Start page - homepage. Defaults to /pages/home @@ -99,6 +114,8 @@ module Fluence getter metadir : String getter publicdir : String getter themedir : String + getter languages : Array(String) + getter default_lang : String getter homepage : String getter pages_prefix : String getter media_prefix : String diff --git a/spec/controllers_spec.cr b/spec/controllers_spec.cr index b1bf88c..8d5f7d6 100644 --- a/spec/controllers_spec.cr +++ b/spec/controllers_spec.cr @@ -524,7 +524,11 @@ describe "page menu over HTTP" do Fluence::Page.new(name).update! SPEC_USER, "# #{name.split('/').last.capitalize}\n" end - body = SpecClient.new.get("/pages/docs/guide").body + # The page menu is part of the signed-in layout; guests get a + # stripped-down view without it. + client = SpecClient.login "editor", "sekrit123" + + body = client.get("/pages/docs/guide").body body.should contain %(Guide) body.should contain %(
) body.should contain %(
) @@ -534,7 +538,7 @@ describe "page menu over HTTP" do body.should contain %(href="/pages/docs" title="Up: Docs">) body.should contain %(href="/pages/docs/guide/install" title="Next: Install">) - body = SpecClient.new.get("/pages/alpha").body + body = client.get("/pages/alpha").body body.should contain %(‹ Prev) body.should contain %(href="/pages/docs" title="Next: Docs">) @@ -654,7 +658,9 @@ describe "attachment list on a page" do Fluence::Media.new("parent/own.txt").update! SPEC_USER, "own" Fluence::Media.new("parent/child/pic.png").update! SPEC_USER, "png" - response = SpecClient.new.get "/pages/parent" + # The attachments list is part of the signed-in layout; guests get a + # stripped-down view without it. + response = SpecClient.login("editor", "sekrit123").get "/pages/parent" response.status_code.should eq 200 response.body.should contain %(href="/media/parent/own.txt") response.body.should_not contain "/media/parent/child/pic.png" @@ -721,7 +727,34 @@ describe "attachment list markup" do SpecClient.new.get("/pages/att-list").body.should_not contain %(name="delete") Fluence::Page.new("no-att").update! SPEC_USER, "# No Att\n" - SpecClient.new.get("/pages/no-att").body.should contain %(
None.
) + client.get("/pages/no-att").body.should contain %(
None.
) + end + end +end + +describe "guest page view" do + it "shows only content and navigation, hiding the menu, attachments, and page management" do + with_each_storage do |_, _| + Fluence::Page.new("guest-view").update! SPEC_USER, "# Guest View\nBody text.\n" + Fluence::Media.new("guest-view/a.txt").update! SPEC_USER, "A" + + body = SpecClient.new.get("/pages/guest-view").body + body.should contain "Body text." + body.should contain %(href="/pages/home">Home) + body.should contain %(href="/sitemap") + body.should contain %(href="/users/login") + body.should contain %(href="/users/register") + body.should_not contain "pages-hierarchy" + body.should_not contain "page-nav" + body.should_not contain "manage-attachments" + body.should_not contain "manage-page" + body.should_not contain "page-permissions" + + # Signed-in users still get the full view. + client = SpecClient.login "editor", "sekrit123" + full_body = client.get("/pages/guest-view").body + full_body.should contain "pages-hierarchy" + full_body.should contain "manage-attachments" end end end diff --git a/src/controllers/pages_controller.cr b/src/controllers/pages_controller.cr index 2be444c..de745ce 100644 --- a/src/controllers/pages_controller.cr +++ b/src/controllers/pages_controller.cr @@ -10,7 +10,7 @@ class PagesController < ApplicationController def show # Permissions are checked against the page's canonical URL rather than # the path as typed: /pages/Secret and /pages/secr%65t are "secret". - page = Fluence::Page.new params.url["path"] + page = Fluence::Page.new params.url["path"], page_lang acl_permit! :read, page.url flash["danger"] = params.query["flash.danger"] if params.query["flash.danger"]? page.process! if page.exists? @@ -95,7 +95,11 @@ class PagesController < ApplicationController tree = page_tree - render "show.slang" + if user_signed_in? + render "show.slang" + else + render "show_guest.slang" + end end # Whether the editor opens in edit mode (as opposed to preview) for @@ -116,7 +120,7 @@ class PagesController < ApplicationController # post /pages/*path def update - page = Fluence::Page.new params.url["path"] + page = Fluence::Page.new params.url["path"], page_lang acl_permit! :write, page.url page.process! if page.exists? if params.body["rename"]? @@ -181,6 +185,7 @@ class PagesController < ApplicationController pages.each do |page| begin + page.translations.each { |code| Fluence::Page.new(page.name, code).delete current_user } page.delete current_user if page.exists? flash["success success-#{page.name}"] = "Page '#{page.name}' has been deleted" rescue e @@ -282,6 +287,13 @@ class PagesController < ApplicationController render "titles.slang" end + # The translation requested via ?lang= (query, or body for POSTs), or nil + # for the default language; unconfigured codes fall back to nil too. + private def page_lang : String? + lang = params.query["lang"]? || params.body["lang"]? + lang if lang && Fluence::OPTIONS.languages.includes?(lang) + end + private def subtree_of(page) prefix = page.name + "/" Fluence::PAGES.names.select(&.starts_with?(prefix)).map { |name| Fluence::Page.new(name).process! } diff --git a/src/page.cr b/src/page.cr index 1ca28e7..7f6e612 100644 --- a/src/page.cr +++ b/src/page.cr @@ -15,10 +15,15 @@ class Fluence::Page < Fluence::File property toc : Page::TableOfContent::Toc property intlinks : Page::InternalLinks::LinkList - def initialize(name : String) + # Language of this instance, from `Fluence::OPTIONS.languages`; nil for + # the default-language content at "pages/.md". + getter lang : String? + + def initialize(name : String, @lang : String? = nil) name = Page.sanitize(name).strip "/" title = ::File.basename name - super("pages/#{name}.md", name, "#{Fluence::OPTIONS.pages_prefix}/#{name}", title) + suffix = @lang ? ".#{@lang}" : "" + super("pages/#{name}#{suffix}.md", name, "#{Fluence::OPTIONS.pages_prefix}/#{name}", title) @slug = Page.title_to_slug @title @intlinks = Page::InternalLinks::LinkList.new @@ -27,6 +32,12 @@ class Fluence::Page < Fluence::File jail! end + # Language codes (from `Fluence::OPTIONS.languages`) this page has a + # translation for. + def translations : Array(String) + Fluence::OPTIONS.languages.select { |code| Page.new(@name, code).exists? } + end + def storage_prefix : String "pages" end @@ -116,6 +127,11 @@ class Fluence::Page < Fluence::File new_page = Page.new(new_name) old_prefix = "media/#{@name}/" moves = attachment_paths.map { |path| {path, "media/#{new_page.name}/#{path.lchop old_prefix}"} } + # Renaming the default-language page takes its translations along; + # renaming a translation directly (@lang set) affects only itself. + unless @lang + moves += translations.map { |code| {Page.new(@name, code).path, Page.new(new_page.name, code).path} } + end rename_to new_page, user, overwrite, moves end diff --git a/src/views/pages/show.slang b/src/views/pages/show.slang index 15392c9..80fd2a3 100644 --- a/src/views/pages/show.slang +++ b/src/views/pages/show.slang @@ -12,6 +12,8 @@ button.btn.btn-sm.btn-outline-secondary.ms-2 type="button" onclick="Fluence.editor.discardDraft()" Discard draft form#edit-page.invisible method="post" fieldset + - if page.lang + input type="hidden" name="lang" value="#{page.lang}" textarea.form-control name="body" = body - else #page-body @@ -43,6 +45,18 @@ input type="hidden" name="media-name" value="#{filename}" button.btn.btn-sm.btn-outline-danger.py-0.ms-2 name="delete" value="Delete" type="submit" formaction="#{Fluence::OPTIONS.media_prefix}/#{page.name}/#{filename}" onclick="return Fluence.deleteAttachment(this, '#{filename}')" Delete #sidebar.col + - if !Fluence::OPTIONS.languages.empty? + section#page-languages + h6.section-title Language + ul.list-unstyled.mb-0 + - default_class = page.lang.nil? ? "fw-bold" : "" + li + a class=default_class href="#{page.url}" = Fluence::OPTIONS.default_lang + - Fluence::OPTIONS.languages.each do |code| + - has_translation = page.translations.includes? code + - current_class = page.lang == code ? "fw-bold" : "" + li + a class=current_class href="#{page.url}?lang=#{code}" = has_translation ? code : "#{code} (untranslated)" - if writable section#page-content h6.section-title Page Content @@ -83,6 +97,8 @@ a.section-toggle.collapsed data-bs-toggle="collapse" href="#page-management-body" role="button" aria-expanded="false" aria-controls="page-management-body" Page Management #page-management-body.collapse form#manage-page method="post" + - if page.lang + input type="hidden" name="lang" value="#{page.lang}" .input-group.input-group-sm span.input-group-text id="label-page-name" Page name input.form-control type="text" id="input-page-name" name="input-page-name" aria-describedby="label-page-name" value="#{page.name}" diff --git a/src/views/pages/show_guest.slang b/src/views/pages/show_guest.slang new file mode 100644 index 0000000..0138b37 --- /dev/null +++ b/src/views/pages/show_guest.slang @@ -0,0 +1,27 @@ +/ Minimal page view for guests (not signed in): page content and the +/ language switcher only. Navigation (home, sitemap, search, login/ +/ register) comes from the shared layout/_nav.slang, which already hides +/ what a guest can't use. +.container-fluid + .row + - if !Fluence::OPTIONS.languages.empty? + .col-md-3 + section#page-languages + h6.section-title Language + ul.list-unstyled.mb-0 + - default_class = page.lang.nil? ? "fw-bold" : "" + li + a class=default_class href="#{page.url}" = Fluence::OPTIONS.default_lang + - Fluence::OPTIONS.languages.each do |code| + - has_translation = page.translations.includes? code + - current_class = page.lang == code ? "fw-bold" : "" + li + a class=current_class href="#{page.url}?lang=#{code}" = has_translation ? code : "#{code} (untranslated)" + .col + - if page.exists? + #page-body + == body_html + - else + .small.text-body-secondary This page does not exist yet. +script + | hljs.highlightAll(); From cea3708de5a43b44fb64ac757d013732fed2f6fe Mon Sep 17 00:00:00 2001 From: Sergey Zhumatiy Date: Mon, 21 Sep 2026 14:51:45 -0700 Subject: [PATCH 3/5] add optional custom js --- config/options.cr | 5 +++-- src/theme.cr | 13 +++++++++---- src/views/layouts/application.slang | 4 ++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/config/options.cr b/config/options.cr index 3a01e73..824f712 100644 --- a/config/options.cr +++ b/config/options.cr @@ -33,8 +33,9 @@ module Fluence @publicdir = ::File.expand_path ENV.fetch("FLUENCE_PUBLICDIR") { default_publicdir } # Directory of theming overrides: header.html, footer.html, and - # nav_extra.html (raw HTML dropped into the layout as-is), and - # custom.css (appended after the built-in stylesheet). Any file + # nav_extra.html (raw HTML dropped into the layout as-is), custom.css + # (appended after the built-in stylesheet), and custom.js (appended + # after the built-in scripts, at the end of the body). Any file # that's missing is simply skipped, so a site can override as much # or as little as it wants without touching the templates. Defaults # to metadir/theme; override with FLUENCE_THEMEDIR. diff --git a/src/theme.cr b/src/theme.cr index 9e172a0..7ac6e52 100644 --- a/src/theme.cr +++ b/src/theme.cr @@ -1,8 +1,8 @@ -# Site-level theming slots: raw HTML/CSS overrides dropped in +# Site-level theming slots: raw HTML/CSS/JS overrides dropped in # Fluence::OPTIONS.themedir, read fresh on every render so a site admin -# can restyle header, footer, nav, and stylesheet without rebuilding or -# restarting the binary. Missing files render as empty content — the -# built-in layout is the default theme. +# can restyle header, footer, nav, stylesheet, and behavior without +# rebuilding or restarting the binary. Missing files render as empty +# content — the built-in layout is the default theme. module Fluence::Theme SLOTS = %w[header footer nav_extra] @@ -17,6 +17,11 @@ module Fluence::Theme read ::File.join(Fluence::OPTIONS.themedir, "custom.css") end + # Contents of the site's custom script, or "" if it has none. + def self.custom_js : String + read ::File.join(Fluence::OPTIONS.themedir, "custom.js") + end + private def self.read(path : String) : String ::File.exists?(path) ? ::File.read(path) : "" end diff --git a/src/views/layouts/application.slang b/src/views/layouts/application.slang index 9a7dbd6..6ec32ac 100644 --- a/src/views/layouts/application.slang +++ b/src/views/layouts/application.slang @@ -50,3 +50,7 @@ html lang="en" data-bs-theme=theme_attr - if !theme_footer.empty? #theme-footer == theme_footer + + - custom_js = Fluence::Theme.custom_js + - if !custom_js.empty? + script == custom_js From 6591552f7adfa7ad634bce7f864945ac63dd51ee Mon Sep 17 00:00:00 2001 From: Sergey Zhumatiy Date: Tue, 22 Sep 2026 08:38:51 -0700 Subject: [PATCH 4/5] whitelisted icons, headers, spans, divs in the parser --- .gitignore | 8 +++++++- config/options.cr | 23 ++++++++++++++-------- spec/markdown_spec.cr | 44 +++++++++++++++++++++++++++++++++++++++--- src/markdown.cr | 45 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 103 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 471f8e6..9fc59ee 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,11 @@ /bin/fluence /data/ -/meta/ + +# meta/ is runtime data (users, ACLs, sessions, the active theme...) and +# stays out of git, except the shipped example theme under meta/themes/. +/meta/* +!/meta/themes +/meta/themes/* +!/meta/themes/example diff --git a/config/options.cr b/config/options.cr index 824f712..196a4cf 100644 --- a/config/options.cr +++ b/config/options.cr @@ -32,14 +32,20 @@ module Fluence # with FLUENCE_PUBLICDIR. @publicdir = ::File.expand_path ENV.fetch("FLUENCE_PUBLICDIR") { default_publicdir } - # Directory of theming overrides: header.html, footer.html, and - # nav_extra.html (raw HTML dropped into the layout as-is), custom.css - # (appended after the built-in stylesheet), and custom.js (appended - # after the built-in scripts, at the end of the body). Any file - # that's missing is simply skipped, so a site can override as much - # or as little as it wants without touching the templates. Defaults - # to metadir/theme; override with FLUENCE_THEMEDIR. - @themedir = ::File.expand_path ENV.fetch("FLUENCE_THEMEDIR") { ::File.join(@metadir, "theme") } + # Active theme's name; its override files are read from + # metadir/themes// (see @themedir below). Defaults to + # "default"; set with FLUENCE_THEME. The "example" theme ships in + # metadir/themes/example/ as a documented starting point. + @theme = ENV.fetch("FLUENCE_THEME", "default") + + # Directory of theming overrides for the active theme: header.html, + # footer.html, and nav_extra.html (raw HTML dropped into the layout + # as-is), custom.css (appended after the built-in stylesheet), and + # custom.js (appended after the built-in scripts, at the end of the + # body). Any file that's missing is simply skipped, so a theme can + # override as much or as little as it wants without touching the + # templates. + @themedir = ::File.join(@metadir, "themes", @theme) # Extra language codes pages can be translated into, e.g. "de,fr". # A page's default-language content lives at "pages/.md" as @@ -114,6 +120,7 @@ module Fluence getter datadir : String getter metadir : String getter publicdir : String + getter theme : String getter themedir : String getter languages : Array(String) getter default_lang : String diff --git a/spec/markdown_spec.cr b/spec/markdown_spec.cr index de6d099..039cf8a 100644 --- a/spec/markdown_spec.cr +++ b/spec/markdown_spec.cr @@ -28,10 +28,11 @@ describe Fluence::Markdown do Fluence::Markdown.to_html("```\n# not a heading\n```\n").should_not contain "\n\ntext bold\n") - html.should_not contain "onerror" - html.should_not contain "onclick" + html.should_not contain "/ through for icons and custom styling" do + html = Fluence::Markdown.to_html(%(An icon and text.)) + html.should contain %() + html.should contain %(text) + end + + it "lets headings, lists, and layout containers through as raw HTML" do + html = Fluence::Markdown.to_html(<<-HTML +
+
+

Heading

+
  • one
  • two
+
  1. first
+
+
+ HTML + ) + html.should contain %(
) + html.should contain "
" + html.should contain %(

Heading

) + html.should contain "
  • one
  • two
" + html.should contain "
  1. first
" + html.should contain "
" + end + + it "still escapes an unsafe tag disguised with a class attribute" do + html = Fluence::Markdown.to_html(%()) + html.should_not contain "text
)) + html.should_not contain %(`), lay + # out custom structure (headings, lists, article/div sections), and + # apply theme CSS classes to any of it, without opening the door to + # `