From 4035c4ec067b51b0edb08e8e2cc4dadae5493eb1 Mon Sep 17 00:00:00 2001 From: sunwei Date: Tue, 26 Nov 2024 20:48:19 +0800 Subject: [PATCH] support terms --- README.md | 133 +++++++++++++----- internal/domain/content/entity/build.go | 18 ++- internal/domain/contenthub/entity/layout.go | 2 + internal/domain/contenthub/entity/page.go | 4 + .../domain/contenthub/entity/pagebuilder.go | 4 + .../domain/contenthub/entity/pagefields.go | 4 +- internal/domain/contenthub/entity/pagemap.go | 7 +- internal/domain/contenthub/entity/term.go | 2 +- internal/domain/contenthub/type.go | 2 - internal/domain/site/entity/navigation.go | 40 +++++- internal/domain/site/entity/page.go | 15 +- internal/domain/site/entity/pagecache.go | 49 +++++++ internal/domain/site/entity/pagedata.go | 21 +++ internal/domain/site/entity/pagefields.go | 26 ++-- internal/domain/site/entity/pages.go | 63 +++++++++ internal/domain/site/entity/sitefields.go | 9 ++ internal/domain/site/entity/siteinit.go | 12 +- internal/interfaces/api/handler/upload.go | 9 +- internal/interfaces/cli/vercurr.go | 2 +- manifest.json | 2 +- 20 files changed, 354 insertions(+), 70 deletions(-) create mode 100644 internal/domain/site/entity/pagedata.go create mode 100644 internal/domain/site/entity/pages.go diff --git a/README.md b/README.md index 2a9e32f..a42180d 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,113 @@ -# Hugoverse +# Hugoverse: Headless CMS for Hugo -Hugo headless CMS server +**Hugoverse** is a headless CMS designed for Hugo, providing a seamless way to manage your static website content. With its powerful APIs, you can upload articles and resources like images, preview your site in real-time, and deploy it effortlessly to the cloud—all tailored to your selected Hugo theme. -## Prerequisite +--- -Take MacOS for example: +## 🚀 Features -- Go: -- Dart Sass: brew install sass/sass/sass -- jq: brew install jq +1. **Content Management API** + Easily upload and manage articles, images, and other resources through Hugoverse's API. -## PoC +2. **Theme Compatibility** + Automatically adapts to your chosen Hugo theme, ensuring your site looks great without additional configuration. -- [x] DDD Ponzu to expose API -- [x] DDD Hugo to get content info -- [x] DDD Hugo to build site -- [x] Load Hugo project from file system -- [x] Manage Post through API -- [x] Build Site through API -- [x] Deploy Site through API +3. **Live Preview** + Preview your Hugo site in real-time to ensure your content and design align before deploying. -## MVP +4. **Cloud Deployment** + Deploy your site with a single click to the cloud, making it live and accessible instantly. -### 目标 +5. **Streamlined Resource Handling** + Efficiently manage images and files for your Hugo website, ensuring all assets are properly organized and accessible. -为敏捷UP主 [老袁讲敏捷](https://space.bilibili.com/36395967) 打造一体化的个人品牌官网,增强专业形象并提升影响力。 +--- -### 需要实现的功能 +## 🌟 Getting Started -- [ ] Hugoverse Notion Plugin - - [ ] 登陆Hugoverse服务 - - [ ] 创建个人主页站点 - - [ ] 创建商业服务站点 - 个人陪跑教练 - - [ ] 创建Unfix敏捷小组介绍 -- [x] Hugoverse待补全功能 - - [x] 注册用户 - - [x] 多租户管理 +Follow these steps to start using Hugoverse for your Hugo project: -## Next +### Prerequisites -- [ ] I18N -- [ ] Sitemap -- [ ] Inline shortcode -- [ ] Output Format: connect page layouts and page outputs, should separate the config info service and business logic -- [ ] Docs \ No newline at end of file +- Install [Hugo](https://gohugo.io/getting-started/installing/) on your machine. +- Create or clone a Hugo site. + +### Installation + +1. Clone the repository: + ```bash + git clone https://github.com/your-username/hugoverse.git + cd hugoverse + ``` + +2. Install dependencies (if applicable): + ```bash + # Example: pipenv, npm, etc. + ``` + +3. Start the Hugoverse server: + ```bash + hugoverse serve + ``` + +### API Usage + +**Upload an Article:** +Use the API to upload a markdown file. +```bash +curl -X POST -F "file=@article.md" https://your-hugoverse-instance/api/upload +``` + +**Upload Resources (e.g., images):** +```bash +curl -X POST -F "file=@image.png" https://your-hugoverse-instance/api/resources +``` + +**Preview Your Site:** +Visit `https://your-hugoverse-instance/preview`. + +**Deploy Your Site to the Cloud:** +```bash +curl -X POST https://your-hugoverse-instance/api/deploy +``` + +--- + +## 📄 Documentation + +Visit the [Hugoverse Documentation](https://hugoverse.example.com/docs) for detailed guides and API references. + +--- + +## 🛠️ Contributing + +We welcome contributions from the community! Feel free to open issues, suggest features, or submit pull requests. + +1. Fork the repository. +2. Create a feature branch: + ```bash + git checkout -b feature-name + ``` +3. Commit your changes: + ```bash + git commit -m "Add new feature" + ``` +4. Push the branch and open a pull request. + +--- + +## 📝 License + +Hugoverse is licensed under the [MIT License](LICENSE). + +--- + +## ✨ Contact + +For questions or support, feel free to reach out: + +- **Email:** support@hugoverse.com +- **Website:** [hugoverse.com](https://hugoverse.com) +- **GitHub Issues:** [Create an Issue](https://github.com/your-username/hugoverse/issues) + +Start building and managing your Hugo site effortlessly with **Hugoverse**! 🎉s \ No newline at end of file diff --git a/internal/domain/content/entity/build.go b/internal/domain/content/entity/build.go index 6d75760..cd55fa6 100644 --- a/internal/domain/content/entity/build.go +++ b/internal/domain/content/entity/build.go @@ -134,7 +134,23 @@ func (c *Content) writeSiteResource(siteId int, dir string) error { } if res.Asset != "" { - go c.copyFiles(dir, getParentPath(sr.Path), []string{res.Asset}) + _, p, err := parseURL(res.Asset) + if err != nil { + c.Log.Printf("parse url %s failed: %v\n", res.Asset, err) + return err + } + + src := path.Join(c.Hugo.DirService.UploadDir(), p) + dst := path.Join(dir, sr.Path) + + if err := c.Hugo.Fs.MkdirAll(path.Join(dir, getParentPath(sr.Path)), 0755); err != nil { + c.Log.Printf("mkdir %s failed: %v\n", path.Join(dir, getParentPath(sr.Path)), err) + continue + } + + if err := c.copyFile(src, dst); err != nil { + return err + } } } diff --git a/internal/domain/contenthub/entity/layout.go b/internal/domain/contenthub/entity/layout.go index 410a1ee..d8d5acf 100644 --- a/internal/domain/contenthub/entity/layout.go +++ b/internal/domain/contenthub/entity/layout.go @@ -22,6 +22,7 @@ const ( TaxonomyList = "taxonomy" + "/" + LayoutList TermTerm = "term/term.html" + TermTag = "taxonomy/tag.html" TermList = "taxonomy" + "/" + LayoutList Sitemap = "sitemap.xml" @@ -67,6 +68,7 @@ func (l *Layout) taxonomy() []string { func (l *Layout) term() []string { return []string{ + TermTag, TermTerm, TermList, DefaultTerm, diff --git a/internal/domain/contenthub/entity/page.go b/internal/domain/contenthub/entity/page.go index 32c23df..a0be049 100644 --- a/internal/domain/contenthub/entity/page.go +++ b/internal/domain/contenthub/entity/page.go @@ -125,6 +125,8 @@ func newTaxonomy(source *Source, content *Content, singular string) (*TaxonomyPa singular: singular, } + taxonomy.Page.title = singular + return taxonomy, nil } @@ -140,5 +142,7 @@ func newTerm(source *Source, content *Content, singular string, term string) (*T term: term, } + tp.TaxonomyPage.Page.title = term + return tp, nil } diff --git a/internal/domain/contenthub/entity/pagebuilder.go b/internal/domain/contenthub/entity/pagebuilder.go index 67089b7..d705422 100644 --- a/internal/domain/contenthub/entity/pagebuilder.go +++ b/internal/domain/contenthub/entity/pagebuilder.go @@ -194,6 +194,8 @@ func (b *PageBuilder) buildTaxonomy() (*TaxonomyPage, error) { return nil, err } + tp.pageMap = b.PageMapper + if err := b.buildOutput(tp.Page); err != nil { return nil, err } @@ -211,6 +213,8 @@ func (b *PageBuilder) buildTerm() (*TermPage, error) { return nil, err } + t.pageMap = b.PageMapper + if err := b.buildOutput(t.Page); err != nil { return nil, err } diff --git a/internal/domain/contenthub/entity/pagefields.go b/internal/domain/contenthub/entity/pagefields.go index 191e84a..121bfba 100644 --- a/internal/domain/contenthub/entity/pagefields.go +++ b/internal/domain/contenthub/entity/pagefields.go @@ -28,7 +28,7 @@ func (p *Page) Pages(langIndex int) contenthub.Pages { case valueobject.KindTerm: return p.pageMap.getPagesWithTerm( pageMapQueryPagesBelowPath{ - Path: p.Path(), + Path: p.Paths().Base(), }, ) case valueobject.KindTaxonomy: @@ -37,7 +37,7 @@ func (p *Page) Pages(langIndex int) contenthub.Pages { pageMapQueryPagesInSection{ Index: langIndex, pageMapQueryPagesBelowPath: pageMapQueryPagesBelowPath{ - Path: p.Path(), + Path: p.Paths().Base(), KeyPart: "term", Include: pagePredicates.ShouldListLocal.And(pagePredicates.KindTerm), }, diff --git a/internal/domain/contenthub/entity/pagemap.go b/internal/domain/contenthub/entity/pagemap.go index 175e62e..d782fbd 100644 --- a/internal/domain/contenthub/entity/pagemap.go +++ b/internal/domain/contenthub/entity/pagemap.go @@ -328,14 +328,16 @@ func (m *PageMap) getPagesWithTerm(q pageMapQueryPagesBelowPath) contenthub.Page doctree.LockTypeNone, paths.AddTrailingSlash(q.Path), func(s string, n *WeightedTermTreeNode) (bool, error) { - if include(n.term.Page) { - pas = append(pas, n.term) + p, found := n.getPage() + if found && include(p) { + pas = append(pas, p) } return false, nil }, ) if err != nil { + m.Log.Errorf("getPagesWithTerm error: %v", err) return nil, err } @@ -344,6 +346,7 @@ func (m *PageMap) getPagesWithTerm(q pageMapQueryPagesBelowPath) contenthub.Page return pas, nil }) if err != nil { + m.Log.Errorf("getPagesWithTerm: %v", err) panic(err) } diff --git a/internal/domain/contenthub/entity/term.go b/internal/domain/contenthub/entity/term.go index 1c7c070..ca06742 100644 --- a/internal/domain/contenthub/entity/term.go +++ b/internal/domain/contenthub/entity/term.go @@ -94,7 +94,7 @@ func (t *Term) Assemble(pages *doctree.NodeShiftTree[*PageTreesNode], } entries.Insert(key, &WeightedTermTreeNode{ - PageTreesNode: term, + PageTreesNode: newPageTreesNode(ps), term: &ordinalWeightPage{Page: tp.(*TermPage), ordinal: i, weight: weight}, }) } diff --git a/internal/domain/contenthub/type.go b/internal/domain/contenthub/type.go index 542d075..97580d4 100644 --- a/internal/domain/contenthub/type.go +++ b/internal/domain/contenthub/type.go @@ -47,8 +47,6 @@ type PageInfo interface { Buffer() *bytes.Buffer } -//TODO remove unless we need to expose those kind for other domains - const ( KindPage = "page" KindHome = "home" diff --git a/internal/domain/site/entity/navigation.go b/internal/domain/site/entity/navigation.go index ba744a8..7a8be80 100644 --- a/internal/domain/site/entity/navigation.go +++ b/internal/domain/site/entity/navigation.go @@ -1,7 +1,7 @@ package entity import ( - "github.com/gohugonet/hugoverse/internal/domain/contenthub" + "fmt" "github.com/gohugonet/hugoverse/internal/domain/site/valueobject" "github.com/gohugonet/hugoverse/pkg/compare" "sort" @@ -29,7 +29,7 @@ type OrderedTaxonomy []OrderedTaxonomyEntry // getOneOPage returns one page in the taxonomy, // nil if there is none. -func (t OrderedTaxonomy) getOneOPage() contenthub.Page { +func (t OrderedTaxonomy) getOneOPage() *WeightedPage { if len(t) == 0 { return nil } @@ -38,18 +38,38 @@ func (t OrderedTaxonomy) getOneOPage() contenthub.Page { // WeightedPages is a list of Pages with their corresponding (and relative) weight // [{Weight: 30, Page: *1}, {Weight: 40, Page: *2}] -type WeightedPages []contenthub.OrdinalWeightPage +type WeightedPages []*WeightedPage // Page will return the Page (of Kind taxonomyList) that represents this set // of pages. This method will panic if p is empty, as that should never happen. -func (p WeightedPages) Page() contenthub.Page { +func (p WeightedPages) Page() *WeightedPage { if len(p) == 0 { - panic("WeightedPages is empty") + _ = fmt.Errorf("page called on empty WeightedPages") + return nil } return p[0] } +func (p WeightedPages) Pages() []*WeightedPage { + pages := make([]*WeightedPage, len(p)) + for i := range p { + pages[i] = p[i] + } + return pages +} + +func (p WeightedPages) Sort() { sort.Stable(p) } +func (p WeightedPages) Count() int { + return len(p) +} + +func (p WeightedPages) Len() int { return len(p) } +func (p WeightedPages) Swap(i, j int) { p[i], p[j] = p[j], p[i] } +func (p WeightedPages) Less(i, j int) bool { + return p[i].Weight() <= p[j].Weight() +} + // OrderedTaxonomyEntry is similar to an element of a Taxonomy, but with the key embedded (as name) // e.g: {Name: Technology, WeightedPages: TaxonomyPages} type OrderedTaxonomyEntry struct { @@ -57,6 +77,16 @@ type OrderedTaxonomyEntry struct { WeightedPages } +// Count returns the count the pages in this taxonomy. +func (ie OrderedTaxonomyEntry) Count() int { + return ie.WeightedPages.Count() +} + +// Term returns the name given to this taxonomy. +func (ie OrderedTaxonomyEntry) Term() string { + return ie.Name +} + // ByCount returns an ordered taxonomy sorted by # of pages per key. // If taxonomies have the same # of pages, sort them alphabetical func (i Taxonomy) ByCount() OrderedTaxonomy { diff --git a/internal/domain/site/entity/page.go b/internal/domain/site/entity/page.go index c693bc6..ed33cb7 100644 --- a/internal/domain/site/entity/page.go +++ b/internal/domain/site/entity/page.go @@ -10,16 +10,12 @@ import ( bp "github.com/gohugonet/hugoverse/pkg/bufferpool" "github.com/gohugonet/hugoverse/pkg/herrors" "path" + "sync" ) -type Pages []Page - -// Len returns the number of pages in the list. -func (p Pages) Len() int { - return len(p) -} -func (p Pages) String() string { - return fmt.Sprintf("Pages(%d)", len(p)) +type WeightedPage struct { + *Page + contenthub.OrdinalWeightPage } type Page struct { @@ -36,6 +32,9 @@ type Page struct { *Site resources []resources.Resource + + dataInit sync.Once + data Data } func (p *Page) processResources(pageSources []contenthub.PageSource) error { diff --git a/internal/domain/site/entity/pagecache.go b/internal/domain/site/entity/pagecache.go index 6a1539d..cb7c9b4 100644 --- a/internal/domain/site/entity/pagecache.go +++ b/internal/domain/site/entity/pagecache.go @@ -18,6 +18,55 @@ func (c *pageCache) clear() { c.m = make(map[string][]pageCacheEntry) } +func (c *pageCache) get(key string, apply func(p Pages), pageLists ...Pages) (Pages, bool) { + return c.getP(key, func(p *Pages) { + if apply != nil { + apply(*p) + } + }, pageLists...) +} + +func (c *pageCache) getP(key string, apply func(p *Pages), pageLists ...Pages) (Pages, bool) { + c.RLock() + if cached, ok := c.m[key]; ok { + for _, entry := range cached { + if entry.matches(pageLists) { + c.RUnlock() + return entry.out, true + } + } + } + c.RUnlock() + + c.Lock() + defer c.Unlock() + + // double-check + if cached, ok := c.m[key]; ok { + for _, entry := range cached { + if entry.matches(pageLists) { + return entry.out, true + } + } + } + + p := pageLists[0] + pagesCopy := append(Pages(nil), p...) + + if apply != nil { + apply(&pagesCopy) + } + + entry := pageCacheEntry{in: pageLists, out: pagesCopy} + if v, ok := c.m[key]; ok { + c.m[key] = append(v, entry) + } else { + c.m[key] = []pageCacheEntry{entry} + } + + return pagesCopy, false +} + type pageCacheEntry struct { in []Pages out Pages diff --git a/internal/domain/site/entity/pagedata.go b/internal/domain/site/entity/pagedata.go new file mode 100644 index 0000000..02e1737 --- /dev/null +++ b/internal/domain/site/entity/pagedata.go @@ -0,0 +1,21 @@ +package entity + +import "fmt" + +type Data map[string]any + +func (d Data) Pages() Pages { + v, found := d["pages"] + if !found { + return nil + } + + switch vv := v.(type) { + case []*Page: + return vv + case func() []*Page: + return vv() + default: + panic(fmt.Sprintf("%T is not Pages", v)) + } +} diff --git a/internal/domain/site/entity/pagefields.go b/internal/domain/site/entity/pagefields.go index de1bdf1..b01a451 100644 --- a/internal/domain/site/entity/pagefields.go +++ b/internal/domain/site/entity/pagefields.go @@ -56,6 +56,20 @@ func (s *sites) First() *Site { return s.site } +func (p *Page) Data() any { + p.dataInit.Do(func() { + p.data = make(Data) + + if p.Kind() == contenthub.KindPage { + return + } + + p.data["pages"] = p.Pages + }) + + return p.data +} + func (p *Page) Pages() []*Page { ps := p.Page.Pages(p.Site.Language.CurrentLanguageIndex()) if ps == nil { @@ -81,10 +95,10 @@ func (p *Page) Translations() []*Page { func (p *Page) sitePages(ps contenthub.Pages) []*Page { var pages []*Page for _, cp := range ps { - np := p.clone() - - np.Page = cp - np.PageOutput = p.getPageOutput(cp) + np, err := p.Site.sitePage(cp) + if err != nil { + continue + } pages = append(pages, np) } @@ -109,10 +123,6 @@ func (p *Page) getPageOutput(chp contenthub.Page) contenthub.PageOutput { panic("no page output") } -func (p *Page) Data() map[string]any { - return map[string]any{} //TODO for sitemap -} - func (p *Page) Content() (any, error) { return p.PageOutput.Content() } diff --git a/internal/domain/site/entity/pages.go b/internal/domain/site/entity/pages.go new file mode 100644 index 0000000..c0b1028 --- /dev/null +++ b/internal/domain/site/entity/pages.go @@ -0,0 +1,63 @@ +package entity + +import ( + "fmt" + "sort" +) + +type Pages []*Page + +// Len returns the number of pages in the list. +func (p Pages) Len() int { + return len(p) +} +func (p Pages) String() string { + return fmt.Sprintf("Pages(%d)", len(p)) +} + +func (p Pages) ByLastmod() Pages { + const key = "pageSort.ByLastmod" + + date := func(p1, p2 *Page) bool { + return p1.Lastmod().Unix() < p2.Lastmod().Unix() + } + + pages, _ := spc.get(key, pageBy(date).Sort, p) + + return pages +} + +func (p Pages) Reverse() Pages { + const key = "pageSort.Reverse" + + reverseFunc := func(pages Pages) { + for i, j := 0, len(pages)-1; i < j; i, j = i+1, j-1 { + pages[i], pages[j] = pages[j], pages[i] + } + } + + pages, _ := spc.get(key, reverseFunc, p) + + return pages +} + +type pageBy func(p1, p2 *Page) bool + +func (by pageBy) Sort(pages Pages) { + ps := &pageSorter{ + pages: pages, + by: by, // The Sort method's receiver is the function (closure) that defines the sort order. + } + sort.Stable(ps) +} + +type pageSorter struct { + pages Pages + by pageBy +} + +func (ps *pageSorter) Len() int { return len(ps.pages) } +func (ps *pageSorter) Swap(i, j int) { ps.pages[i], ps.pages[j] = ps.pages[j], ps.pages[i] } + +// Less is part of sort.Interface. It is implemented by calling the "by" closure in the sorter. +func (ps *pageSorter) Less(i, j int) bool { return ps.by(ps.pages[i], ps.pages[j]) } diff --git a/internal/domain/site/entity/sitefields.go b/internal/domain/site/entity/sitefields.go index a9cea45..75d531d 100644 --- a/internal/domain/site/entity/sitefields.go +++ b/internal/domain/site/entity/sitefields.go @@ -105,3 +105,12 @@ func (s *Site) sitePage(p contenthub.Page) (*Page, error) { return sp, nil } + +func (s *Site) siteWeightedPage(p contenthub.OrdinalWeightPage) (*WeightedPage, error) { + sp, err := s.sitePage(p) + if err != nil { + return nil, err + } + + return &WeightedPage{sp, p}, nil +} diff --git a/internal/domain/site/entity/siteinit.go b/internal/domain/site/entity/siteinit.go index adaba34..3d1ae8b 100644 --- a/internal/domain/site/entity/siteinit.go +++ b/internal/domain/site/entity/siteinit.go @@ -64,17 +64,25 @@ func (s *Site) PrepareLazyLoads() { tax = make(Taxonomy) s.Navigation.taxonomies[taxonomy] = tax } + + wp, err := s.siteWeightedPage(page) + if err != nil { + return err + } + weightedPages := tax[term] if weightedPages == nil { - weightedPages = WeightedPages{page} + weightedPages = WeightedPages{wp} tax[term] = weightedPages + } else { + tax[term] = append(weightedPages, wp) } - tax[term] = append(weightedPages, page) return nil }); err != nil { return nil, err } + return s.Navigation.taxonomies, nil }) } diff --git a/internal/interfaces/api/handler/upload.go b/internal/interfaces/api/handler/upload.go index a78fa92..e7876bd 100644 --- a/internal/interfaces/api/handler/upload.go +++ b/internal/interfaces/api/handler/upload.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "time" ) // StoreFiles stores file uploads at paths like /YYYY/MM/filename.ext @@ -68,10 +69,10 @@ func (s *Handler) StoreFiles(req *http.Request) (map[string]string, error) { // support later : check if file at path exists, if so, add timestamp to file absPath := filepath.Join(uploadDir, filename) - //if _, err := os.Stat(absPath); os.IsExist(err) { - // filename = fmt.Sprintf("%d-%s", time.Now().Unix(), filename) - // absPath = filepath.Join(uploadDir, filename) - //} + if _, err := os.Stat(absPath); err == nil { + filename = fmt.Sprintf("%d-%s", time.Now().UnixMilli(), filename) + absPath = filepath.Join(uploadDir, filename) + } // save to disk // (TODO: or check if S3 credentials exist, & save to cloud) diff --git a/internal/interfaces/cli/vercurr.go b/internal/interfaces/cli/vercurr.go index 93313b1..c7ed72c 100644 --- a/internal/interfaces/cli/vercurr.go +++ b/internal/interfaces/cli/vercurr.go @@ -3,6 +3,6 @@ package cli var CurrentVersion = Version{ Major: 0, Minor: 0, - PatchLevel: 13, + PatchLevel: 14, Suffix: "", } diff --git a/manifest.json b/manifest.json index ff95399..a08d11e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "version": "0.0.13", + "version": "0.0.14", "name": "Hugoverse", "description": "Headless CMS for Hugo", "author": "sunwei",