Commit 2e13b50

mo khan <mo@mokhan.ca>
2025-12-11 23:50:23
fix: relative paths
1 parent de14376
lib/gitem/cli.rb
@@ -4,7 +4,7 @@ module Gitem
   class CLI
     def initialize(argv)
       @argv = argv.dup
-      @options = { output: nil, port: 8000, generate: true, open: false }
+      @options = { output: nil, port: 8000, generate: true, open: false, base_path: nil }
     end
 
     def run
@@ -32,13 +32,13 @@ module Gitem
     def run_generate
       parse_generate_options!
       validate_repo!
-      Generator.new(@options[:repo_path], @options[:output]).export!
+      Generator.new(@options[:repo_path], @options[:output], @options[:base_path]).export!
     end
 
     def run_serve
       parse_serve_options!
       validate_repo!
-      generator = Generator.new(@options[:repo_path], @options[:output])
+      generator = Generator.new(@options[:repo_path], @options[:output], @options[:base_path])
       generator.export! if @options[:generate]
       server = Server.new(generator.output_dir, @options[:port])
       open_browser(server.url) if @options[:open]
@@ -49,6 +49,7 @@ module Gitem
       OptionParser.new do |opts|
         opts.banner = "Usage: gitem generate [REPO_PATH] [options]"
         opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v }
+        opts.on("-b", "--base-path PATH", "Base path for hosting (e.g., /xlgmokha/gitem)") { |v| @options[:base_path] = v }
         opts.on("-h", "--help", "Show help") { puts opts; exit }
       end.parse!(@argv)
       @options[:repo_path] = @argv.shift || "."
@@ -59,6 +60,7 @@ module Gitem
         opts.banner = "Usage: gitem serve [REPO_PATH] [options]"
         opts.on("-o", "--output DIR", "Output directory") { |v| @options[:output] = v }
         opts.on("-p", "--port PORT", Integer, "Port (default: 8000)") { |v| @options[:port] = v }
+        opts.on("-b", "--base-path PATH", "Base path for hosting (e.g., /xlgmokha/gitem)") { |v| @options[:base_path] = v }
         opts.on("--[no-]generate", "Generate before serving") { |v| @options[:generate] = v }
         opts.on("--open", "Open browser") { @options[:open] = true }
         opts.on("-h", "--help", "Show help") { puts opts; exit }
lib/gitem/generator.rb
@@ -5,9 +5,10 @@ module Gitem
     TEMPLATE_PATH = File.expand_path("index.html", __dir__)
     attr_reader :output_dir
 
-    def initialize(repo_path, output_dir = nil)
+    def initialize(repo_path, output_dir = nil, base_path = nil)
       @repo = Rugged::Repository.new(repo_path)
       @output_dir = output_dir || File.join(@repo.path, "srv")
+      @base_path = base_path
       @processed_trees = Set.new
       @processed_blobs = Set.new
     end
@@ -37,11 +38,13 @@ module Gitem
     def export_repo_info
       branch = @repo.branches[default_branch_name]
       readme_content, readme_name = extract_readme(branch)
-      write_json("repo.json", {
+      info = {
         name: repo_name, default_branch: default_branch_name,
         branches_count: local_branches.size, tags_count: @repo.tags.count,
         readme: readme_content, readme_name: readme_name, generated_at: Time.now.iso8601
-      })
+      }
+      info[:base_path] = @base_path if @base_path
+      write_json("repo.json", info)
     end
 
     def repo_name
lib/gitem/index.html
@@ -112,6 +112,7 @@
   <div id="app"><div class="loading">Loading repository...</div></div>
 </div>
 <script>
+let BASE_PATH='';
 const S={repo:null,view:'code',ref:'main',path:'',sha:'',base:'',compare:''};
 const $=id=>document.getElementById(id);
 const h=(t,a={},c=[])=>{const e=document.createElement(t);Object.entries(a).forEach(([k,v])=>k==='text'?e.textContent=v:k==='html'?e.innerHTML=v:k.startsWith('on')?e.addEventListener(k.slice(2).toLowerCase(),v):e.setAttribute(k,v));c.forEach(x=>e.appendChild(typeof x==='string'?document.createTextNode(x):x));return e};
