package main import ( "bytes" "fmt" "net/http" "os" "path/filepath" "regexp" "strings" "github.com/nikolalohinski/gonja/v2" "github.com/yuin/goldmark" ) // markdownAnnotation matches comments like var markdownAnnotation = regexp.MustCompile(``) // renderMarkdown reads a Markdown file, extracts the template:block annotation, // converts the Markdown to HTML, and injects it into the specified Jinja2 block. func renderMarkdown(w http.ResponseWriter, r *http.Request, mdPath, projectDir string) { content, err := readMarkdownFile(mdPath) if err != nil { http.Error(w, "Markdown not found", http.StatusNotFound) return } templateName, blockName, body := parseAnnotation(string(content)) if templateName == "" { http.Error(w, "Missing template annotation. Add to your Markdown.", http.StatusBadRequest) return } html := markdownToHTML([]byte(body)) // Resolve the template path (e.g., "_base" -> "project/_base.jinja2") tmplPath := resolveTemplateName(templateName, projectDir) if !fileExists(tmplPath) { http.Error(w, fmt.Sprintf("Template %s not found", templateName), http.StatusNotFound) return } // Build a child template that extends the target and overrides the block child := "{% extends \"" + templateName + ".jinja2\" %}\n\n" + "{% block " + blockName + " %}\n" + html + "\n{% endblock %}" result, err := renderMarkdownTemplate(child, tmplPath, r) if err != nil { http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(result)) } // readMarkdownFile reads and returns the file content. func readMarkdownFile(path string) ([]byte, error) { return os.ReadFile(path) } // parseAnnotation extracts the template and block names from the annotation // and returns them along with the remaining body (annotation line stripped). func parseAnnotation(content string) (template, block, body string) { matches := markdownAnnotation.FindStringSubmatch(content) if matches == nil { return "", "", content } template = matches[1] block = matches[2] // Remove the annotation line from the body body = markdownAnnotation.ReplaceAllString(content, "") // Trim leading/trailing whitespace from body body = strings.TrimSpace(body) return template, block, body } // markdownToHTML converts Markdown text to HTML using goldmark. func markdownToHTML(input []byte) string { md := goldmark.New() var buf bytes.Buffer if err := md.Convert(input, &buf); err != nil { return "
" + escHTML(string(input)) + "" } return buf.String() } // renderMarkdownTemplate renders a template string using Gonja, with extends // resolved relative to the given base path's directory. func renderMarkdownTemplate(templateContent, templatePath string, r *http.Request) (string, error) { ctx := buildRequestContext(r, nil) // Write the dynamic template next to the target template so Gonja can // resolve relative extends/include paths correctly. tmplDir := filepath.Dir(templatePath) tmpName := ".md_dyn_" + strings.ReplaceAll(filepath.Base(templatePath), ".", "_") + ".jinja2" tmpPath := filepath.Join(tmplDir, tmpName) if err := os.WriteFile(tmpPath, []byte(templateContent), 0644); err != nil { return "", fmt.Errorf("writing dynamic template: %w", err) } defer os.Remove(tmpPath) tmpl, err := gonja.FromFile(tmpPath) if err != nil { return "", fmt.Errorf("parsing template: %w", err) } result, err := tmpl.ExecuteToString(ctx) if err != nil { return "", fmt.Errorf("executing template: %w", err) } return result, nil } // resolveTemplateName converts a template name to a filesystem path. // e.g., "_base" with projectDir="/app/project" -> "/app/project/_base.jinja2" func resolveTemplateName(name, projectDir string) string { return fmt.Sprintf("%s/%s.jinja2", projectDir, name) } // escHTML escapes basic HTML characters. func escHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\"", """) return s }