M builder.go => builder.go +43 -7
@@ 96,25 96,56 @@ func Build(root string, args *Args) (*Config, error) {
)
}
- // 6. Copy all assets
+ // 6. Generate redirected pages + update the index
+ if len(cfg.Redirects) != 0 {
+ redirectGemtextTemplate := fb.Templates.Lookup("redirect-gemtext")
+ redirectWebTemplate := fb.Templates.Lookup("redirect-html")
+ if redirectGemtextTemplate == nil || redirectWebTemplate == nil {
+ err = fmt.Errorf(
+ "there are some redirects in the configuration, but the `redirect-gemtext` or `redirect-html` templates seem to be missing from the `.templates` folder.\n" +
+ "Cannot continue generating the capsule\n" +
+ "Reminder: the gemtext template is for the gemini template rendering, and the html template is for rendering what the gemtext template rendered " +
+ "(following the standard rendering flow)",
+ )
+ domain.L.Debugf("Registered redirects: %v", cfg.Redirects)
+
+ return cfg, err
+ }
+
+ for src, redirect := range cfg.Redirects {
+ err = LoadRedirect(
+ index,
+ &fb,
+ redirectGemtextTemplate,
+ mainSection,
+ src,
+ redirect,
+ )
+ if err != nil {
+ return cfg, err
+ }
+ }
+ }
+
+ // 7. Copy all assets
totalAssets, err := fb.HandleAssets(assets)
if err != nil {
return cfg, err
}
- // 7. Render the entire file tree into the output folder, including all pages, feeds, files, media assets, etc.
+ // 8. Render the entire file tree into the output folder, including all pages, feeds, files, media assets, etc.
totalFiles, err := fb.HandleSection(&mainSection)
if err != nil {
return cfg, err
}
- // 7a. Check on (and wait for) the media processing pipeline
+ // 8a. Check on (and wait for) the media processing pipeline
err = fb.ThumbnailPool.WaitForFinish()
if err != nil {
return cfg, err
}
- // 8. Analyse the file index for broken content
+ // 9. Analyse the file index for broken content
for _, link := range index.FoundLinks {
if strings.HasPrefix(link.PointsTo, "/") {
if _, ok := index.Files[filepath.Join(root, link.PointsTo)]; !ok {
@@ 129,15 160,16 @@ func Build(root string, args *Args) (*Config, error) {
}
}
- // 8a. Display the broken links
+ // 9a. Display the broken links
if deadLinkCount := len(index.DeadLinks); deadLinkCount != 0 {
domain.L.Warnf("%d dead links were found", deadLinkCount)
for _, deadLink := range index.DeadLinks {
- if deadLink.Line != -1 {
+ if _, ok := cfg.Redirects[deadLink.From.RelativePath]; ok {
+ domain.L.Warnf("- [Redirect from %s] %s", deadLink.From.RelativePath, deadLink.PointsTo)
+ } else if deadLink.Line != -1 {
domain.L.Warnf("- [%s:%d] %s", deadLink.From.RelativePath, deadLink.Line, deadLink.PointsTo)
} else {
- // No way to get the line no from the markdown parser yet
domain.L.Warnf("- [%s] %s", deadLink.From.RelativePath, deadLink.PointsTo)
}
}
@@ 184,6 216,10 @@ func (f *FsBuilder) GetPath(permalink string, output bool) string {
}
}
+func (f *FsBuilder) GetCapsuleRoot() string {
+ return f.InputPath
+}
+
// RunPostBuildCommand runs a single post-build command, starting from the capsule root.
func (f *FsBuilder) RunPostBuildCommand(globalContext *context.Context, command string) error {
args, err := shlex.Split(command)
M config.go => config.go +1 -0
@@ 41,6 41,7 @@ func LoadConfig(path string) (*Config, error) {
GlobalTimeout: 15,
CommandTimeout: 5,
},
+ Redirects: map[string]Redirect{},
}
mergo.Merge(&outputCfg, cfg, mergo.WithOverride)
if len(outputCfg.PostBuildCommands) > 0 {
M domain/types.go => domain/types.go +2 -0
@@ 126,6 126,7 @@ type ContentFileType int
const (
ContentFileTypeUnused = iota
+ ContentFileTypeRaw
ContentFileTypeMedia
ContentFileTypeThumbnail
)
@@ 135,6 136,7 @@ type ContentFile struct {
Path string
RelativePath string // relative to content root
RelativeOutputPath string // If unset, will use the RelativePath
+ Raw []byte // for files that don't originate from the FS
Kind ContentFileType
}
M handler.go => handler.go +13 -7
@@ 334,14 334,20 @@ func (f *FsBuilder) HandleFile(file *domain.ContentFile) error {
to = f.GetPath(file.RelativeOutputPath, true)
}
- domain.L.Debugf("Copying file from `%s` to `%s`", from, to)
-
- src, err := os.Open(from)
- if err != nil {
- domain.L.Debugf("Failed to find source file at %s", from)
- return err
+ var src io.Reader
+ if file.Kind == domain.ContentFileTypeRaw {
+ domain.L.Debugf("Writing raw file to `%s`", to)
+ src = bytes.NewReader(file.Raw)
+ } else {
+ domain.L.Debugf("Copying file from `%s` to `%s`", from, to)
+ fileSrc, err := os.Open(from)
+ if err != nil {
+ domain.L.Debugf("Failed to find source file at %s", from)
+ return err
+ }
+ defer fileSrc.Close()
+ src = fileSrc
}
- defer src.Close()
dest, err := os.Create(to)
if err != nil {
M loader.go => loader.go +101 -0
@@ 8,6 8,7 @@ import (
"os"
"path/filepath"
"strings"
+ "text/template"
"git.sr.ht/~artemis/cap/domain"
"git.sr.ht/~artemis/cap/parsing"
@@ 395,3 396,103 @@ func LoadFeed(s *domain.Section, path string) (*domain.Feed, error) {
return feed, nil
}
+
+func LoadRedirect(
+ idx *domain.Index,
+ fb *FsBuilder,
+ template *template.Template,
+ rootSection domain.Section,
+ src string,
+ redirect Redirect,
+) error {
+ capsuleSrc := fb.GetPath(src, false)
+ // Cleaning the relative path
+ relativeToRoot, _ := filepath.Rel(fb.GetCapsuleRoot(), capsuleSrc)
+
+ contentBuf := strings.Builder{}
+ redirectTarget := redirect.To
+ if !strings.HasPrefix(redirectTarget, "/") {
+ redirectTarget = "/" + redirectTarget
+ }
+ err := template.Execute(&contentBuf, Redirect{
+ Title: redirect.Title,
+ To: redirectTarget,
+ })
+ if err != nil {
+ return err
+ }
+ // Building a non-fs page
+ page := domain.Page{
+ Slug: strings.TrimSuffix(filepath.Base(capsuleSrc), filepath.Ext(capsuleSrc)),
+ RelativePath: relativeToRoot,
+ Permalink: "/" + strings.TrimSuffix(relativeToRoot, filepath.Ext(relativeToRoot)),
+ Title: redirect.Title,
+ Content: strings.TrimSpace(contentBuf.String()),
+ Format: domain.FileFormatGemini,
+ LinksCount: 1,
+ Template: "redirect-html",
+ }
+ result, err := Parsers[page.Format].Parse(idx, &page)
+ if err != nil {
+ return err
+ }
+ page.RenderedContent = result.Content
+ // Building a non-fs file
+ gemtextFile := domain.ContentFile{
+ Path: capsuleSrc,
+ RelativePath: relativeToRoot,
+ Kind: domain.ContentFileTypeRaw,
+ Raw: []byte(page.Content),
+ }
+
+ // Exploring the section tree to get to the right section level,
+ // creating new sections as needed
+ pointedSection := &rootSection
+ targetPermalink := filepath.Dir(page.Permalink)
+ permalinkChunk := targetPermalink
+ for {
+ permalinkChunk = strings.TrimPrefix(permalinkChunk, pointedSection.Slug+"/")
+ if pointedSection.Permalink == targetPermalink {
+ break
+ }
+
+ permalinkChunks := strings.Split(permalinkChunk, string(os.PathSeparator))
+ if len(permalinkChunks) == 0 {
+ return fmt.Errorf("failed to find the next root slug for the redirect page\n"+
+ "Pointed section permalink: %s\n"+
+ "Current permalink chunk: %s",
+ pointedSection.Permalink,
+ permalinkChunk,
+ )
+ }
+ nextSectionSlug := permalinkChunks[0]
+ nextSection := util.Find(
+ pointedSection.Subsections,
+ func(s *domain.Section) bool {
+ return s.Slug == nextSectionSlug
+ },
+ )
+ if nextSection == nil {
+ newSection := &domain.Section{
+ Path: filepath.Join(pointedSection.Path, nextSectionSlug),
+ RelativePath: filepath.Join(pointedSection.RelativePath, nextSectionSlug),
+ Slug: nextSectionSlug,
+ Permalink: filepath.Join(pointedSection.Permalink, nextSectionSlug),
+ Parent: pointedSection,
+ }
+ pointedSection.Subsections = append(pointedSection.Subsections, newSection)
+ pointedSection = newSection
+ nextSection = &newSection
+ } else {
+ pointedSection = *nextSection
+ }
+ permalinkChunk = strings.TrimPrefix(permalinkChunk, (*nextSection).Permalink)
+ }
+
+ // should work, to verify
+ // TODO: to change during the builder overhaul
+ pointedSection.Pages = append(pointedSection.Pages, &page)
+ pointedSection.Files = append(pointedSection.Files, &gemtextFile)
+
+ return nil
+}
M types.go => types.go +8 -0
@@ 58,6 58,11 @@ type PostBuildConfig struct {
CommandTimeout time.Duration `toml:"command_timeout"`
}
+type Redirect struct {
+ To string `toml:"to"`
+ Title string `toml:"title"`
+}
+
// Config stores the capsule's configuration, including metadata, media optimization info, post-build commands, etc.
type Config struct {
Capsule CapsuleConfig
@@ 65,6 70,9 @@ type Config struct {
PostBuildCommands []string `toml:"post_build_commands"`
PostBuild PostBuildConfig `toml:"post_build"`
FoldersToIgnore []string `toml:"ignore"`
+ // of the format "~notebook/recipe.gmi" = "~notebook/recipes/index.gmi" for example
+ // all paths are relative to the capsule root
+ Redirects map[string]Redirect `toml:"redirects"`
}
// WatchCMD contains the subcommand's flags and arguments
A util/list.go => util/list.go +24 -0
@@ 0,0 1,24 @@
+package util
+
+// Includes takes an input string and a list of strings, and checks if the input string is included in the list of strings.
+func Includes(compared string, opts ...string) bool {
+ for _, opt := range opts {
+ if opt == compared {
+ return true
+ }
+ }
+ return false
+}
+
+// Find takes a slice and a finder function as input,
+// and returns a ref to the found object if any matched,
+// nil otherwise
+func Find[K any](haystack []K, finder func(K) bool) *K {
+ for _, needle := range haystack {
+ if finder(needle) {
+ return &needle
+ }
+ }
+
+ return nil
+}
M util/util.go => util/util.go +0 -10
@@ 12,16 12,6 @@ func PathGetSegments(path string) []string {
return strings.Split(strings.TrimPrefix(path, "/"), string(os.PathSeparator))
}
-// Includes takes an input string and a list of strings, and checks if the input string is included in the list of strings.
-func Includes(compared string, opts ...string) bool {
- for _, opt := range opts {
- if opt == compared {
- return true
- }
- }
- return false
-}
-
// FindCapsuleRoot checks the current folder and all its parents for a cap(1) configuration file,
// and if found, returns its absolute path.
func FindCapsuleRoot(configFile string, path string) (bool, string) {