~artemis/cap

66e889f581f1a0cd93c06dcb661db8653d53989b — Artémis 1 year, 10 months ago d6c1914 v0.3.0
feat/page-redirects: added support for auto-generated page redirects

* the [redirects] config key expects a map of the path for the
  "previous page" to an object with the keys To (pointing to the
  replacement page) and Title (the title to use for the previous page).
  All paths are relative to the capsule root and will be handled as
  absolute from the capsule root.
* the dead link checker now supports checking if the values in To exist,
  using the label `[Redirect from {key}]`
8 files changed, 192 insertions(+), 24 deletions(-)

M builder.go
M config.go
M domain/types.go
M handler.go
M loader.go
M types.go
A util/list.go
M util/util.go
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) {