Fosi Fossil Service
Small teams. Open source. Built on Fossil.

CaaS / Lamina

Unified Management for Fossil Repositories

Public | Owner: CaaS | Default branch: trunk | Last update: 2026-10-25 00:41

Artifact ceb6d2ffa3e37aac44654befed99762309916bf35feec7e37a3b1c5d9a71b183:


;; lamina 默认 Fossil 皮肤,版式按 docs/仓库皮肤.png。
;; Fossil 的 header/footer 会经过 TH1 处理,因此这里使用 Fossil 提供的
;; $home 等变量;平台站点信息在生成配置时写入并转义。
(defmodule lamina_fossil_skin
  (export all))

(defun render (org-name slug)
  (render org-name slug ""))

(defun render (org-name slug description)
  (let* ((dir (filename:join "data" "skin-cache"))
         (path (filename:join dir
                              (lists:flatten
                               (io_lib:format "skin-~p.conf"
                                             (list (erlang:unique_integer)))))))
    (filelib:ensure_dir path)
    (let ((header (header org-name slug description))
          (footer (footer org-name slug))
          (css (css)))
      (let ((content (lists:flatten
                      (list
                       "# lamina generated skin\n"
                       (card "css" css)
                       (card "header" header)
                       (card "footer" footer)))))
        (case (file:write_file path content)
          ('ok `#(ok ,path))
          (`#(error ,reason) `#(error ,(tuple 'skin-write-failed reason))))))))

(defun header (org-name slug)
  (header org-name slug ""))

(defun header (org-name slug description)
  (let* ((site (lamina_site_mgr:all))
         (site-name (escape-html (maps:get 'site_name site "lamina")))
         (tagline (escape-html (maps:get 'site_tagline site
                                         "Small teams. Open source. Built on Fossil.")))
         (root (escape-attr (trim-right-slash
                             (maps:get 'site_url site "http://localhost:8827"))))
         (owner (escape-html (if (=:= "" (to-str org-name))
                                 (maps:get 'site_name site "lamina")
                                 org-name)))
         (repo-title (escape-html
                      (if (=:= "" (to-str org-name))
                          (to-str slug)
                          (lists:flatten (list (to-str org-name) " / " (to-str slug))))))
         (desc (to-str description)))
    (lists:flatten
     (list
      "<div class=\"lamina-shell\"><header class=\"lamina-nav\">"
      "<a class=\"lamina-brand\" href=\"" root "\">" site-name "</a>"
      "<nav><a href=\"" root "\">Explore</a>"
      "<a href=\"" root "/docs\">Docs</a>"
      (custom-nav-html)
      "</nav>"
      "<th1>if {[info exists login]} {html \"<a class=\\\"lamina-signin\\\" href=\\\"$home/logout\\\">Logout</a>\"} else {html \"<a class=\\\"lamina-signin\\\" href=\\\"$home/login\\\">Sign in</a>\"}</th1>"
      "</header>"
      "<div class=\"lamina-tagline\">" tagline "</div>"
      "<h1 class=\"lamina-title\">" repo-title "</h1>"
      (if (=:= "" desc)
          ""
          (list "<p class=\"lamina-desc\">" (escape-skin desc) "</p>"))
      "<p class=\"lamina-meta\">Public | Owner: " owner
      " | Default branch: trunk"
      "<th1>query {SELECT strftime('%Y-%m-%d %H:%M',mtime,'localtime') AS lu FROM event ORDER BY mtime DESC LIMIT 1} {html \" | Last update: $lu\"}</th1>"
      "</p>"
      "<nav class=\"lamina-tabs\" title=\"Main Menu\">"
      "<a class=\"tab-files\" href=\"$home/dir?ci=tip\">Files</a>"
      "<a class=\"tab-timeline\" href=\"$home/timeline\">Timeline</a>"
      "<a class=\"tab-branches\" href=\"$home/brlist\">Branches</a>"
      "<a class=\"tab-tags\" href=\"$home/taglist\">Tags</a>"
      "<a class=\"tab-tickets\" href=\"$home/ticket\">Tickets</a>"
      "<a class=\"tab-wiki\" href=\"$home/wiki\">Wiki</a>"
      "<a class=\"tab-forum\" href=\"$home/forum\">Forum</a>"
      "<th1>if {[info exists login]} {html \"<a class=\\\"tab-admin\\\" href=\\\"$home/setup\\\">Admin</a>\"}</th1>"
      "<input class=\"lamina-search\" type=\"search\" placeholder=\"Search files...\" aria-label=\"Search files\">"
      "</nav></div>"))))

(defun custom-nav-html ()
  (lists:map
   (lambda (item)
     (let ((label (escape-html (maps:get 'label item "")))
           (url (escape-attr (maps:get 'url item ""))))
       (list "<a href=\"" url "\">" label "</a>")))
   (lamina_site_mgr:nav-items)))

(defun footer (org-name slug)
  (let* ((site (lamina_site_mgr:all))
         (root (escape-attr (trim-right-slash (maps:get 'site_url site
                                                        "http://localhost:8827"))))
         (terms (maps:get 'footer_terms_url site ""))
         (privacy (maps:get 'footer_privacy_url site ""))
         (contact (maps:get 'footer_contact_url site ""))
         (text (escape-html (lamina_ui:footer-text site)))
         (path (if (=:= "" (to-str org-name))
                   (lists:flatten (list "/" (to-str slug)))
                   (lists:flatten (list "/" (to-str org-name) "/" (to-str slug)))))
         (clone (lists:flatten (list "fossil clone " (trim-right-slash
                                                     (maps:get 'site_url site
                                                               "http://localhost:8827"))
                                     path))))
    (lists:flatten
     (list
      "<div class=\"lamina-shell\">"
      (files-panel)
      "<div class=\"lamina-home-only\">"
      "<section class=\"lamina-section\">"
      "<h2>Clone</h2><p>You can clone this repository with the following command:</p>"
      "<pre class=\"lamina-clone\">" (escape-html clone) "</pre></section>"
      "<section class=\"lamina-section\"><h2>Contribute</h2>"
      "<p>The best way to contribute is through e-mail"
      (if (=:= "" (to-str contact))
          ". See the "
          (list ", send your patches to <a href=\"" (escape-attr contact) "\">"
                (escape-html contact) "</a> and change the subject prefix to specify this repository. See the "))
      "<a href=\"https://fossil-scm.org/home/doc/trunk/www/index.wiki\">project documentation</a> for more details.</p>"
      "</section></div>"
      "<footer class=\"lamina-footer\"><span>" text "</span><nav>"
      (footer-link terms "Terms")
      (footer-link privacy "Privacy")
      (footer-link contact "Contact")
      "</nav></footer></div>"))))

;; Files:左侧仓库树 + 右侧当前目录图标。
;; TH1 的 query 不能读 files_of_checkin(表被限制);用 dir 列出,再用 string/expr 拆目录名。
(defun files-panel ()
  (lists:flatten
   (list
    "<div class=\"lamina-home-only lamina-files\">"
    "<th1>"
    "if {[info exists current_page]} {\n"
    "if {[string compare $current_page dir]==0} {\n"
    "if {[catch {\n"
    "set folders {}\n"
    "set files {}\n"
    "set children_dirs {}\n"
    "set children_files {}\n"
    "set folder_mark |\n"
    "set child_dir_mark |\n"
    "set cid tip\n"
    "set path {}\n"
    "catch {set cid [getParameter ci tip]}\n"
    "if {[string length $cid] == 0} {set cid tip}\n"
    "catch {set path [getParameter name {}]}\n"
    "set prefix {}\n"
    "if {[string length $path] > 0} {set prefix $path/}\n"
    "set prelen [string length $prefix]\n"
    "if {[catch {set listing [dir $cid * 0]}]} {set listing [dir tip * 0]}\n"
    "foreach fname $listing {\n"
    "  set i [string first / $fname]\n"
    "  if {$i >= 0} {\n"
    "    set top [string range $fname 0 [expr {$i - 1}]]\n"
    "    if {[string first |$top| $folder_mark] < 0} {\n"
    "      set folder_mark $folder_mark$top|\n"
    "      lappend folders $top\n"
    "    }\n"
    "  } else {\n"
    "    lappend files $fname\n"
    "  }\n"
    "  if {$prelen == 0} {\n"
    "    if {$i >= 0} {\n"
    "      set top [string range $fname 0 [expr {$i - 1}]]\n"
    "      if {[string first |$top| $child_dir_mark] < 0} {\n"
    "        set child_dir_mark $child_dir_mark$top|\n"
    "        lappend children_dirs $top\n"
    "      }\n"
    "    } else {\n"
    "      lappend children_files $fname\n"
    "    }\n"
    "  } else {\n"
    "    if {[string first $prefix $fname] == 0} {\n"
    "      set rest [string range $fname $prelen end]\n"
    "      set j [string first / $rest]\n"
    "      if {$j >= 0} {\n"
    "        set top [string range $rest 0 [expr {$j - 1}]]\n"
    "        if {[string first |$top| $child_dir_mark] < 0} {\n"
    "          set child_dir_mark $child_dir_mark$top|\n"
    "          lappend children_dirs $top\n"
    "        }\n"
    "      } elseif {[string length $rest] > 0} {\n"
    "        lappend children_files $rest\n"
    "      }\n"
    "    }\n"
    "  }\n"
    "}\n"
    "html {<div class=\"fb\"><aside class=\"fb-tree\"><div class=\"fb-kicker\">REPOSITORY</div>}\n"
    "html {<a class=\"fb-tree-item\" href=\"}\n"
    "html $home\n"
    "html {/dir?ci=}\n"
    "html [httpize $cid]\n"
    "html {\"><span class=\"fb-mini fb-mini-folder\"></span>/</a>}\n"
    "foreach top $folders {\n"
    "  html {<a class=\"fb-tree-item\" data-name=\"}\n"
    "  html [htmlize $top]\n"
    "  html {\" href=\"}\n"
    "  html $home\n"
    "  html {/dir?ci=}\n"
    "  html [httpize $cid]\n"
    "  html {&name=}\n"
    "  html [httpize $top]\n"
    "  html {\"><span class=\"fb-mini fb-mini-folder\"></span>}\n"
    "  html [htmlize $top]\n"
    "  html {</a>}\n"
    "}\n"
    "foreach f $files {\n"
    "  html {<a class=\"fb-tree-item\" data-name=\"}\n"
    "  html [htmlize $f]\n"
    "  html {\" href=\"}\n"
    "  html $home\n"
    "  html {/artifact?filename=}\n"
    "  html [httpize $f]\n"
    "  html {&ci=}\n"
    "  html [httpize $cid]\n"
    "  html {\"><span class=\"fb-mini fb-mini-file\"></span>}\n"
    "  html [htmlize $f]\n"
    "  html {</a>}\n"
    "}\n"
    "html {</aside><div class=\"fb-main\"><h2>Files in }\n"
    "if {[string length $path] == 0} {\n"
    "  html {the top-level directory}\n"
    "} else {\n"
    "  html [htmlize $path]\n"
    "}\n"
    "html { from the <a href=\"}\n"
    "html $home\n"
    "html {/info/}\n"
    "html [httpize $cid]\n"
    "html {\">latest check-in</a></h2><div class=\"fb-grid\">}\n"
    "foreach top $children_dirs {\n"
    "  set href $top\n"
    "  if {[string length $path] > 0} {set href $path/$top}\n"
    "  html {<a class=\"fb-card\" data-name=\"}\n"
    "  html [htmlize $top]\n"
    "  html {\" href=\"}\n"
    "  html $home\n"
    "  html {/dir?ci=}\n"
    "  html [httpize $cid]\n"
    "  html {&name=}\n"
    "  html [httpize $href]\n"
    "  html {\"><span class=\"fb-icon fb-icon-folder\"></span><span>}\n"
    "  html [htmlize $top]\n"
    "  html {</span></a>}\n"
    "}\n"
    "foreach f $children_files {\n"
    "  set href $f\n"
    "  if {[string length $path] > 0} {set href $path/$f}\n"
    "  html {<a class=\"fb-card\" data-name=\"}\n"
    "  html [htmlize $f]\n"
    "  html {\" href=\"}\n"
    "  html $home\n"
    "  html {/artifact?filename=}\n"
    "  html [httpize $href]\n"
    "  html {&ci=}\n"
    "  html [httpize $cid]\n"
    "  html {\"><span class=\"fb-icon fb-icon-file\"></span><span>}\n"
    "  html [htmlize $f]\n"
    "  html {</span></a>}\n"
    "}\n"
    "html {</div>}\n"
    "if {[llength $children_dirs] == 0 && [llength $children_files] == 0} {\n"
    "  html {<p class=\"fb-empty\">No files in this directory.</p>}\n"
    "}\n"
    "set rfile {}\n"
    "foreach cand {README.md readme.md README.markdown README} {\n"
    "  foreach f $children_files {\n"
    "    if {[string compare $f $cand] == 0} {set rfile $cand}\n"
    "  }\n"
    "}\n"
    "if {[string length $rfile] > 0} {\n"
    "  set rpath $rfile\n"
    "  if {[string length $path] > 0} {set rpath $path/$rfile}\n"
    "  catch {\n"
    "    set raw [artifact $cid $rpath]\n"
    "    set md [markdown $raw]\n"
    "    html {<article class=\"fb-readme\">}\n"
    "    set title [lindex $md 0]\n"
    "    if {[string length $title] > 0} {\n"
    "      html {<h1>}\n"
    "      html [htmlize $title]\n"
    "      html {</h1>}\n"
    "    }\n"
    "    html [lindex $md 1]\n"
    "    html {</article>}\n"
    "  }\n"
    "}\n"
    "html {<div class=\"fb-note\"><span>This repository is using <b>Fossil</b> — a simple, efficient, self-contained SCM.</span><a href=\"https://fossil-scm.org/home/doc/trunk/www/index.wiki\">Learn more about Fossil →</a></div></div></div>}\n"
    "}]} {}\n"
    "}\n"
    "}\n"
    "</th1>"
    (md-script)
    "</div>")))

;; Fossil CSP 只允许带 $nonce 的内联脚本。JS 里不能出现 $、[、#,否则页脚 TH1 会改写或当注释。
(defun md-script ()
  (lists:flatten
   (list
    "<script nonce=\"$nonce\">(function(){"
    "function go(){"
    "var q=document.querySelector('.lamina-search');"
    "if(q){q.addEventListener('input',function(){var s=q.value.toLowerCase();document.querySelectorAll('.fb-card,.fb-tree-item').forEach(function(el){var n=(el.getAttribute('data-name')||el.textContent||'').toLowerCase();el.style.display=(!s||n.indexOf(s)>=0)?'':'none'})})}"
    "function esc(t){return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}"
    "function isKeyChar(c){var n=c.charCodeAt(0);return (n>=48&&n<=57)||(n>=65&&n<=90)||(n>=97&&n<=122)||n===95||n===46||n===45}"
    "function hi(src){var nl=String.fromCharCode(10);var hash=String.fromCharCode(35);return src.split(nl).map(function(line){"
    "var t=esc(line);var i=0;while(i<t.length&&(t.charAt(i)===' '||t.charAt(i)==='\\t'))i++;"
    "if(t.charAt(i)===hash)return '<span class=\"tok-cmt\">'+t+'</span>';"
    "var j=i;while(j<t.length&&isKeyChar(t.charAt(j)))j++;"
    "if(j>i){var head=t.slice(0,i)+'<span class=\"tok-key\">'+t.slice(i,j)+'</span>';"
    "if(t.charAt(j)===':'){var k=j+1;while(k<t.length&&(t.charAt(k)===' '||t.charAt(k)==='\\t'))k++;"
    "head+=t.charAt(j);if(k>=t.length)return head;return head+t.slice(j+1,k)+'<span class=\"tok-str\">'+t.slice(k)+'</span>'}"
    "if(j<t.length)return head+'<span class=\"tok-str\">'+t.slice(j)+'</span>';return head}"
    "var out='',p=0;while(p<t.length){var c=t.charAt(p);var qc=c.charCodeAt(0);if(qc===34||qc===39){var r=p+1;while(r<t.length&&t.charAt(r)!==c)r++;if(r<t.length)r++;out+='<span class=\"tok-str\">'+t.slice(p,r)+'</span>';p=r;}else{out+=c;p++;}}return out"
    "}).join(nl)}"
    "document.querySelectorAll('.fb-readme pre,blockquote.file-content pre').forEach(function(pre){"
    "var code=pre.querySelector('code')||pre;"
    "if(code.getAttribute('data-st-hi'))return;"
    "code.setAttribute('data-st-hi','1');"
    "code.innerHTML=hi(code.textContent);"
    "if(!pre.querySelector('.fb-copy')){var b=document.createElement('button');b.type='button';b.className='fb-copy';b.title='Copy';"
    "b.innerHTML='<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\"><rect x=\"5.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1.5\" stroke=\"currentColor\"/><path d=\"M3.5 10.5h-1A1.5 1.5 0 0 1 1 9V3.5A1.5 1.5 0 0 1 2.5 2H8a1.5 1.5 0 0 1 1.5 1.5v1\" stroke=\"currentColor\"/></svg>';"
    "b.addEventListener('click',function(){navigator.clipboard.writeText(code.textContent)});pre.appendChild(b)}"
    "});"
    "}"
    "if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',go);else go();"
    "})();</script>")))

(defun footer-link (url label)
  (if (=:= "" (to-str url))
      ""
      (list "<a href=\"" (escape-attr url) "\">" label "</a>")))

(defun css ()
  (lists:flatten
   (list
    "body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif;background:#fff;color:#1f2328;margin:0;z-index:0}"
    ".lamina-shell{max-width:1120px;margin:0 auto;padding:0 32px}"
    ".lamina-nav{display:flex;align-items:center;gap:16px;height:32px;border-bottom:1px solid #d0d7de}"
    ".lamina-brand{font-size:16px;font-weight:700;color:#1f2328;text-decoration:none}"
    ".lamina-nav nav{display:flex;gap:16px}.lamina-nav a{color:#1f2328;text-decoration:none;font-size:13px}"
    ".lamina-nav a:hover{color:#656d76;text-decoration:none}.lamina-signin{margin-left:auto}"
    ".lamina-tagline{text-align:right;color:#656d76;font-size:13px;padding:14px 0 8px}"
    ".lamina-title{font-size:32px;font-weight:600;letter-spacing:-.03em;margin:12px 0 6px}"
    ".lamina-desc{margin:0 0 10px;color:#1f2328}.lamina-meta{color:#656d76;font-size:13px;margin:0 0 8px}"
    ".lamina-tabs{display:flex;flex-wrap:wrap;gap:22px;align-items:center;border-bottom:1px solid #d0d7de}"
    ".lamina-tabs a{color:#656d76;text-decoration:none;padding:10px 0 8px;border-bottom:2px solid transparent;font-size:14px;margin-bottom:-1px}"
    ".lamina-tabs a:hover{color:#1f2328;text-decoration:none}"
    ".lamina-search{display:none;margin-left:auto;margin-bottom:6px;width:220px;padding:7px 12px;border:1px solid #d0d7de;border-radius:999px;font:inherit;font-size:13px;background:#fff}"
    "body.dir .lamina-search,body.rpage-dir .lamina-search{display:block}"
    "body.dir .tab-files,body.rpage-dir .tab-files,body.timeline .tab-timeline,body.rpage-timeline .tab-timeline,body.brlist .tab-branches,body.rpage-brlist .tab-branches,body.taglist .tab-tags,body.rpage-taglist .tab-tags,body.ticket .tab-tickets,body.reportlist .tab-tickets,body.wiki .tab-wiki,body.rpage-wiki .tab-wiki,body.forum .tab-forum,body.forumpost .tab-forum,body.forumnew .tab-forum,body.forumedit .tab-forum,body.rpage-forum .tab-forum,body.cpage-forum .tab-forum,body.rpage-forumpost .tab-forum,body.cpage-forumpost .tab-forum,body.rpage-forumnew .tab-forum,body.cpage-forumnew .tab-forum,body.rpage-forumedit .tab-forum,body.cpage-forumedit .tab-forum,body.setup .tab-admin,body.rpage-setup .tab-admin{color:#1f2328;font-weight:600;border-bottom-color:#1f2328}"
    "div.mainmenu,div#mainmenu,nav.mainmenu{display:none}"
    "form#f01,div.submenu{max-width:1120px;margin:8px auto 0;padding:0 32px;box-sizing:border-box}"
    "div.submenu{display:flex;flex-wrap:wrap;gap:10px;align-items:center}"
    "div.content,div#content,.content{max-width:1120px;margin:0 auto;padding:18px 32px 8px}"
    "table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px 12px 10px 0;border-bottom:1px solid #eaeef2;font-size:14px}"
    "a{color:#1f2328;text-decoration:none}a:hover{color:#656d76;text-decoration:none}h2{font-size:20px;border-bottom:1px solid #d0d7de;padding-bottom:8px}"
    ".lamina-home-only{display:none}body.dir .lamina-home-only,body.rpage-dir .lamina-home-only{display:block}"
    "body.dir form#f01,body.dir div.submenu,body.dir div.content,body.rpage-dir form#f01,body.rpage-dir div.submenu,body.rpage-dir div.content{display:none}"
    ".lamina-files{margin:22px 0 8px}"
    ".fb{display:grid;grid-template-columns:240px minmax(0,1fr);gap:36px;align-items:start}"
    ".fb-tree{background:#f6f8fa;border-radius:12px;padding:16px 12px}"
    ".fb-kicker{font-size:11px;letter-spacing:.08em;color:#656d76;font-weight:700;padding:0 8px 10px}"
    ".fb-tree-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;font-size:14px;color:#1f2328}"
    ".fb-tree-item:hover{background:#fff;color:#1f2328}"
    ".fb-mini{width:16px;height:16px;flex:none;background:no-repeat center/contain}"
    ".fb-mini-folder{background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%23e8b931' d='M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5Z'/></svg>\")}"
    ".fb-mini-file{background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%23d0d7de' d='M7 3h8l5 5v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z'/><path fill='%23fff' d='M15 3v5h5'/></svg>\")}"
    ".fb-main h2{border:0;padding:2px 0 0;margin:0;font-size:18px;font-weight:650}"
    ".fb-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(196px,1fr));gap:2px 10px;margin-top:14px}"
    ".fb-card{display:flex;flex-direction:row;align-items:center;gap:8px;min-width:0;padding:7px 8px;border-radius:8px;font-size:13px;color:#1f2328;line-height:1.3}"
    ".fb-card:hover{background:#f6f8fa;color:#1f2328}"
    ".fb-card span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}"
    ".fb-icon{width:18px;height:16px;flex:none;display:block;background:no-repeat center/contain}"
    ".fb-icon-folder{background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 40'><path fill='%23e8b931' d='M4 10.5A4.5 4.5 0 0 1 8.5 6H18l4 4h17.5A4.5 4.5 0 0 1 44 14.5V31a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V10.5Z'/><path fill='%23f5d36c' d='M4 16h40v15a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V16Z'/></svg>\")}"
    ".fb-icon-file{height:18px;background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 40'><path fill='%23e8eef3' stroke='%23c8d1da' d='M6 2.5h13l8.5 8.5V36a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4.5A2 2 0 0 1 6 2.5z'/><path fill='%23fff' stroke='%23c8d1da' d='M19 2.5V12h8.5'/></svg>\")}"
    ".fb-empty{color:#656d76;margin:16px 0 0}"
    ".fb-readme{margin-top:28px;padding-top:20px;border-top:1px solid #d0d7de;max-width:780px;color:#1f2328;font-size:15px;line-height:1.65}"
    ".fb-readme h1{font-size:22px;font-weight:650;margin:0 0 14px}"
    ".fb-readme h2,.fb-readme h3,.fb-readme .markdown h2,.fb-readme .markdown h3{border:0;padding:0;margin:22px 0 8px;font-size:17px;font-weight:650}"
    ".fb-readme p,.fb-readme .markdown p{margin:0 0 12px;line-height:1.65}"
    "div.content .fb-readme .markdown pre,div.content .fb-readme pre,.fb-readme .markdown pre,.fb-readme pre,body.artifact blockquote.file-content pre,blockquote.file-content pre{position:relative;display:block;background:#f4f6fa;border:0;border-radius:12px;padding:16px 44px 16px 18px;overflow:auto;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;margin:12px 0 18px;color:#1f2328}"
    "blockquote.file-content{margin:0;padding:0;border:0}"
    ".fb-readme .markdown :not(pre)>code,.fb-readme :not(pre)>code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;background:#eef0f3;border:0;border-radius:6px;padding:2px 8px;font-weight:600;white-space:nowrap}"
    ".fb-readme .markdown pre code,.fb-readme pre code{background:transparent;border:0;padding:0;font:inherit;font-weight:400;color:#1f2328;white-space:pre}"
    ".tok-key{color:#1f2328;font-weight:500}.tok-str{color:#0969da}.tok-cmt{color:#8b949e}.tok-num{color:#0969da}"
    ".fb-copy{position:absolute;top:10px;right:10px;width:28px;height:28px;border:0;border-radius:6px;background:transparent;color:#656d76;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0}"
    ".fb-copy:hover{background:#e7ebf0;color:#1f2328}"
    ".fb-readme .markdown blockquote,.fb-readme blockquote{margin:12px 0;padding:2px 0 2px 14px;border-left:3px solid #d0d7de;color:#656d76}"
    ".fb-readme ul,.fb-readme ol,.fb-readme .markdown ul,.fb-readme .markdown ol{margin:0 0 12px;padding-left:22px}"
    ".fb-readme a{text-decoration:underline}"
    ".fb-note{display:flex;justify-content:space-between;gap:16px;align-items:center;flex-wrap:wrap;margin-top:28px;background:#eef2ff;color:#1f2328;border-radius:999px;padding:12px 20px;font-size:13px}"
    ".fb-note a{color:#1f2328;font-weight:600;white-space:nowrap}"
    "@media(max-width:800px){.fb{grid-template-columns:1fr}.fb-tree{display:none}.lamina-search{width:100%;order:20}}"
    "p:has(span.disabled){display:none}"
    "#debugMsg{display:none}"
    "form#x6{margin:12px 0}form#x6 input[type=submit]{appearance:none;background:#1f2328;color:#fff;border:0;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;cursor:pointer}"
    "body.file div.content,body.rpage-file div.content,body.cpage-file div.content{display:block}"
    ".lamina-section{margin:28px 0}.lamina-clone{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:12px 16px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px}"
    "body.forum div.content>h1,body.forumpost div.content>h1,body.forumnew div.content>h1,body.forumedit div.content>h1{font-size:22px;font-weight:650;letter-spacing:-.02em;margin:4px 0 16px;border:0}"
    "body.forum div.submenu a,body.forumpost div.submenu a,body.forumnew div.submenu a,body.forumedit div.submenu a{font-size:13px;color:#656d76;padding:4px 0}"
    "body.forum div.submenu a:hover,body.forumpost div.submenu a:hover{color:#1f2328}"
    "body.forum div.forumPosts.fileage table{width:100%;border:0;border-collapse:collapse}"
    "body.forum div.forumPosts.fileage tr{border-bottom:1px solid #eaeef2}"
    "body.forum div.forumPosts.fileage td{border:0;padding:14px 16px 14px 0;vertical-align:baseline;font-size:14px;background:transparent}"
    "body.forum div.forumPosts.fileage td:first-child{color:#656d76;white-space:nowrap;width:8rem;font-size:13px}"
    "body.forum div.forumPosts.fileage td:nth-child(2){font-size:15px;font-weight:600}"
    "body.forum div.forumPosts.fileage td:nth-child(2) a:hover{text-decoration:underline;color:#1f2328}"
    "body.forum div.forumPosts.fileage td:nth-child(3){color:#656d76;text-align:right;white-space:nowrap;font-size:13px;font-weight:400;width:16rem}"
    "body.forum div.forumPosts.fileage tr:hover td{background:#f6f8fa}"
    "span.modpending{display:inline-block;margin-top:4px;color:#9a6700;background:#fff8c5;border-radius:999px;padding:1px 8px;font-size:12px;font-weight:600}"
    "div.forumTime{border:1px solid #d0d7de;border-radius:12px;padding:16px 18px 12px;margin:0 0 14px;background:#fff;box-sizing:border-box}"
    "div.forumTime.forumSel{background:#f6f8fa;border-color:#afb8c1}"
    "div.forumTime.forumObs{opacity:.62}"
    "div.forumTime.forumClosed>.forumPostBody{opacity:.72}"
    "div.forumTime>h1{font-size:22px;font-weight:650;letter-spacing:-.02em;margin:0 0 8px}"
    "h3.forumPostHdr{font-size:13px;font-weight:500;color:#656d76;margin:0 0 12px;padding-bottom:10px;border-bottom:1px solid #eaeef2}"
    "span.forumPostReplyTitle{font-weight:600;color:#1f2328}"
    "div.forumPostBody{max-height:none;overflow:visible;font-size:15px;line-height:1.65;color:#1f2328}"
    "div.forumPostBody p{margin:0 0 12px}"
    "div.forumPostBody pre{background:#f4f6fa;border:0;border-radius:10px;padding:12px 14px;overflow:auto;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}"
    "div.forumPostBody pre.textPlain{background:transparent;border-radius:0;padding:0;margin:0;overflow:visible;font:15px/1.65 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif;white-space:pre-wrap;color:#1f2328}"
    "div.forumPostBody :not(pre)>code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;background:#eef0f3;border-radius:6px;padding:1px 6px}"
    "div.forumpost-single-controls{margin-top:4px}"
    "div.forumpost-single-controls form{display:flex;flex-wrap:wrap;gap:8px;align-items:center}"
    "div.forumpost-single-controls input[type=submit],body.forum input[type=submit],body.forumnew input[type=submit],body.forumedit input[type=submit],body.forume2 input[type=submit]{appearance:none;background:#fff;color:#1f2328;border:1px solid #d0d7de;border-radius:8px;padding:7px 14px;font:inherit;font-size:13px;cursor:pointer}"
    "div.forumpost-single-controls input[name=reply],body.forum input[name=submit],body.forumnew input[name=submit],body.forumedit input[name=submit]{background:#1f2328;color:#fff;border-color:#1f2328}"
    "body.forum input[name=title],body.forumnew input[name=title],body.forumedit input[name=title]{display:block;width:min(720px,100%);box-sizing:border-box;border:1px solid #d0d7de;border-radius:8px;padding:8px 12px;font:inherit;font-size:15px;margin:8px 0 12px}"
    "textarea.wikiedit{display:block;width:min(760px,100%);box-sizing:border-box;min-height:240px;border:1px solid #d0d7de;border-radius:12px;padding:14px 16px;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;margin:8px 0 12px}"
    "body.forum select{border:1px solid #d0d7de;border-radius:8px;padding:6px 10px;font:inherit;font-size:13px;background:#fff;margin:6px 8px 6px 0}"
    "span.submenuctrl input[type=text]{border:1px solid #d0d7de;border-radius:8px;padding:4px 8px;font:inherit;font-size:13px;width:4.5rem;background:#fff}"
    "body.cpage-forumnew div.content>table,body.rpage-forumnew div.content>table{width:auto;margin-top:8px}"
    "body.cpage-forumnew div.content>table td,body.rpage-forumnew div.content>table td{border:0;padding:8px 18px 8px 0;vertical-align:middle;background:transparent}"
    "input[type=submit][disabled]{opacity:.4;cursor:default}"
    ".lamina-footer{display:flex;justify-content:space-between;gap:16px;border-top:1px solid #d0d7de;padding:16px 0 24px;color:#656d76;font-size:13px}"
    ".lamina-footer nav{display:flex;gap:18px}.lamina-footer a{color:#656d76;text-decoration:none}.lamina-footer a:hover{color:#1f2328}"
    "div.header,div.title,#title{display:none}"
    "@media(max-width:720px){.lamina-shell,.lamina-tabs,div.content{padding-left:16px;padding-right:16px}.lamina-nav{flex-wrap:wrap;height:auto;padding:12px 0}.lamina-footer{display:block}body.forum div.forumPosts.fileage td:first-child,body.forum div.forumPosts.fileage td:nth-child(3){white-space:normal;text-align:left;width:auto}div.forumTime{margin-left:0 !important}}")))

(defun card (name value)
  (let ((line (lists:flatten
               (io_lib:format "~p '~s' value '~s'"
                             (list (erlang:system_time 'second)
                                   name
                                   (fossil-quote value))))))
    (lists:flatten
     (list "config /config "
           (integer_to_list (byte_size (list_to_binary line)))
           "\n" line "\n"))))

(defun fossil-quote (value)
  (string:replace (to-str value) "'" "''" 'all))

(defun escape-html (value)
  (let ((s (to-str value)))
    (string:replace
     (string:replace
      (string:replace
       (string:replace s "&" "&amp;" 'all)
       "<" "&lt;" 'all)
      ">" "&gt;" 'all)
     "\"" "&quot;" 'all)))

;; Header 会走 TH1,$ 会被当成变量,描述里的 $ 需要写成实体。
(defun escape-skin (value)
  (string:replace (escape-html value) "$" "&#36;" 'all))

(defun escape-attr (value)
  (escape-html value))

(defun trim-right-slash (value)
  (let ((s (to-str value)))
    (if (and (> (length s) 0) (=:= (lists:last s) 47))
        (trim-right-slash (lists:sublist s 1 (- (length s) 1)))
        s)))

(defun to-str (value)
  (cond
    ((is_binary value) (binary_to_list value))
    ((is_list value) value)
    ((or (=:= 'undefined value) (=:= 'null value)) "")
    ('true (lists:flatten (io_lib:format "~p" (list value))))))

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/CaaS/Lamina

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.