package docs import ( "context" "database/sql" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "github.com/tim/md-hub-secure/apps/server/internal/markdown" "github.com/tim/md-hub-secure/apps/server/internal/store" ) type Service struct { sourceDir string store *store.ContentStore renderer *markdown.Renderer repo *Repository logger *slog.Logger } type Page struct { Path string Title string Tags []string HTML string Hash string } func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service { return &Service{ sourceDir: sourceDir, store: store, renderer: renderer, repo: repo, logger: logger, } } func (s *Service) SyncSourceDir(ctx context.Context) error { return filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() || filepath.Ext(path) != ".md" { return nil } return s.syncFile(ctx, path) }) } func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) { if err := s.SyncSourceDir(ctx); err != nil { return nil, err } return s.repo.ListDocuments(ctx) } func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) { if err := s.SyncSourceDir(ctx); err != nil { return nil, err } normalized := normalizeRequestPath(requestPath) record, err := s.repo.GetDocumentByPath(ctx, normalized) if err != nil { return nil, err } content, err := s.store.Read(record.CurrentHash) if err != nil { return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err) } rendered, err := s.renderer.Render(content) if err != nil { return nil, fmt.Errorf("render page %s: %w", normalized, err) } return &Page{ Path: record.Path, Title: rendered.Title, Tags: record.Tags, HTML: string(rendered.HTML), Hash: record.CurrentHash, }, nil } func normalizeRequestPath(path string) string { path = strings.Trim(path, "/") if path == "" { return "getting-started.md" } if !strings.HasSuffix(path, ".md") { path += ".md" } return path } func (s *Service) syncFile(ctx context.Context, path string) error { content, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read content file %s: %w", path, err) } rendered, err := s.renderer.Render(content) if err != nil { return fmt.Errorf("render content file %s: %w", path, err) } record, err := s.store.PutBytes(content) if err != nil { return fmt.Errorf("store content file %s: %w", path, err) } relative, err := filepath.Rel(s.sourceDir, path) if err != nil { return fmt.Errorf("compute relative path %s: %w", path, err) } relative = filepath.ToSlash(relative) existing, err := s.repo.GetDocumentByPath(ctx, relative) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } previousHash := "" documentID := "doc:" + strings.TrimSuffix(relative, ".md") if existing != nil { previousHash = existing.CurrentHash documentID = existing.ID if existing.CurrentHash == record.Hash { return nil } } if err := s.repo.PersistDocument(ctx, PersistDocumentInput{ ID: documentID, Path: relative, Title: rendered.Title, Hash: record.Hash, PreviousHash: previousHash, Tags: rendered.Tags, }); err != nil { return err } s.logger.Debug("synced document", "path", relative, "hash", record.Hash) return nil }