Minimal working prototype
parent
7d22424cf1
commit
38b1017681
@ -0,0 +1,57 @@
|
||||
# Cabret
|
||||
|
||||
A yaml based static site generator, ideally with the same features as Hugo but with a simpler model.
|
||||
|
||||
```yaml
|
||||
entryPoints:
|
||||
- source: index.html
|
||||
pipeline:
|
||||
- layout: layouts/base.html
|
||||
- target: dist/index.html
|
||||
- source: posts/{id}.md
|
||||
pipeline:
|
||||
- plugin: markdown
|
||||
- layout: layouts/base.html
|
||||
- target: dist/posts/{id}/index.html
|
||||
```
|
||||
|
||||
## ToDo
|
||||
|
||||
### Tags
|
||||
|
||||
A case of fan-in (get all posts and group by tags) and fan-out (generate all tag pages with back-links to posts)
|
||||
|
||||
```yaml
|
||||
entryPoints:
|
||||
...
|
||||
- source: posts/{id}.md
|
||||
pipeline:
|
||||
- plugin: frontmatter
|
||||
- plugin: group
|
||||
metadataKey: tag
|
||||
key: tags
|
||||
pipeline:
|
||||
- layout: layouts/tag.html
|
||||
- layout: layouts/base.html
|
||||
- target: dist/tags/{tag}/index.html # ...{tag}... is the same as "metadataKey" (?)
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
A case of fan-out with (various data leakages)
|
||||
|
||||
```yaml
|
||||
entryPoints:
|
||||
...
|
||||
- pipeline:
|
||||
- plugin: paginate
|
||||
items:
|
||||
pipeline:
|
||||
- source: posts/{id}.md
|
||||
- plugin: frontmatter
|
||||
pageSize: 10
|
||||
metadataKey: page
|
||||
pipeline:
|
||||
- layout: layouts/list.html
|
||||
|
||||
```
|
@ -0,0 +1,25 @@
|
||||
package cabret
|
||||
|
||||
const MatchResult = "MatchResult"
|
||||
|
||||
type Map map[string]any
|
||||
|
||||
type File struct {
|
||||
Path string
|
||||
Content
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
// Type for known content formats is just the mime-type
|
||||
Type string
|
||||
|
||||
// Data is the content of the file
|
||||
Data []byte
|
||||
|
||||
// Metadata is any extra data of the file (e.g. yaml frontmatter) or injected by plugins
|
||||
Metadata Map
|
||||
}
|
||||
|
||||
type Operation interface {
|
||||
Process(content Content) (*Content, error)
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
site:
|
||||
entry-points:
|
||||
- source: index.html
|
||||
pipeline:
|
||||
- layout: layouts/base
|
||||
- target: dist/index.html
|
||||
- source: 'posts/{post-name}.md'
|
||||
pipeline:
|
||||
- plugin: markdown
|
||||
- layout: layouts/base
|
||||
- layout: layouts/post.html
|
||||
- target: 'dist/posts/{post-name}/index.html'
|
||||
entryPoints:
|
||||
- source: index.html
|
||||
pipeline:
|
||||
- layout: layouts/base.html
|
||||
- target: dist/index.html
|
||||
- source: posts/{id}.md
|
||||
pipeline:
|
||||
- plugin: markdown
|
||||
- layout: layouts/base.html
|
||||
- target: dist/posts/{id}/index.html
|
||||
|
||||
|
||||
|
@ -0,0 +1,5 @@
|
||||
<h1>My Website</h1>
|
||||
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Minima, eveniet, dolorum amet, cupiditate quae excepturi aspernatur dolor voluptatem obcaecati ratione quas? Et explicabo illum iure eius porro, dolor quos doloremque!
|
||||
</p>
|
@ -0,0 +1,116 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"log"
|
||||
"mime"
|
||||
"os"
|
||||
gopath "path"
|
||||
|
||||
"github.com/alecthomas/repr"
|
||||
"github.com/aziis98/cabret"
|
||||
"github.com/aziis98/cabret/config"
|
||||
"github.com/aziis98/cabret/operation"
|
||||
"github.com/aziis98/cabret/path"
|
||||
)
|
||||
|
||||
type matchResult struct {
|
||||
file string
|
||||
context map[string]string
|
||||
}
|
||||
|
||||
func BuildOperation(op config.Operation) cabret.Operation {
|
||||
switch {
|
||||
case op.Layout != "":
|
||||
path := op.Layout
|
||||
return &operation.Layout{
|
||||
TemplateFilesPattern: path,
|
||||
Options: op.Options,
|
||||
}
|
||||
case op.Target != "":
|
||||
path := op.Target
|
||||
return &operation.Target{
|
||||
PathTemplate: path,
|
||||
}
|
||||
case op.Plugin == "markdown":
|
||||
return &operation.Markdown{
|
||||
Options: op.Options,
|
||||
}
|
||||
default:
|
||||
log.Fatalf(`invalid operation: %s`, op.Plugin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Execute(cfg *config.Cabretfile) error {
|
||||
files, err := cabret.FindFiles([]string{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// the first index is the entrypoint ID, the second is for the array of matched files for this entrypoint
|
||||
entryPointMatches := make([][]matchResult, len(cfg.EntryPoints))
|
||||
|
||||
// load all files to process
|
||||
for id, ep := range cfg.EntryPoints {
|
||||
pat, err := path.ParsePattern(ep.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matchedFiles := []matchResult{}
|
||||
for _, f := range files {
|
||||
if ok, ctx, _ := pat.Match(f); ok {
|
||||
log.Printf(`[Preload] [EntryPoint %d] Found "%s": %#v`, id, f, ctx)
|
||||
|
||||
matchedFiles = append(matchedFiles, matchResult{f, ctx})
|
||||
}
|
||||
}
|
||||
|
||||
entryPointMatches[id] = matchedFiles
|
||||
}
|
||||
|
||||
// TODO: preload all metadata...
|
||||
|
||||
// process all entrypoints
|
||||
for id, ep := range cfg.EntryPoints {
|
||||
log.Printf(`[EntryPoint %d] starting to process %d file(s)`, id, len(entryPointMatches[id]))
|
||||
|
||||
for _, m := range entryPointMatches[id] {
|
||||
log.Printf(`[EntryPoint %d] ["%s"] reading file`, id, m.file)
|
||||
data, err := os.ReadFile(m.file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content := cabret.Content{
|
||||
Type: mime.TypeByExtension(gopath.Ext(m.file)),
|
||||
Data: data,
|
||||
Metadata: cabret.Map{
|
||||
cabret.MatchResult: m.context,
|
||||
},
|
||||
}
|
||||
|
||||
for i, opConfig := range ep.Pipeline {
|
||||
op := BuildOperation(opConfig)
|
||||
|
||||
log.Printf(`[EntryPoint %d] ["%s"] [Operation(%d)] applying %s`, id, m.file, i, repr.String(op))
|
||||
|
||||
newContent, err := op.Process(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newContent == nil {
|
||||
break
|
||||
}
|
||||
|
||||
// log.Printf(`[EntryPoint %d] ["%s"] [Operation(%d)] [Metadata] %s`, id, m.file, i, repr.String(newContent.Metadata, repr.Indent(" ")))
|
||||
content = *newContent
|
||||
}
|
||||
|
||||
log.Printf(`[EntryPoint %d] ["%s"] done`, id, m.file)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
gopath "path"
|
||||
|
||||
"github.com/alecthomas/repr"
|
||||
"github.com/aziis98/cabret"
|
||||
"github.com/aziis98/cabret/operation/layout"
|
||||
"github.com/aziis98/cabret/util"
|
||||
)
|
||||
|
||||
var HtmlMimeType = mime.TypeByExtension(".html")
|
||||
|
||||
var _ cabret.Operation = Layout{}
|
||||
|
||||
type Layout struct {
|
||||
// TemplateFilesPattern is a comma separated list of unix glob patterns
|
||||
TemplateFilesPattern string
|
||||
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
func (op Layout) Process(content cabret.Content) (*cabret.Content, error) {
|
||||
var tmpl layout.Template
|
||||
|
||||
patterns := strings.Split(op.TemplateFilesPattern, ",")
|
||||
|
||||
tmplFiles := []string{}
|
||||
for _, pat := range patterns {
|
||||
files, err := filepath.Glob(strings.TrimSpace(pat))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmplFiles = append(tmplFiles, files...)
|
||||
}
|
||||
|
||||
log.Printf(`[Layout] template pattern "%s" expanded to %s`, op.TemplateFilesPattern, repr.String(tmplFiles))
|
||||
|
||||
if gopath.Ext(tmplFiles[0]) == ".html" {
|
||||
var err error
|
||||
if tmpl, err = layout.NewHtmlTemplate(tmplFiles...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
if tmpl, err = layout.NewTextTemplate(tmplFiles...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ctx := util.CloneMap(content.Metadata)
|
||||
|
||||
if content.Type == HtmlMimeType {
|
||||
ctx["Content"] = template.HTML(content.Data)
|
||||
} else {
|
||||
ctx["Content"] = content.Data
|
||||
}
|
||||
|
||||
data, err := tmpl.Render(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content.Data = data
|
||||
return &content, nil
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
var _ Template = HtmlTemplate{}
|
||||
|
||||
type HtmlTemplate struct {
|
||||
*template.Template
|
||||
}
|
||||
|
||||
func NewHtmlTemplate(files ...string) (*HtmlTemplate, error) {
|
||||
t, err := template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &HtmlTemplate{t}, nil
|
||||
}
|
||||
|
||||
func (t HtmlTemplate) Render(ctx map[string]any) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
|
||||
if err := t.Template.Execute(&b, ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), nil
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package layout
|
||||
|
||||
type Template interface {
|
||||
Render(ctx map[string]any) ([]byte, error)
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var _ Template = TextTemplate{}
|
||||
|
||||
type TextTemplate struct {
|
||||
*template.Template
|
||||
}
|
||||
|
||||
func NewTextTemplate(files ...string) (*TextTemplate, error) {
|
||||
t, err := template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TextTemplate{t}, nil
|
||||
}
|
||||
|
||||
func (t TextTemplate) Render(ctx map[string]any) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
|
||||
if err := t.Template.Execute(&b, ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), nil
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/aziis98/cabret"
|
||||
"github.com/iancoleman/strcase"
|
||||
"github.com/yuin/goldmark"
|
||||
meta "github.com/yuin/goldmark-meta"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
)
|
||||
|
||||
type Markdown struct {
|
||||
Options map[string]any
|
||||
}
|
||||
|
||||
func (op Markdown) Process(content cabret.Content) (*cabret.Content, error) {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
meta.Meta,
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
context := parser.NewContext()
|
||||
if err := md.Convert(content.Data, &buf, parser.WithContext(context)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
frontmatter := meta.Get(context)
|
||||
for k, v := range frontmatter {
|
||||
content.Metadata[strcase.ToCamel(k)] = v
|
||||
}
|
||||
|
||||
return &cabret.Content{
|
||||
Type: HtmlMimeType,
|
||||
Data: buf.Bytes(),
|
||||
Metadata: content.Metadata,
|
||||
}, nil
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
gopath "path"
|
||||
|
||||
"github.com/aziis98/cabret"
|
||||
"github.com/aziis98/cabret/path"
|
||||
)
|
||||
|
||||
var _ cabret.Operation = Target{}
|
||||
|
||||
type Target struct {
|
||||
PathTemplate string
|
||||
}
|
||||
|
||||
func (op Target) Process(c cabret.Content) (*cabret.Content, error) {
|
||||
mr, ok := c.Metadata[cabret.MatchResult].(map[string]string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(`invalid match result type %T`, c.Metadata[cabret.MatchResult])
|
||||
}
|
||||
|
||||
target := path.RenderTemplate(op.PathTemplate, mr)
|
||||
|
||||
if err := os.MkdirAll(gopath.Dir(target), 0777); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(target, c.Data, 0666); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Metadata["Target"] = op.PathTemplate
|
||||
return &c, nil
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package util
|
||||
|
||||
func CloneMap[K comparable, V any](m1 map[K]V) map[K]V {
|
||||
m2 := map[K]V{}
|
||||
for k, v := range m1 {
|
||||
m2[k] = v
|
||||
}
|
||||
return m2
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package cabret
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/aziis98/cabret/path"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
func FindFiles(excludes []string) ([]string, error) {
|
||||
paths := []string{}
|
||||
|
||||
excludeMatchers := []*path.Pattern{}
|
||||
for _, p := range excludes {
|
||||
m, err := path.ParsePattern(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
excludeMatchers = append(excludeMatchers, m)
|
||||
}
|
||||
|
||||
if err := filepath.Walk(".", func(p string, info fs.FileInfo, err error) error {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
excluded := slices.ContainsFunc(excludeMatchers, func(excludePattern *path.Pattern) bool {
|
||||
ok, _, _ := excludePattern.Match(p)
|
||||
return ok
|
||||
})
|
||||
|
||||
if excluded {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths = append(paths, p)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
Loading…
Reference in New Issue