@@ -131,10 +132,100 @@ const icons={
 const timeAgo=d=>{const s=Math.floor((Date.now()-new Date(d))/1000);if(s<60)return'just now';if(s<3600)return Math.floor(s/60)+' min ago';if(s<86400)return Math.floor(s/3600)+' hours ago';if(s<604800)return Math.floor(s/86400)+' days ago';return new Date(d).toLocaleDateString()};
 const esc=s=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
 
-async function load(p){const r=await fetch(p);if(!r.ok)throw new Error(`Failed: ${p}`);return r.json()}
+async function load(p){const path=p.startsWith('/')?p:`/${p}`;const fullPath=BASE_PATH+path;const r=await fetch(fullPath);if(!r.ok)throw new Error(`Failed: ${fullPath}`);return r.json()}
 function showError(m){const e=$('error');e.textContent=m;e.style.display='block'}
 function hideError(){$('error').style.display='none'}
 
+function parseUrl(){
+  let path=window.location.pathname;
+  if(BASE_PATH&&path.startsWith(BASE_PATH))path=path.slice(BASE_PATH.length)||'/';
+  const newState={view:'code',ref:S.repo?.default_branch||'main',path:'',sha:'',base:'',compare:''};
+  if(path==='/') return newState;
+  const parts=path.split('/').filter(x=>x);
+  if(parts[0]==='tree'&&parts.length>=2){
+    newState.view='code';newState.ref=parts[1];newState.path=parts.slice(2).join('/');
+  }else if(parts[0]==='blob'&&parts.length>=2){
+    newState.view='code';newState.ref=parts[1];newState.path=parts.slice(2).join('/');
+  }else if(parts[0]==='commit'&&parts.length===2){
+    newState.view='commit';newState.sha=parts[1];
+  }else if(parts[0]==='commits'){
+    newState.view='commits';
+    if(parts.length===2)newState.ref=parts[1];
+  }else if(parts[0]==='branches'){
+    newState.view='branches';
+  }else if(parts[0]==='tags'){
+    newState.view='tags';
+  }else if(parts[0]==='compare'&&parts.length===2){
+    newState.view='compare';
+    const compareParts=parts[1].split('...');
+    if(compareParts.length===2){newState.base=compareParts[0];newState.compare=compareParts[1]}
+  }
+  return newState;
+}
+
+async function checkIsBlob(ref,path){
+  if(!path)return false;
+  try{
+    const branches=await load('branches.json');
+    const branch=branches.find(b=>b.name===ref);
+    if(!branch)return false;
+    const commit=await load(`commits/${branch.sha}.json`);
+    const root=await load(`trees/${commit.tree_sha}.json`);
+    const parts=path.split('/');
+    let current=root;
+    for(let i=0;i<parts.length;i++){
+      const entry=current.entries.find(e=>e.name===parts[i]);
+      if(!entry)return false;
+      if(entry.type==='blob')return true;
+      if(i<parts.length-1)current=await load(`trees/${entry.sha}.json`);
+    }
+    return false;
+  }catch(e){return false}
+}
+
+async function buildUrl(state){
+  let path;
+  switch(state.view){
+    case'code':
+      if(!state.path){
+        if(state.ref===S.repo.default_branch)path='/';
+        else path=`/tree/${encodeURIComponent(state.ref)}`;
+      }else{
+        const isBlob=await checkIsBlob(state.ref,state.path);
+        const prefix=isBlob?'/blob':'/tree';
+        path=`${prefix}/${encodeURIComponent(state.ref)}/${state.path.split('/').map(encodeURIComponent).join('/')}`;
+      }
+      break;
+    case'commits':
+      if(!state.ref||state.ref===S.repo.default_branch)path='/commits';
+      else path=`/commits/${encodeURIComponent(state.ref)}`;
+      break;
+    case'commit':
+      path=`/commit/${state.sha}`;
+      break;
+    case'branches':
+      path='/branches';
+      break;
+    case'tags':
+      path='/tags';
+      break;
+    case'compare':
+      path=`/compare/${encodeURIComponent(state.base)}...${encodeURIComponent(state.compare)}`;
+      break;
+    default:
+      path='/';
+  }
+  return BASE_PATH+path;
+}
+
+async function navigate(newState,replace=false){
+  const url=await buildUrl(newState);
+  if(replace)history.replaceState(newState,'',url);
+  else history.pushState(newState,'',url);
+  Object.assign(S,newState);
+  await render();
+}
+
 function renderNav(){
   const nav=$('nav-tabs');nav.innerHTML='';
   const tabs=[['code','Code',icons.code],['commits','Commits',icons.commit],['branches','Branches',icons.branch],['tags','Tags',icons.tag],['compare','Compare',icons.compare]];
@@ -143,7 +234,7 @@ function renderNav(){
     a.innerHTML=svg(i)+' '+t;
     if(v==='branches')a.innerHTML+=` <span class="Counter">${S.repo?.branches_count||0}</span>`;
     if(v==='tags')a.innerHTML+=` <span class="Counter">${S.repo?.tags_count||0}</span>`;
-    a.onclick=e=>{e.preventDefault();S.view=v;S.path='';S.sha='';render()};
+    a.onclick=e=>{e.preventDefault();navigate({...S,view:v,path:'',sha:''})};
     nav.appendChild(a);
   });
 }
