Files
smartbar/cmd/build/main.go
2025-08-31 12:46:36 -06:00

413 lines
11 KiB
Go

package main
import (
"bytes"
"flag"
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"smartbar/internal/config"
)
// Expose page size to templates via CSS variables inline on <body>.
// Use HTMLAttr so it renders as an attribute safely.
func pageSizeStyleAttr(widthIn, heightIn float64) template.HTMLAttr {
return template.HTMLAttr(
`style="--page-w: ` + floatToIn(widthIn) + `; --page-h: ` + floatToIn(heightIn) + `;"`,
)
}
func floatToIn(v float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4fin", v), "0"), ".")
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// extractAndScopeCSS extracts <style> blocks from HTML content and scopes them to a page-specific selector
func extractAndScopeCSS(content string, pageID string) (string, string) {
styleRegex := regexp.MustCompile(`(?s)<style[^>]*>(.*?)</style>`)
matches := styleRegex.FindAllStringSubmatch(content, -1)
var scopedCSS strings.Builder
contentWithoutCSS := content
// Remove all <style> blocks from content
contentWithoutCSS = styleRegex.ReplaceAllString(contentWithoutCSS, "")
// Process each CSS block and scope it
for _, match := range matches {
cssContent := strings.TrimSpace(match[1])
if cssContent == "" {
continue
}
// Scope the CSS rules to the page
scopedCSS.WriteString(scopeCSS(cssContent, pageID))
scopedCSS.WriteString("\n\n")
}
return contentWithoutCSS, scopedCSS.String()
}
// scopeCSS wraps CSS rules with a page-specific selector
func scopeCSS(css string, pageID string) string {
// Simple CSS scoping - wrap all rules with the page selector
lines := strings.Split(css, "\n")
var scopedLines []string
var inRule bool
var ruleBuffer strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip comments and empty lines
if trimmed == "" || strings.HasPrefix(trimmed, "/*") {
scopedLines = append(scopedLines, line)
continue
}
// Handle @-rules (like @font-face, @media) - don't scope these
if strings.HasPrefix(trimmed, "@") {
if inRule {
// Finish current rule first
scopedLines = append(scopedLines, scopeRuleBuffer(ruleBuffer.String(), pageID))
ruleBuffer.Reset()
inRule = false
}
scopedLines = append(scopedLines, line)
continue
}
// Detect start of CSS rule
if strings.Contains(trimmed, "{") && !inRule {
inRule = true
ruleBuffer.WriteString(line)
ruleBuffer.WriteString("\n")
} else if inRule {
ruleBuffer.WriteString(line)
ruleBuffer.WriteString("\n")
// Check if rule ends
if strings.Contains(trimmed, "}") {
scopedLines = append(scopedLines, scopeRuleBuffer(ruleBuffer.String(), pageID))
ruleBuffer.Reset()
inRule = false
}
} else {
// Standalone line - might be a selector
if strings.Contains(trimmed, "{") {
ruleBuffer.WriteString(line)
ruleBuffer.WriteString("\n")
inRule = true
} else {
scopedLines = append(scopedLines, line)
}
}
}
// Handle any remaining rule
if inRule && ruleBuffer.Len() > 0 {
scopedLines = append(scopedLines, scopeRuleBuffer(ruleBuffer.String(), pageID))
}
return strings.Join(scopedLines, "\n")
}
func scopeRuleBuffer(rule string, pageID string) string {
lines := strings.Split(strings.TrimSpace(rule), "\n")
if len(lines) == 0 {
return rule
}
// Find the selector line (first line before {)
for i, line := range lines {
if strings.Contains(line, "{") {
// Extract selector part
parts := strings.Split(line, "{")
if len(parts) >= 2 {
selector := strings.TrimSpace(parts[0])
rest := "{" + strings.Join(parts[1:], "{")
// Scope the selector
scopedSelector := scopeSelector(selector, pageID)
lines[i] = scopedSelector + " " + rest
}
break
}
}
return strings.Join(lines, "\n")
}
func scopeSelector(selector string, pageID string) string {
// Split multiple selectors by comma
selectors := strings.Split(selector, ",")
var scopedSelectors []string
for _, sel := range selectors {
sel = strings.TrimSpace(sel)
if sel == "" {
continue
}
// Don't scope selectors that already include the page ID or are body/html
if strings.Contains(sel, "#"+pageID) || sel == "body" || sel == "html" {
scopedSelectors = append(scopedSelectors, sel)
} else {
// Scope to page
scopedSelectors = append(scopedSelectors, "#"+pageID+" "+sel)
}
}
return strings.Join(scopedSelectors, ", ")
}
type PageData struct {
Title string
Content template.HTML
}
type IndexPage struct {
Name string
Href string
}
type PrintPage struct {
Content template.HTML
PageID string
ScopedCSS template.HTML
}
type TwoUpSheet struct {
Left PrintPage
Right *PrintPage
}
func mustReadFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return string(data)
}
func writeFile(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
}
func main() {
// Required flags
var pagesDir string
var outDir string
var templatesDir string
var orderPath string
flagSet := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
flagSet.StringVar(&pagesDir, "pages", "", "path to pages directory (required)")
flagSet.StringVar(&outDir, "out", "", "path to output directory (required)")
flagSet.StringVar(&templatesDir, "templates", "", "path to templates directory (required)")
flagSet.StringVar(&orderPath, "order", "pages.yaml", "path to YAML file listing page order (optional)")
_ = flagSet.Parse(os.Args[1:])
if pagesDir == "" || outDir == "" || templatesDir == "" {
fmt.Fprintln(os.Stderr, "error: --pages, --out, and --templates are required")
os.Exit(2)
}
baseTplPath := filepath.Join(templatesDir, "base.gohtml")
baseTpl := template.Must(template.New("base").Parse(mustReadFile(baseTplPath)))
var pageFiles []string
if data, err := os.ReadFile(orderPath); err == nil {
ordered, perr := parseYAMLListOfStrings(string(data))
if perr != nil {
panic(perr)
}
for _, rel := range ordered {
abs := filepath.Join(pagesDir, rel)
if _, statErr := os.Stat(abs); statErr == nil {
pageFiles = append(pageFiles, abs)
} else {
fmt.Fprintf(os.Stderr, "warning: listed page not found: %s\n", rel)
}
}
}
if len(pageFiles) == 0 {
filepath.WalkDir(pagesDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(d.Name(), ".html") {
pageFiles = append(pageFiles, path)
}
return nil
})
sort.Strings(pageFiles)
}
// Build each page
for _, pagePath := range pageFiles {
raw := mustReadFile(pagePath)
// Trim outer whitespace but keep inner spacing
content := strings.TrimSpace(raw)
// Title based on filename
base := filepath.Base(pagePath)
title := strings.TrimSuffix(base, filepath.Ext(base))
var out bytes.Buffer
if err := baseTpl.Execute(&out, map[string]any{
"Title": title,
"Content": template.HTML(content),
"PageSizeAttr": pageSizeStyleAttr(config.PageWidthIn, config.PageHeightIn),
}); err != nil {
panic(err)
}
rel, _ := filepath.Rel(pagesDir, pagePath)
outPath := filepath.Join(outDir, rel)
if err := writeFile(outPath, out.Bytes()); err != nil {
panic(err)
}
}
// Build index.html via template
indexPath := filepath.Join(outDir, "index.html")
indexTplPath := filepath.Join(templatesDir, "index.gohtml")
indexTpl := template.Must(template.New("index").Parse(mustReadFile(indexTplPath)))
var pages []IndexPage
for _, pagePath := range pageFiles {
rel, _ := filepath.Rel(pagesDir, pagePath)
pages = append(pages, IndexPage{
Name: filepath.Base(pagePath),
// Index is written to outDir, and the compiled pages are also in outDir.
// So iframe src should be relative to index location, not prefixed with outDir again.
Href: filepath.ToSlash(rel),
})
}
var outIndex bytes.Buffer
if err := indexTpl.Execute(&outIndex, map[string]any{"Pages": pages}); err != nil {
panic(err)
}
if err := writeFile(indexPath, outIndex.Bytes()); err != nil {
panic(err)
}
// Build print.html containing all pages one after another for PDF
printTplPath := filepath.Join(templatesDir, "print.gohtml")
printTpl := template.Must(template.New("print").Parse(mustReadFile(printTplPath)))
var printPages []PrintPage
var allScopedCSS strings.Builder
for _, pagePath := range pageFiles {
content := strings.TrimSpace(mustReadFile(pagePath))
// Generate unique page ID based on filename
base := filepath.Base(pagePath)
pageID := strings.TrimSuffix(base, filepath.Ext(base)) + "-page"
// Extract and scope CSS
contentWithoutCSS, scopedCSS := extractAndScopeCSS(content, pageID)
// Collect all scoped CSS
if scopedCSS != "" {
allScopedCSS.WriteString(scopedCSS)
allScopedCSS.WriteString("\n")
}
printPages = append(printPages, PrintPage{
Content: template.HTML(contentWithoutCSS),
PageID: pageID,
ScopedCSS: template.HTML(scopedCSS),
})
}
var outPrint bytes.Buffer
if err := printTpl.Execute(&outPrint, map[string]any{
"Pages": printPages,
"PageSizeAttr": pageSizeStyleAttr(config.PageWidthIn, config.PageHeightIn),
}); err != nil {
panic(err)
}
// Inject scoped CSS into the output
cssToInject := allScopedCSS.String()
finalOutput := strings.ReplaceAll(
outPrint.String(),
"PLACEHOLDER_FOR_SCOPED_CSS",
cssToInject,
)
if err := writeFile(filepath.Join(outDir, "print.html"), []byte(finalOutput)); err != nil {
panic(err)
}
// Build 2-up print HTML (letter pages with two half-letter pages side by side)
var sheets []TwoUpSheet
for i := 0; i < len(printPages); i += 2 {
left := printPages[i]
var right *PrintPage
if i+1 < len(printPages) {
r := printPages[i+1]
right = &r
}
sheets = append(sheets, TwoUpSheet{Left: left, Right: right})
}
print2TplPath := filepath.Join(templatesDir, "print_2up.gohtml")
print2Tpl := template.Must(template.New("print2up").Parse(mustReadFile(print2TplPath)))
var outPrint2 bytes.Buffer
if err := print2Tpl.Execute(&outPrint2, map[string]any{
"Sheets": sheets,
}); err != nil {
panic(err)
}
// Inject scoped CSS into the 2-up output
finalOutput2Up := strings.ReplaceAll(
outPrint2.String(),
"PLACEHOLDER_FOR_SCOPED_CSS",
cssToInject,
)
if err := writeFile(filepath.Join(outDir, "print_2up.html"), []byte(finalOutput2Up)); err != nil {
panic(err)
}
}
// parseYAMLListOfStrings expects a YAML document that is a top-level sequence of strings.
// We avoid adding a dependency to keep the builder lightweight; this is minimal and strict.
func parseYAMLListOfStrings(src string) ([]string, error) {
var out []string
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "-") {
item := strings.TrimSpace(strings.TrimPrefix(line, "-"))
if item != "" {
out = append(out, item)
}
} else {
// For simplicity, only support list form
return nil, fmt.Errorf("invalid yaml line (expect '- item'): %s", line)
}
}
return out, nil
}