diff options
| author | mo khan <mo@mokhan.ca> | 2025-12-11 16:50:23 -0700 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-12-11 16:50:23 -0700 |
| commit | 2e13b50d3205e214f8ab6c64e3e69eb0ae72b938 (patch) | |
| tree | ba5ab4539d1d8388e34dcb7f444165995300c760 | |
| parent | de14376f33de34e27176a7492050ac1f99867648 (diff) | |
| -rw-r--r-- | README.md | 27 | ||||
| -rw-r--r-- | lib/gitem/cli.rb | 8 | ||||
| -rw-r--r-- | lib/gitem/generator.rb | 9 | ||||
| -rw-r--r-- | lib/gitem/index.html | 160 | ||||
| -rw-r--r-- | lib/gitem/server.rb | 25 |
5 files changed, 193 insertions, 36 deletions
@@ -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 diff --git a/lib/gitem/cli.rb b/lib/gitem/cli.rb index a522163..7033032 100644 --- a/lib/gitem/cli.rb +++ b/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 } diff --git a/lib/gitem/generator.rb b/lib/gitem/generator.rb index e8f483a..b62543d 100644 --- a/lib/gitem/generator.rb +++ b/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 diff --git a/lib/gitem/index.html b/lib/gitem/index.html index 3f885a6..3e60888 100644 --- a/lib/gitem/index.html +++ b/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,'&').replace(/</g,'<').replace(/>/g,'>'); -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')} } diff --git a/lib/gitem/server.rb b/lib/gitem/server.rb index c73ff56..82edc90 100644 --- a/lib/gitem/server.rb +++ b/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 |
