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:
|
entryPoints:
|
||||||
entry-points:
|
- source: index.html
|
||||||
- source: index.html
|
pipeline:
|
||||||
pipeline:
|
- layout: layouts/base.html
|
||||||
- layout: layouts/base
|
- target: dist/index.html
|
||||||
- target: dist/index.html
|
- source: posts/{id}.md
|
||||||
- source: 'posts/{post-name}.md'
|
pipeline:
|
||||||
pipeline:
|
- plugin: markdown
|
||||||
- plugin: markdown
|
- layout: layouts/base.html
|
||||||
- layout: layouts/base
|
- target: dist/posts/{id}/index.html
|
||||||
- layout: layouts/post.html
|
|
||||||
- target: 'dist/posts/{post-name}/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