;; 公开文档页。/docs 打开第一篇文章,其余路径按仓库里的文件解析。
(defmodule lamina_docs_pages
(export all))
(defun show (req)
(if (=:= 'true (lamina_setup:installed?))
(render req (request-path req))
`#(redirect "/setup")))
(defun render (req path)
(let ((session (lamina_auth:verify-request req))
(fragment (fragment? req)))
(case (lamina_docs:serve path)
(`#(file ,mime ,bytes)
`#(status 200
#M(#"content-type" ,(list_to_binary mime)
#"x-content-type-options" #"nosniff"
#"content-security-policy" #"default-src 'none'")
,bytes))
(`#(article ,tree ,article)
(if fragment
(html 200 (article-fragment article))
(html 200 (article-page session tree article))))
('unconfigured
(html 200 (message-page session "Docs" "Documentation has not been configured yet.")))
(`#(empty ,_tree)
(html 200 (message-page session "Docs" "This repository has no Markdown files on the selected branch.")))
(`#(not-found ,tree)
(if fragment
(html 404 (not-found-fragment))
(html 404 (not-found-page session tree))))
(`#(error ,message)
(html 200 (message-page session "Docs" message)))
(_
(html 200 (message-page session "Docs" "The documentation could not be read."))))))
(defun fragment? (req)
(=:= "fragment" (string:lowercase (lamina_ui:to-str (cowboy_req:header #"x-lamina-docs" req #"")))))
(defun html (code body)
(let ((bin (lamina_docs:encode-html body)))
`#(status ,code
#M(#"content-type" #"text/html; charset=utf-8"
#"vary" #"x-lamina-docs")
,bin)))
(defun request-path (req)
(let ((path (lamina_docs:decode-url-path (decode-path (cowboy_req:path req)))))
(cond
((lists:prefix "/docs/" path) (lists:nthtail 6 path))
((=:= path "/docs") "")
('true ""))))
(defun decode-path (path)
(case (unicode:characters_to_list path)
(`#(error ,_ ,_) (lamina_ui:to-str path))
(`#(incomplete ,_ ,_) (lamina_ui:to-str path))
(chars chars)))
(defun article-page (session tree article)
(let ((site (lamina_site_mgr:all)))
(page site session (maps:get 'title article "")
(list (sidebar (maps:get 'categories tree '()) (maps:get 'path article))
(article-html article)))))
(defun article-fragment (article)
(let ((site-name (maps:get 'site_name (lamina_site_mgr:all) "lamina"))
(title (maps:get 'title article "")))
(list "<title>" (lamina_ui:escape (lists:flatten (list title " - Docs - " site-name))) "</title>"
(article-html article))))
(defun article-html (article)
(list "<article class=\"docs-body\"><h1>" (lamina_ui:escape (maps:get 'title article "")) "</h1>"
(lamina_docs:render-markdown
(maps:get 'body article "")
(tuple (maps:get 'prefix article "/docs") (maps:get 'dir article "")))
"</article>"))
(defun not-found-page (session tree)
(let ((site (lamina_site_mgr:all)))
(page site session "Not found"
(list (sidebar (maps:get 'categories tree '()) "")
(not-found-html)))))
(defun not-found-fragment ()
(let ((site-name (maps:get 'site_name (lamina_site_mgr:all) "lamina")))
(list "<title>" (lamina_ui:escape (lists:flatten (list "Not found - Docs - " site-name))) "</title>"
(not-found-html))))
(defun not-found-html ()
"<article class=\"docs-body\"><h1>Page not found</h1><p>That document is not in the selected repository.</p></article>")
(defun message-page (session title message)
(page (lamina_site_mgr:all) session title
(list "<article class=\"docs-body\"><h1>" (lamina_ui:escape title) "</h1><p>"
(lamina_ui:escape message) "</p></article>")))
(defun page (site session title body)
(let ((site-name (maps:get 'site_name site "lamina")))
(lists:flatten
(list
(lamina_ui:public-head (lists:flatten (list title " - Docs - " site-name)) "shell docs-shell")
(lamina_ui:public-nav site-name session 'docs)
(lamina_ui:public-tagline (maps:get 'site_tagline site ""))
"<main class=\"docs\">" body "</main>"
(nav-script)
(lamina_ui:public-footer site)
"</div></body></html>"))))
(defun sidebar (categories current-path)
(if (=:= '() categories)
""
(list "<aside class=\"docs-side\">"
(lists:map
(lambda (group)
(list "<h2>" (lamina_ui:escape (maps:get 'label group)) "</h2><ul>"
(lists:map (lambda (article) (article-link article current-path))
(maps:get 'articles group '()))
"</ul>"))
categories)
"</aside>")))
(defun article-link (article current-path)
(list "<li><a"
(if (=:= current-path (maps:get 'path article)) " class=\"active\"" "")
" href=\"" (lamina_ui:escape (maps:get 'url article)) "\">"
(lamina_ui:escape (maps:get 'title article)) "</a></li>"))
(defun nav-script ()
"<script>(function(){
var main=document.querySelector('main.docs');
if(!main) return;
function asset(path){
var clean=path.split('?')[0].split('#')[0];
var slash=clean.lastIndexOf('/');
var name=slash<0?clean:clean.slice(slash+1);
var dot=name.lastIndexOf('.');
if(dot<0) return false;
var ext=name.slice(dot+1).toLowerCase();
return ext==='png'||ext==='jpg'||ext==='jpeg'||ext==='gif'||ext==='webp'||ext==='svg'||ext==='pdf'||ext==='css'||ext==='txt';
}
function docsPath(path){return path==='/docs'||path.indexOf('/docs/')===0||path.indexOf('/library/')===0;}
function setActive(path){
var links=main.querySelectorAll('.docs-side a');
for(var i=0;i<links.length;i++){
if(links[i].pathname===path) links[i].classList.add('active');
else links[i].classList.remove('active');
}
}
function swap(html,url,push,restoreY){
var doc=new DOMParser().parseFromString(html,'text/html');
var next=doc.querySelector('article.docs-body');
var current=main.querySelector('article.docs-body');
if(!next||!current){location.assign(url);return;}
var dest=new URL(url,location.href);
if(push) history.replaceState({y:window.scrollY},'',location.href);
current.replaceWith(document.importNode(next,true));
var title=doc.querySelector('title');
if(title&&title.textContent) document.title=title.textContent;
setActive(dest.pathname);
if(push){
history.pushState({y:0},'',dest.pathname+dest.search+dest.hash);
if(dest.hash===''){
var placed=main.querySelector('article.docs-body');
var y=placed.getBoundingClientRect().top+window.scrollY-8;
if(y<window.scrollY) window.scrollTo(0,Math.max(0,y));
}
history.replaceState({y:window.scrollY},'',location.href);
}else if(typeof restoreY==='number'){
window.scrollTo(0,restoreY);
}
}
var pending=0;
function load(url,push,restoreY){
var seq=++pending;
fetch(url,{headers:{'x-lamina-docs':'fragment'},credentials:'same-origin'})
.then(function(res){
var type=res.headers.get('content-type')||'';
if(type.indexOf('text/html')<0){location.assign(url);return null;}
return res.text();
})
.then(function(html){
if(html==null||seq!==pending) return;
swap(html,url,push,restoreY);
})
.catch(function(){if(seq===pending) location.assign(url);});
}
if(!history.state) history.replaceState({y:window.scrollY},'',location.href);
main.addEventListener('click',function(ev){
if(ev.defaultPrevented||ev.button!==0||ev.metaKey||ev.ctrlKey||ev.shiftKey||ev.altKey) return;
var node=ev.target;
while(node&&node!==main&&(!node.tagName||node.tagName!=='A')) node=node.parentNode;
if(!node||node===main||node.tagName!=='A') return;
if(node.target&&node.target!=='_self') return;
var dest;
try{dest=new URL(node.href,location.href);}catch(e){return;}
if(dest.origin!==location.origin||!docsPath(dest.pathname)||asset(dest.pathname)) return;
if(dest.pathname===location.pathname&&dest.search===location.search) return;
ev.preventDefault();
load(dest.pathname+dest.search+dest.hash,true);
});
window.addEventListener('popstate',function(ev){
if(!docsPath(location.pathname)) return;
var y=ev.state&&typeof ev.state.y==='number'?ev.state.y:0;
load(location.pathname+location.search+location.hash,false,y);
});
})();</script>")