packagegeneratorimport("bytes""encoding/json""fmt""html/template""io""io/fs""log""os""path""path/filepath""runtime""strings""sync""time""static-repo/internal/models""static-repo/internal/renderer""static-repo/internal/tree""static-repo/internal/utils")typeConfigstruct{ContentDirstringOutputDirstringLayoutFilestringSubdirstring}typeGeneratorstruct{configConfigrend*renderer.Renderertmpl*template.Templatefsfs.FS}funcNewGenerator(configConfig,templatesFSfs.FS)(*Generator,error){rend,err:=renderer.NewRenderer(templatesFS)iferr!=nil{returnnil,fmt.Errorf("failed to create renderer: %v",err)}tmpl,err:=template.New(filepath.Base(config.LayoutFile)).Funcs(template.FuncMap{"dict":func(values...interface{})(map[string]interface{},error){iflen(values)%2!=0{returnnil,fmt.Errorf("invalid dict call")}dict:=make(map[string]interface{},len(values)/2)fori:=0;i<len(values);i+=2{key,ok:=values[i].(string)if!ok{returnnil,fmt.Errorf("dict keys must be strings")}dict[key]=values[i+1]}returndict,nil},"isAncestor":func(current,nodePathstring)bool{ifnodePath==""||nodePath=="."||nodePath=="Root"{returntrue}current=filepath.ToSlash(current)nodePath=filepath.ToSlash(nodePath)returncurrent==nodePath||strings.HasPrefix(current,nodePath+"/")},}).ParseFS(templatesFS,config.LayoutFile)iferr!=nil{returnnil,fmt.Errorf("failed to parse template: %v",err)}// Parse partialstmpl,err=tmpl.ParseFS(templatesFS,"templates/partials/*.html")iferr!=nil{returnnil,fmt.Errorf("failed to parse partials: %v",err)}// Parse iconstmpl,err=tmpl.ParseFS(templatesFS,"templates/partials/icons/*.svg")iferr!=nil{returnnil,fmt.Errorf("failed to parse icons: %v",err)}// Parse projects index templatetmpl,err=tmpl.ParseFS(templatesFS,"templates/projects_index.html")iferr!=nil{returnnil,fmt.Errorf("failed to parse projects index template: %v",err)}return&Generator{config:config,rend:rend,tmpl:tmpl,fs:templatesFS,},nil}vargenerationCountintfunc(g*Generator)Generate()error{start:=time.Now()// Ensure output directory exists and is cleaniferr:=os.RemoveAll(g.config.OutputDir);err!=nil{returnfmt.Errorf("failed to clean output directory: %v",err)}iferr:=os.MkdirAll(g.config.OutputDir,0755);err!=nil{returnfmt.Errorf("failed to create output directory: %v",err)}projects,err:=g.collectProjects()deferfunc(){for_,p:=rangeprojects{ifp.TempDir!=""{fmt.Printf("Cleaning up temp dir for project %s: %s\n",p.Title,p.TempDir)os.RemoveAll(p.TempDir)}}}()iferr!=nil{returnerr}for_,project:=rangeprojects{iferr:=g.processProject(project,projects);err!=nil{log.Printf("Warning: failed to process project %s: %v",project.Name,err)}}iferr:=g.renderOverview(projects);err!=nil{returnerr}iferr:=g.copyAssets();err!=nil{returnerr}fmt.Printf("Generated %d pages in %s\n",generationCount,time.Since(start))returnnil}func(g*Generator)getBaseURL()string{prefix:="/"ifg.config.Subdir!=""{prefix=path.Join(prefix,g.config.Subdir)}if!strings.HasSuffix(prefix,"/"){prefix+="/"}returnprefix}func(g*Generator)getURLPrefix(projectNamestring)string{baseURL:=g.getBaseURL()ifprojectName==""{returnbaseURL}returnpath.Join(baseURL,projectName)}func(g*Generator)collectProjects()([]models.Project,error){varprojects[]models.Project// Process external.json if it existsexternalPath:=filepath.Join(g.config.ContentDir,"external.json")if_,err:=os.Stat(externalPath);err==nil{data,err:=os.ReadFile(externalPath)iferr==nil{varextProjects[]struct{URLstring`json:"url"`Titlestring`json:"title"`Descriptionstring`json:"description"`Datestring`json:"date"`}iferr:=json.Unmarshal(data,&extProjects);err==nil{for_,ep:=rangeextProjects{fmt.Printf("Downloading external project: %s\n",ep.Title)proj,err:=g.downloadExternalProject(ep.URL,ep.Title,ep.Description,ep.Date)iferr!=nil{log.Printf("Warning: %v",err)continue}projects=append(projects,proj)}}}}rootEntries,err:=os.ReadDir(g.config.ContentDir)iferr!=nil{returnprojects,fmt.Errorf("failed to read content directory: %v",err)}for_,entry:=rangerootEntries{if!entry.IsDir(){continue}projectName:=entry.Name()projectDir:=filepath.Join(g.config.ContentDir,projectName)urlPrefix:=g.getURLPrefix(projectName)project:=models.Project{Name:projectName,Title:projectName,ContentDir:projectDir,Link:path.Join(urlPrefix,"index.html"),ZipLink:path.Join(urlPrefix,projectName+".zip"),}readmePath:=g.findReadme(projectDir)ifreadmePath!=""{meta,err:=g.rend.ExtractFrontmatter(readmePath)iferr==nil{iftitle,ok:=meta["title"].(string);ok{project.Title=title}ifdesc,ok:=meta["description"].(string);ok{project.Description=desc}project.Date=normalizeProjectDate(meta["date"])}}projects=append(projects,project)}returnprojects,nil}func(g*Generator)downloadExternalProject(url,title,description,datestring)(models.Project,error){zipFile,err:=os.CreateTemp("","ext-*.zip")iferr!=nil{returnmodels.Project{},fmt.Errorf("failed to create temp file: %v",err)}zipFilePath:=zipFile.Name()zipFile.Close()deferos.Remove(zipFilePath)iferr:=utils.DownloadFile(url,zipFilePath);err!=nil{returnmodels.Project{},fmt.Errorf("failed to download %s: %v",url,err)}tempDir,err:=os.MkdirTemp("","ext-project-*")iferr!=nil{returnmodels.Project{},fmt.Errorf("failed to create temp dir: %v",err)}iferr:=utils.Unzip(zipFilePath,tempDir);err!=nil{os.RemoveAll(tempDir)returnmodels.Project{},fmt.Errorf("failed to unzip: %v",err)}entries,_:=os.ReadDir(tempDir)actualContentDir:=tempDiriflen(entries)==1&&entries[0].IsDir(){actualContentDir=filepath.Join(tempDir,entries[0].Name())}projectName:=strings.ToLower(strings.ReplaceAll(title," ","-"))urlPrefix:=g.getURLPrefix(projectName)returnmodels.Project{Name:projectName,Title:title,Description:description,Date:date,ContentDir:actualContentDir,Link:path.Join(urlPrefix,"index.html"),TempDir:tempDir,ZipLink:path.Join(urlPrefix,projectName+".zip"),},nil}funcnormalizeProjectDate(valueany)string{switchv:=value.(type){casestring:returnstrings.TrimSpace(v)casetime.Time:returnv.Format("2006-01-02")default:return""}}func(g*Generator)processProject(projectmodels.Project,allProjects[]models.Project)error{projectOutputDir:=filepath.Join(g.config.OutputDir,project.Name)zipName:=project.Name+".zip"iferr:=os.MkdirAll(projectOutputDir,0755);err!=nil{returnerr}fileTree,err:=tree.BuildFileTree(project.ContentDir,"",g.getURLPrefix(project.Name))iferr!=nil{returnerr}// Collect files to rendervarfiles[]stringerr=filepath.WalkDir(project.ContentDir,func(filePathstring,dfs.DirEntry,errerror)error{iferr!=nil{returnerr}if!d.IsDir(){files=append(files,filePath)}returnnil})iferr!=nil{returnerr}numFiles:=len(files)ifnumFiles==0{returnnil}// Use worker pool for parallel renderingnumWorkers:=runtime.NumCPU()ifnumWorkers>numFiles{numWorkers=numFiles}jobs:=make(chanstring,numFiles)results:=make(chanerror,numFiles)varwgsync.WaitGroupforw:=0;w<numWorkers;w++{wg.Add(1)gofunc(){deferwg.Done()forfilePath:=rangejobs{relPath,_:=filepath.Rel(project.ContentDir,filePath)outRel:=relPath+".html"ifstrings.ToLower(filepath.Base(relPath))=="readme.md"{outRel=filepath.Join(filepath.Dir(relPath),"index.html")}outputPath:=filepath.Join(projectOutputDir,outRel)os.MkdirAll(filepath.Dir(outputPath),0755)urlPrefix:=g.getURLPrefix(project.Name)content,err:=g.rend.RenderFile(filePath,project.ContentDir,projectOutputDir,urlPrefix)iferr!=nil{log.Printf("Warning: failed to render %s: %v",filePath,err)results<-nil// Continue on render errorcontinue}// Copy the original file as well, so users can download itifstrings.HasSuffix(strings.ToLower(filePath),".md"){origPath:=filepath.Join(projectOutputDir,relPath)os.MkdirAll(filepath.Dir(origPath),0755)utils.CopyFile(filePath,origPath)}generationCount++data:=models.TemplateData{Title:filepath.ToSlash(relPath),Content:template.HTML(content),FileTree:fileTree,CurrentProject:&project,Projects:allProjects,CurrentPath:filepath.ToSlash(relPath),BaseURL:g.getBaseURL(),}f,err:=os.Create(outputPath)iferr!=nil{results<-errcontinue}iferr:=g.tmpl.Execute(f,data);err!=nil{f.Close()results<-errcontinue}f.Close()results<-nil}}()}for_,path:=rangefiles{jobs<-path}close(jobs)gofunc(){wg.Wait()close(results)}()forerr:=rangeresults{iferr!=nil{returnerr}}// Render project indexreadmePath:=""currentPath:=""for_,child:=rangefileTree.Children{ifstrings.ToLower(child.Name)=="readme.md"{readmePath=filepath.Join(project.ContentDir,child.Path)currentPath=child.Pathbreak}}varcontenttemplate.HTMLifreadmePath!=""{c,err:=g.rend.RenderMarkdown(readmePath)iferr!=nil{returnerr}content=template.HTML(c)}indexPath:=filepath.Join(projectOutputDir,"index.html")indexFile,err:=os.Create(indexPath)iferr!=nil{returnerr}deferindexFile.Close()data:=models.TemplateData{Title:project.Title,Content:content,FileTree:fileTree,CurrentProject:&project,Projects:allProjects,CurrentPath:currentPath,BaseURL:g.getBaseURL(),IsIndex:true,}iferr:=g.tmpl.Execute(indexFile,data);err!=nil{returnerr}returnutils.ZipDirectory(project.ContentDir,filepath.Join(projectOutputDir,zipName))}func(g*Generator)renderOverview(projects[]models.Project)error{overviewPath:=filepath.Join(g.config.OutputDir,"index.html")f,err:=os.Create(overviewPath)iferr!=nil{returnerr}deferf.Close()overviewTitle:="Project Overview"varrootReadmeContenttemplate.HTMLrootReadmePath:=g.findReadme(g.config.ContentDir)ifrootReadmePath!=""{meta,err:=g.rend.ExtractFrontmatter(rootReadmePath)iferr==nil{iftitle,ok:=meta["title"].(string);ok{overviewTitle=title}}content,err:=g.rend.RenderMarkdown(rootReadmePath)iferr==nil{rootReadmeContent=template.HTML(content)}}varcontentBufbytes.Bufferiferr:=g.tmpl.ExecuteTemplate(&contentBuf,"projects_index.html",models.TemplateData{Projects:projects,Extra:map[string]any{"OverviewContent":rootReadmeContent},});err!=nil{returnerr}data:=models.TemplateData{Title:overviewTitle,Content:template.HTML(contentBuf.String()),Projects:projects,BaseURL:g.getBaseURL(),IsIndex:true,}returng.tmpl.Execute(f,data)}func(g*Generator)copyAssets()error{assetsDir:=filepath.Join(g.config.OutputDir,"assets")os.MkdirAll(filepath.Join(assetsDir,"icons"),0755)style,err:=fs.ReadFile(g.fs,"templates/style.min.css")iferr==nil{os.WriteFile(filepath.Join(assetsDir,"style.min.css"),style,0644)}script,err:=fs.ReadFile(g.fs,"templates/script.js")iferr==nil{os.WriteFile(filepath.Join(assetsDir,"script.js"),script,0644)}iconEntries,_:=fs.ReadDir(g.fs,"templates/partials/icons")for_,entry:=rangeiconEntries{data,err:=fs.ReadFile(g.fs,filepath.Join("templates/partials/icons",entry.Name()))iferr==nil{dst:=filepath.Join(assetsDir,"icons",entry.Name())os.WriteFile(dst,data,0644)}}icon,err:=os.Open(filepath.Join(g.config.ContentDir,"favicon.ico"))iferr==nil{f,_:=os.Create(filepath.Join(g.config.OutputDir,"favicon.ico"))io.Copy(f,icon)f.Close()}returnnil}func(g*Generator)findReadme(dirstring)string{entries,_:=os.ReadDir(dir)for_,e:=rangeentries{ifstrings.ToLower(e.Name())=="readme.md"{returnfilepath.Join(dir,e.Name())}}return""}