From 38b10176813fe3a6214ffedec8ec536419c2cdb1 Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Mon, 26 Dec 2022 02:57:25 +0100 Subject: [PATCH] Minimal working prototype --- README.md | 57 ++++++++++++++ cabret.go | 25 ++++++ cmd/cabret/main.go | 12 ++- config/config.go | 32 ++++---- examples/basic/Cabretfile.yaml | 24 +++--- examples/basic/index.html | 5 ++ examples/basic/layouts/base.html | 6 +- exec/exec.go | 116 +++++++++++++++++++++++++++ go.mod | 10 +++ go.sum | 33 ++++++++ operation/layout.go | 73 +++++++++++++++++ operation/layout/html.go | 31 ++++++++ operation/layout/layout.go | 5 ++ operation/layout/text.go | 31 ++++++++ operation/markdown.go | 46 +++++++++++ operation/target.go | 36 +++++++++ path/path.go | 60 ++++++++++---- path/path_test.go | 131 +++++++++++++++++++++++++++---- util/util.go | 9 +++ walk.go | 45 +++++++++++ 20 files changed, 724 insertions(+), 63 deletions(-) create mode 100644 README.md create mode 100644 cabret.go create mode 100644 exec/exec.go create mode 100644 operation/layout.go create mode 100644 operation/layout/html.go create mode 100644 operation/layout/layout.go create mode 100644 operation/layout/text.go create mode 100644 operation/markdown.go create mode 100644 operation/target.go create mode 100644 util/util.go create mode 100644 walk.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..b246c5f --- /dev/null +++ b/README.md @@ -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 + +``` \ No newline at end of file diff --git a/cabret.go b/cabret.go new file mode 100644 index 0000000..635fbd1 --- /dev/null +++ b/cabret.go @@ -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) +} diff --git a/cmd/cabret/main.go b/cmd/cabret/main.go index 81461fe..c5d2bb6 100644 --- a/cmd/cabret/main.go +++ b/cmd/cabret/main.go @@ -3,17 +3,23 @@ package main import ( "log" - "github.com/alecthomas/repr" "github.com/aziis98/cabret/config" + "github.com/aziis98/cabret/exec" ) func main() { + log.SetFlags(0) log.Printf("Rendering current project") - site, err := config.ParseCabretfile("./Cabretfile.yaml") + site, err := config.ReadCabretfile("./Cabretfile.yaml") if err != nil { log.Fatal(err) } - repr.Println(site) + // repr.Println(site) + + if err := exec.Execute(site); err != nil { + log.Fatal(err) + } + } diff --git a/config/config.go b/config/config.go index 6756fa1..52e22aa 100644 --- a/config/config.go +++ b/config/config.go @@ -8,34 +8,38 @@ import ( // Operation is an enum of various operations type Operation struct { - Layout string `yaml:"layout,omitempty"` - Target string `yaml:"target,omitempty"` - Plugin string `yaml:"plugin,omitempty"` + Layout string `yaml:",omitempty"` + Target string `yaml:",omitempty"` + Plugin string `yaml:",omitempty"` + + Options map[string]any `yaml:",omitempty"` } type EntryPoint struct { - Source string `yaml:"source"` - Pipeline []Operation `yaml:"pipeline"` + Source string `yaml:",omitempty"` + Pipeline []Operation `yaml:",omitempty"` } -type Config struct { - Output string `yaml:"output,omitempty"` +type Options struct { + Excludes []string `yaml:",omitempty"` + // Include []string `yaml:",omitempty"` + Output string `yaml:",omitempty"` } -// Site has some configuration for the -type Site struct { - Config Config `yaml:"config,omitempty"` - EntryPoints []EntryPoint `yaml:"entry-points"` +// Cabretfile has some configuration for the +type Cabretfile struct { + Options Options `yaml:",omitempty"` + EntryPoints []*EntryPoint `yaml:"entryPoints"` } -func ParseCabretfile(file string) (*Site, error) { +func ReadCabretfile(file string) (*Cabretfile, error) { f, err := os.Open(file) if err != nil { return nil, err } - site := new(Site) - if err := yaml.NewDecoder(f).Decode(&site); err != nil { + site := new(Cabretfile) + if err := yaml.NewDecoder(f).Decode(site); err != nil { return nil, err } diff --git a/examples/basic/Cabretfile.yaml b/examples/basic/Cabretfile.yaml index ec0ba66..f9a3fb7 100644 --- a/examples/basic/Cabretfile.yaml +++ b/examples/basic/Cabretfile.yaml @@ -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 + + diff --git a/examples/basic/index.html b/examples/basic/index.html index e69de29..bf2bf55 100644 --- a/examples/basic/index.html +++ b/examples/basic/index.html @@ -0,0 +1,5 @@ +

My Website

+ +

+ 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! +

diff --git a/examples/basic/layouts/base.html b/examples/basic/layouts/base.html index 5b17b73..3b2bd13 100644 --- a/examples/basic/layouts/base.html +++ b/examples/basic/layouts/base.html @@ -4,7 +4,9 @@ - {{ .Metadata.Title }} + {{- if .Title -}}{{ .Title }} | {{ else }}{{ end -}} My Blog - + + {{- .Content -}} + diff --git a/exec/exec.go b/exec/exec.go new file mode 100644 index 0000000..d27a1e1 --- /dev/null +++ b/exec/exec.go @@ -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 +} diff --git a/go.mod b/go.mod index 6977c63..dbf1f14 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,19 @@ go 1.19 require ( github.com/alecthomas/participle/v2 v2.0.0-beta.5 // indirect github.com/alecthomas/repr v0.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect github.com/google/go-cmp v0.5.9 // indirect + github.com/iancoleman/strcase v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/rakyll/gotest v0.0.6 // indirect github.com/yuin/goldmark v1.5.3 // indirect + github.com/yuin/goldmark-meta v1.1.0 // indirect + golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 // indirect + golang.org/x/sys v0.3.0 // indirect + golang.org/x/text v0.5.0 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools v2.2.0+incompatible // indirect ) diff --git a/go.sum b/go.sum index bec3c0f..04050a3 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,46 @@ github.com/alecthomas/participle/v2 v2.0.0-beta.5 h1:y6dsSYVb1G5eK6mgmy+BgI3Mw35 github.com/alecthomas/participle/v2 v2.0.0-beta.5/go.mod h1:RC764t6n4L8D8ITAJv0qdokritYSNR3wV5cVwmIEaMM= github.com/alecthomas/repr v0.1.1 h1:87P60cSmareLAxMc4Hro0r2RBY4ROm0dYwkJNpS4pPs= github.com/alecthomas/repr v0.1.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rakyll/gotest v0.0.6 h1:hBTqkO3jiuwYW/M9gL4bu0oTYcm8J6knQAAPUsJsz1I= +github.com/rakyll/gotest v0.0.6/go.mod h1:SkoesdNCWmiD4R2dljIUcfSnNdVZ12y8qK4ojDkc2Sc= github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M= github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= +github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= +golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 h1:5oN1Pz/eDhCpbMbLstvIPa0b/BEQo6g6nwV3pLjfM6w= +golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= diff --git a/operation/layout.go b/operation/layout.go new file mode 100644 index 0000000..3f26eef --- /dev/null +++ b/operation/layout.go @@ -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 +} diff --git a/operation/layout/html.go b/operation/layout/html.go new file mode 100644 index 0000000..b253e56 --- /dev/null +++ b/operation/layout/html.go @@ -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 +} diff --git a/operation/layout/layout.go b/operation/layout/layout.go new file mode 100644 index 0000000..edd2c10 --- /dev/null +++ b/operation/layout/layout.go @@ -0,0 +1,5 @@ +package layout + +type Template interface { + Render(ctx map[string]any) ([]byte, error) +} diff --git a/operation/layout/text.go b/operation/layout/text.go new file mode 100644 index 0000000..566112b --- /dev/null +++ b/operation/layout/text.go @@ -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 +} diff --git a/operation/markdown.go b/operation/markdown.go new file mode 100644 index 0000000..b8b222a --- /dev/null +++ b/operation/markdown.go @@ -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 +} diff --git a/operation/target.go b/operation/target.go new file mode 100644 index 0000000..e5e401b --- /dev/null +++ b/operation/target.go @@ -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 +} diff --git a/path/path.go b/path/path.go index 37db14d..1ee9639 100644 --- a/path/path.go +++ b/path/path.go @@ -9,20 +9,34 @@ import ( "github.com/alecthomas/participle/v2/lexer" ) +// FullMatch represents the complete match in a match result context map +const FullMatch = "*" + type PathPart interface { regex() string } -type PathSlash struct { - Value string `"/"` +type PathLiteral struct { + LongAny bool `parser:" @'**'"` + ShortAny bool `parser:"| @'*' "` + Slash bool `parser:"| @'/' "` } -func (p PathSlash) regex() string { - return "/" +func (p PathLiteral) regex() string { + switch { + case p.Slash: + return "/" + case p.ShortAny: + return "([^/]+?)" + case p.LongAny: + return "(.+?)" + default: + panic("illegal enum state") + } } type PathShortPattern struct { - Name string `"{" @Ident "}"` + Name string `parser:"'{' @Ident '}'"` } func (p PathShortPattern) regex() string { @@ -30,7 +44,7 @@ func (p PathShortPattern) regex() string { } type PathLongPattern struct { - Name string `"{{" @Ident "}}"` + Name string `parser:"'{{' @Ident '}}'"` } func (p PathLongPattern) regex() string { @@ -38,18 +52,18 @@ func (p PathLongPattern) regex() string { } type PathString struct { - Value string `@Ident` + Value string `parser:"@Ident"` } func (p PathString) regex() string { return regexp.QuoteMeta(p.Value) } -type PathPattern struct { - Parts []PathPart `@@+` +type Pattern struct { + Parts []PathPart `parser:"@@+"` } -func (pp PathPattern) regex() string { +func (pp Pattern) regex() string { sb := &strings.Builder{} for _, p := range pp.Parts { @@ -59,7 +73,7 @@ func (pp PathPattern) regex() string { return sb.String() } -func (pp PathPattern) Match(s string) (bool, map[string]string, error) { +func (pp Pattern) Match(s string) (bool, map[string]string, error) { r, err := regexp.Compile("^" + pp.regex() + "$") if err != nil { return false, nil, err @@ -72,15 +86,19 @@ func (pp PathPattern) Match(s string) (bool, map[string]string, error) { ctx := map[string]string{} for i, name := range r.SubexpNames() { - ctx[name] = ms[i] + if name != "" { + ctx[name] = ms[i] + } } + ctx[FullMatch] = ms[0] + return true, ctx, nil } -var parser = participle.MustBuild[PathPattern]( +var parser = participle.MustBuild[Pattern]( participle.Union[PathPart]( - &PathSlash{}, + &PathLiteral{}, &PathLongPattern{}, &PathShortPattern{}, &PathString{}, @@ -88,6 +106,8 @@ var parser = participle.MustBuild[PathPattern]( participle.Lexer( lexer.MustSimple([]lexer.SimpleRule{ {Name: "Slash", Pattern: `/`}, + {Name: "LongAny", Pattern: `\*\*`}, + {Name: "ShortAny", Pattern: `\*`}, {Name: "PatternLongOpen", Pattern: `{{`}, {Name: "PatternLongClose", Pattern: `}}`}, {Name: "PatternShortOpen", Pattern: `{`}, @@ -97,11 +117,19 @@ var parser = participle.MustBuild[PathPattern]( ), ) -func ParsePattern(path string) (*PathPattern, error) { +func RenderTemplate(tmpl string, ctx map[string]string) string { + s := tmpl + for k, v := range ctx { + s = strings.ReplaceAll(s, "{"+k+"}", v) + } + return s +} + +func ParsePattern(path string) (*Pattern, error) { return parser.ParseString("", path) } -func MustParsePattern(path string) *PathPattern { +func MustParsePattern(path string) *Pattern { p, err := ParsePattern(path) if err != nil { panic(err) diff --git a/path/path_test.go b/path/path_test.go index dbda3e1..7edb0a4 100644 --- a/path/path_test.go +++ b/path/path_test.go @@ -7,53 +7,124 @@ import ( "gotest.tools/assert" ) -func Test1(t *testing.T) { +func TestPath(t *testing.T) { p, err := path.ParsePattern("/foo/bar") assert.NilError(t, err) - assert.DeepEqual(t, p, &path.PathPattern{ + assert.DeepEqual(t, p, &path.Pattern{ Parts: []path.PathPart{ - &path.PathSlash{}, &path.PathString{"foo"}, &path.PathSlash{}, &path.PathString{"bar"}, + &path.PathLiteral{Slash: true}, + &path.PathString{"foo"}, + &path.PathLiteral{Slash: true}, + &path.PathString{"bar"}, }, }) } -func Test2(t *testing.T) { +func TestSimplePattern(t *testing.T) { p, err := path.ParsePattern("posts/{name}.md") assert.NilError(t, err) - assert.DeepEqual(t, p, &path.PathPattern{ + assert.DeepEqual(t, p, &path.Pattern{ Parts: []path.PathPart{ - &path.PathString{"posts"}, &path.PathSlash{}, &path.PathShortPattern{"name"}, &path.PathString{".md"}, + &path.PathString{"posts"}, + &path.PathLiteral{Slash: true}, + &path.PathShortPattern{"name"}, + &path.PathString{".md"}, }, }) } -func Test3(t *testing.T) { +func TestSimplePatternWithAny(t *testing.T) { + p, err := path.ParsePattern("posts/*/{name}.md") + + assert.NilError(t, err) + assert.DeepEqual(t, p, &path.Pattern{ + Parts: []path.PathPart{ + &path.PathString{"posts"}, + &path.PathLiteral{Slash: true}, + &path.PathLiteral{ShortAny: true}, + &path.PathLiteral{Slash: true}, + &path.PathShortPattern{"name"}, + &path.PathString{".md"}, + }, + }) + + t.Run("MatchGood", func(t *testing.T) { + m, ctx, err := p.Match("posts/2022/post-1.md") + + assert.NilError(t, err, nil) + assert.Equal(t, m, true) + assert.DeepEqual(t, ctx, map[string]string{ + path.FullMatch: "posts/2022/post-1.md", + "name": "post-1", + }) + }) + + t.Run("MatchBad", func(t *testing.T) { + m, ctx, err := p.Match("posts/2022/12/post-1.md") + + assert.NilError(t, err) + assert.Assert(t, m == false) + assert.Assert(t, ctx == nil) + }) +} + +func TestSimplePatternCombo(t *testing.T) { p, err := path.ParsePattern("posts/{{path}}/{name}.md") assert.NilError(t, err) - assert.DeepEqual(t, p, &path.PathPattern{ + assert.DeepEqual(t, p, &path.Pattern{ Parts: []path.PathPart{ &path.PathString{"posts"}, - &path.PathSlash{}, + &path.PathLiteral{Slash: true}, &path.PathLongPattern{"path"}, - &path.PathSlash{}, + &path.PathLiteral{Slash: true}, &path.PathShortPattern{"name"}, &path.PathString{".md"}, }, }) } -func Test4(t *testing.T) { +func TestPatternCombo(t *testing.T) { + p, err := path.ParsePattern("posts/**/{date}_{slug}.md") + + assert.NilError(t, err) + assert.DeepEqual(t, p, &path.Pattern{ + Parts: []path.PathPart{ + &path.PathString{"posts"}, + &path.PathLiteral{Slash: true}, + &path.PathLiteral{LongAny: true}, + &path.PathLiteral{Slash: true}, + &path.PathShortPattern{"date"}, + &path.PathString{"_"}, + &path.PathShortPattern{"slug"}, + &path.PathString{".md"}, + }, + }) + + t.Run("MatchGood", func(t *testing.T) { + ok, ctx, err := p.Match("posts/a/b/c/2022-12-25_example.md") + + assert.Assert(t, ok) + assert.NilError(t, err) + assert.DeepEqual(t, ctx, map[string]string{ + path.FullMatch: "posts/a/b/c/2022-12-25_example.md", + "date": "2022-12-25", + "slug": "example", + }) + }) +} + +func TestStrangePatternCombo(t *testing.T) { p, err := path.ParsePattern("foo{{path}}/{name}.md") assert.NilError(t, err) - assert.DeepEqual(t, p, &path.PathPattern{ + assert.DeepEqual(t, p, &path.Pattern{ Parts: []path.PathPart{ &path.PathString{"foo"}, &path.PathLongPattern{"path"}, - &path.PathSlash{}, + &path.PathLiteral{Slash: true}, &path.PathShortPattern{"name"}, &path.PathString{".md"}, }, @@ -65,9 +136,37 @@ func Test4(t *testing.T) { assert.NilError(t, err, nil) assert.Equal(t, m, true) assert.DeepEqual(t, ctx, map[string]string{ - "": "foo-1/bar/baz/post-1.md", - "name": "post-1", - "path": "-1/bar/baz", + path.FullMatch: "foo-1/bar/baz/post-1.md", + "name": "post-1", + "path": "-1/bar/baz", + }) + }) +} + +func TestMultipleGroups(t *testing.T) { + p, err := path.ParsePattern("{a}/{b}/{c}") + + assert.NilError(t, err) + assert.DeepEqual(t, p, &path.Pattern{ + Parts: []path.PathPart{ + &path.PathShortPattern{"a"}, + &path.PathLiteral{Slash: true}, + &path.PathShortPattern{"b"}, + &path.PathLiteral{Slash: true}, + &path.PathShortPattern{"c"}, + }, + }) + + t.Run("match", func(t *testing.T) { + m, ctx, err := p.Match("foo/bar/baz") + + assert.NilError(t, err, nil) + assert.Equal(t, m, true) + assert.DeepEqual(t, ctx, map[string]string{ + path.FullMatch: "foo/bar/baz", + "a": "foo", + "b": "bar", + "c": "baz", }) }) } diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..0cb8ba7 --- /dev/null +++ b/util/util.go @@ -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 +} diff --git a/walk.go b/walk.go new file mode 100644 index 0000000..9a82e75 --- /dev/null +++ b/walk.go @@ -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 +}