Commit 2e13b50
Changed files (5)
lib
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,'&').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')}
}
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