@@ -175,26 +266,26 @@ async function renderCode(){
   const branch=branches.find(b=>b.name===S.ref)||branches.find(b=>b.is_head)||branches[0];
   if(!branch)return h('div',{class:'empty-state',text:'No branches found'});
   const commit=await load(`commits/${branch.sha}.json`);
-  
+
   const top=h('div',{class:'branch-select'});
-  top.appendChild(branchDropdown('branch-sel',S.ref,name=>{S.ref=name;S.path='';render()}));
-  
+  top.appendChild(branchDropdown('branch-sel',S.ref,name=>{navigate({...S,ref:name,path:''})}));
+
   const goTo=h('button',{class:'btn',html:svg(icons.commit)+' <span>Go to commit</span>'});
-  goTo.onclick=()=>{S.view='commits';render()};
+  goTo.onclick=()=>{navigate({...S,view:'commits'})};
   top.appendChild(goTo);
   wrap.appendChild(top);
 
   if(S.path){
     const bc=h('div',{class:'breadcrumb'});
     const root=h('a',{class:'breadcrumb-item',href:'#',text:S.repo.name});
-    root.onclick=e=>{e.preventDefault();S.path='';render()};
+    root.onclick=e=>{e.preventDefault();navigate({...S,path:''})};
     bc.appendChild(root);
     const parts=S.path.split('/');
     parts.forEach((p,i)=>{
       bc.appendChild(h('span',{class:'breadcrumb-sep',text:'/'}));
       if(i<parts.length-1){
         const link=h('a',{class:'breadcrumb-item',href:'#',text:p});
-        link.onclick=e=>{e.preventDefault();S.path=parts.slice(0,i+1).join('/');render()};
+        link.onclick=e=>{e.preventDefault();navigate({...S,path:parts.slice(0,i+1).join('/')})};
         bc.appendChild(link);
       }else bc.appendChild(h('span',{text:p}));
     });
@@ -241,21 +332,21 @@ async function renderCode(){
   const box=h('div',{class:'Box'});
   const header=h('div',{class:'Box-header'});
   header.innerHTML=`<span><strong>${esc(commit.author.name)}</strong> ${esc(commit.message_headline)}</span><a href="#" class="commit-sha">${commit.short_sha}</a>`;
-  header.querySelector('.commit-sha').onclick=e=>{e.preventDefault();S.view='commit';S.sha=commit.sha;render()};
+  header.querySelector('.commit-sha').onclick=e=>{e.preventDefault();navigate({...S,view:'commit',sha:commit.sha})};
   box.appendChild(header);
 
   const sorted=[...tree.entries].sort((a,b)=>a.type===b.type?a.name.localeCompare(b.name):a.type==='tree'?-1:1);
   if(S.path){
     const up=h('div',{class:'Box-row',style:'cursor:pointer'});
     up.innerHTML=`<span class="icon-directory">${svg(icons.dir)}</span><span class="file-name">..</span>`;
-    up.onclick=()=>{S.path=S.path.split('/').slice(0,-1).join('/');render()};
+    up.onclick=()=>{navigate({...S,path:S.path.split('/').slice(0,-1).join('/')})};
     box.appendChild(up);
   }
   sorted.forEach(e=>{
     const row=h('div',{class:'Box-row',style:'cursor:pointer'});
     const icon=e.type==='tree'?`<span class="icon-directory">${svg(icons.dir)}</span>`:`<span class="icon-file">${svg(icons.file)}</span>`;
     row.innerHTML=`${icon}<span class="file-name">${esc(e.name)}</span><span class="file-commit"></span><span class="file-time"></span>`;
-    row.onclick=()=>{S.path=e.path;render()};
+    row.onclick=()=>{navigate({...S,path:e.path})};
     box.appendChild(row);
   });
   wrap.appendChild(box);
@@ -276,16 +367,16 @@ async function renderCode(){
 async function renderCommits(){
   const commits=await load('commits.json');
   const wrap=h('div');
-  wrap.appendChild(branchDropdown('commits-branch',S.ref,name=>{S.ref=name;render()}));
+  wrap.appendChild(branchDropdown('commits-branch',S.ref,name=>{navigate({...S,ref:name})}));
   const box=h('div',{class:'Box commit-list',style:'margin-top:16px'});
   commits.forEach(c=>{
     const row=h('div',{class:'Box-row',style:'display:block'});
     const msg=h('a',{class:'commit-msg',href:'#',text:c.message_headline});
-    msg.onclick=e=>{e.preventDefault();S.view='commit';S.sha=c.sha;render()};
+    msg.onclick=e=>{e.preventDefault();navigate({...S,view:'commit',sha:c.sha})};
     const meta=h('div',{class:'commit-meta'});
     meta.innerHTML=`<span class="avatar"></span><strong>${esc(c.author.name)}</strong> committed ${timeAgo(c.committed_at)}`;
     const sha=h('a',{class:'commit-sha',href:'#',text:c.short_sha,style:'float:right;margin-top:-24px'});
-    sha.onclick=e=>{e.preventDefault();S.view='commit';S.sha=c.sha;render()};
+    sha.onclick=e=>{e.preventDefault();navigate({...S,view:'commit',sha:c.sha})};
     row.append(msg,meta,sha);
     box.appendChild(row);
   });
@@ -329,7 +420,7 @@ async function renderBranches(){
     row.innerHTML=`${b.is_head?'<span style="color:gold;margin-right:8px">โ˜…</span>':''}<span style="flex:1"><span class="tag">${svg(icons.branch,12)} ${esc(b.name)}</span></span>
       <span style="color:var(--color-fg-muted);margin-right:16px">${timeAgo(b.committed_at)}</span>`;
     const sha=h('a',{class:'commit-sha',href:'#',text:b.sha.slice(0,7)});
-    sha.onclick=e=>{e.preventDefault();S.view='commit';S.sha=b.sha;render()};
+    sha.onclick=e=>{e.preventDefault();navigate({...S,view:'commit',sha:b.sha})};
     row.appendChild(sha);
     box.appendChild(row);
   });
@@ -346,7 +437,7 @@ async function renderTags(){
     row.innerHTML=`<span style="flex:1"><span class="tag">${svg(icons.tag,12)} ${esc(t.name)}</span></span>
       <span style="color:var(--color-fg-muted);margin-right:16px">${timeAgo(t.committed_at)}</span>`;
     const sha=h('a',{class:'commit-sha',href:'#',text:t.sha.slice(0,7)});
-    sha.onclick=e=>{e.preventDefault();S.view='commit';S.sha=t.sha;render()};
+    sha.onclick=e=>{e.preventDefault();navigate({...S,view:'commit',sha:t.sha})};
     row.appendChild(sha);
     box.appendChild(row);
   });
@@ -358,11 +449,11 @@ async function renderCompare(){
   const branches=await load('branches.json');
   if(!S.base)S.base=S.repo.default_branch;
   if(!S.compare)S.compare=branches.find(b=>b.name!==S.base)?.name||S.base;
-  
+
   const sel=h('div',{class:'compare-selector'});
-  sel.appendChild(branchDropdown('base-branch',S.base,name=>{S.base=name;render()}));
+  sel.appendChild(branchDropdown('base-branch',S.base,name=>{navigate({...S,base:name})}));
   sel.appendChild(h('span',{class:'compare-arrow',text:'โ†'}));
-  sel.appendChild(branchDropdown('compare-branch',S.compare,name=>{S.compare=name;render()}));
+  sel.appendChild(branchDropdown('compare-branch',S.compare,name=>{navigate({...S,compare:name})}));
   wrap.appendChild(sel);
 
   const baseBranch=branches.find(b=>b.name===S.base);
@@ -378,8 +469,8 @@ async function renderCompare(){
       <p><strong>Base:</strong> ${esc(baseCommit.message_headline)} <a href="#" class="commit-sha base-sha">${baseCommit.short_sha}</a></p>
       <p style="margin-top:8px"><strong>Compare:</strong> ${esc(compareCommit.message_headline)} <a href="#" class="commit-sha compare-sha">${compareCommit.short_sha}</a></p>
     </div>`;
-  info.querySelector('.base-sha').onclick=e=>{e.preventDefault();S.view='commit';S.sha=baseBranch.sha;render()};
-  info.querySelector('.compare-sha').onclick=e=>{e.preventDefault();S.view='commit';S.sha=compareBranch.sha;render()};
+  info.querySelector('.base-sha').onclick=e=>{e.preventDefault();navigate({...S,view:'commit',sha:baseBranch.sha})};
+  info.querySelector('.compare-sha').onclick=e=>{e.preventDefault();navigate({...S,view:'commit',sha:compareBranch.sha})};
   wrap.appendChild(info);
 
   if(compareCommit.files?.length){
@@ -414,12 +505,37 @@ async function render(){
   }catch(e){showError(e.message);app.innerHTML=''}
 }
 
+function detectBasePath(){
+  const path=window.location.pathname;
+  const knownRoutes=['tree','blob','commit','commits','branches','tags','compare'];
+  const parts=path.split('/').filter(x=>x);
+  for(let i=0;i<parts.length;i++){
+    if(knownRoutes.includes(parts[i])){
+      return'/'+parts.slice(0,i).join('/');
+    }
+  }
+  if(path.endsWith('/'))return path.slice(0,-1);
+  const lastSlash=path.lastIndexOf('/');
+  return lastSlash>0?path.slice(0,lastSlash):'';
+}
+
 async function init(){
   try{
+    BASE_PATH=detectBasePath();
     S.repo=await load('repo.json');
-    S.ref=S.repo.default_branch;
+    if(S.repo.base_path!==undefined)BASE_PATH=S.repo.base_path;
+    const initialState=parseUrl();
+    Object.assign(S,initialState);
+    if(!S.ref)S.ref=S.repo.default_branch;
     $('repo-name').textContent=S.repo.name;
     document.title=S.repo.name+' - Git Browser';
+    const url=await buildUrl(S);
+    history.replaceState(S,'',url);
+    window.addEventListener('popstate',async(e)=>{
+      const state=e.state||parseUrl();
+      Object.assign(S,state);
+      await render();
+    });
     render();
   }catch(e){showError('Failed to load. Run the Ruby script first and serve with: python3 -m http.server')}
 }
lib/gitem/server.rb
@@ -14,9 +14,30 @@ module Gitem
       puts "๐ŸŒ Server running at #{@url}"
       puts "   Press Ctrl+C to stop\n\n"
       server = WEBrick::HTTPServer.new(
-        Port: @port, DocumentRoot: @root,
-        Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN), AccessLog: []
+        Port: @port,
+        Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
+        AccessLog: []
       )
+
+      server.mount_proc '/' do |req, res|
+        path = File.join(@root, req.path)
+        path = File.join(path, 'index.html') if File.directory?(path)
+
+        if File.exist?(path) && !File.directory?(path)
+          res.body = File.read(path)
+          res.content_type = WEBrick::HTTPUtils.mime_type(path, WEBrick::HTTPUtils::DefaultMimeTypes)
+        else
+          index_path = File.join(@root, 'index.html')
+          if File.exist?(index_path)
+            res.body = File.read(index_path)
+            res.content_type = 'text/html'
+          else
+            res.status = 404
+            res.body = 'Not Found'
+          end
+        end
+      end
+
       trap("INT") { server.shutdown }
       trap("TERM") { server.shutdown }
       server.start
README.md
@@ -11,14 +11,29 @@ gem install gitem
 ## Usage
 
 ```
-gitem serve                # Generate and serve (default)
-gitem serve -p 3000        # Custom port
-gitem serve --open         # Open browser
-gitem serve --no-generate  # Serve only
-gitem generate             # Generate only
-gitem generate -o ./out    # Custom output
+gitem serve                           # Generate and serve (default)
+gitem serve -p 3000                   # Custom port
+gitem serve --open                    # Open browser
+gitem serve --no-generate             # Serve only
+gitem generate                        # Generate only
+gitem generate -o ./out               # Custom output
+gitem generate -b /xlgmokha/gitem     # With base path for subdirectory hosting
 ```
 
+### Hosting Multiple Projects
+
+When hosting multiple projects under the same domain, use the `--base-path` option:
+
+```bash
+# For https://www.mokhan.ca/xlgmokha/net-hippie/
+gitem generate -b /xlgmokha/net-hippie -o /var/www/mokhan.ca/xlgmokha/net-hippie
+
+# For https://www.mokhan.ca/xlgmokha/gitem/
+gitem generate -b /xlgmokha/gitem -o /var/www/mokhan.ca/xlgmokha/gitem
+```
+
+The base path will be automatically detected if not specified, but explicit configuration is recommended for production deployments.
+
 ## Requirements
 
 - Ruby >= 3.4.0