// Package export renders the current buffer to a self-contained, printable
// HTML document in the markdown-doc-kit house style (TASK-021).
//
// The output bakes in the vendored doc.css (go:embed — no runtime dependency
// on the external kit) plus the user's configured fonts, so opening it in a
// browser and choosing Print → Save as PDF yields a clean US-Letter PDF:
// a centered cover page from the leading `# Title`, page breaks per the kit's
// print rules, and black-on-white when printed.
package export
import (
"bytes"
_ "embed"
"fmt"
"html"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
//go:embed assets/doc.css
var docCSS string
// Options controls a single export. Theme is a kit data-theme name (use
// MapTheme to convert a glint theme); the three font fields are CSS
// font-family stacks that override doc.css's --font-* tokens.
type Options struct {
Title string
Theme string
FontDisplay string
FontBody string
FontMono string
Cover bool // wrap a leading H1 (+ subtitle) into a print cover page
PrintURLs bool // surface link URLs when printing in black-and-white
}
var md = goldmark.New(goldmark.WithExtensions(extension.GFM))
// Document renders markdown into a complete, self-contained HTML document.
func Document(markdown string, opts Options) (string, error) {
body, err := renderBody(markdown, opts)
if err != nil {
return "", err
}
theme := opts.Theme
if theme == "" {
theme = "flexoki"
}
cls := "doc"
if !opts.PrintURLs {
cls += " no-print-urls"
}
var b strings.Builder
b.WriteString(`` + "\n")
fmt.Fprintf(&b, ``+"\n", html.EscapeString(theme))
b.WriteString("
\n")
b.WriteString(`` + "\n")
b.WriteString(`` + "\n")
fmt.Fprintf(&b, "%s\n", html.EscapeString(opts.Title))
if faces := embedFontFaces(opts); faces != "" {
fmt.Fprintf(&b, "\n", faces)
}
fmt.Fprintf(&b, "\n", docCSS)
fmt.Fprintf(&b, "\n", fontOverride(opts))
b.WriteString(`` + "\n")
b.WriteString("\n\n")
fmt.Fprintf(&b, ``+"\n", cls)
b.WriteString(body)
b.WriteString("\n\n\n\n")
return b.String(), nil
}
// renderBody converts markdown to the document inner HTML, applying the kit's
// postprocessing conventions (task-list classes, {.class} heading suffixes,
// cover wrap).
func renderBody(markdown string, opts Options) (string, error) {
var buf bytes.Buffer
if err := md.Convert([]byte(stripFrontmatter(markdown)), &buf); err != nil {
return "", err
}
out := buf.String()
out = markTaskListItems(out)
out = applyHeadingClasses(out)
if opts.Cover {
out = wrapCover(out)
}
return out, nil
}
// fontOverride is a :root block that overrides doc.css's three font tokens
// with the configured stacks — so the export never depends on the kit's
// licensed faces.
func fontOverride(opts Options) string {
return fmt.Sprintf(
":root{--font-display:%s;--font-body:%s;--font-mono:%s;}",
cssFontStack(opts.FontDisplay), cssFontStack(opts.FontBody), cssFontStack(opts.FontMono),
)
}
// cssFontStack normalizes a CSS font-family stack so multi-word family names
// are quoted. A bare `Untitled Sans` substituted through a custom property
// fails to match in some engines (notably WebKit); quoting makes it robust.
// Already-quoted segments and single-token names (incl. generics like
// sans-serif, system-ui) are left as-is.
func cssFontStack(stack string) string {
if strings.TrimSpace(stack) == "" {
return ""
}
var out []string
for _, seg := range strings.Split(stack, ",") {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
if !strings.HasPrefix(seg, `"`) && !strings.HasPrefix(seg, `'`) && strings.ContainsAny(seg, " \t") {
seg = `"` + seg + `"`
}
out = append(out, seg)
}
return strings.Join(out, ", ")
}
// stripFrontmatter drops a leading YAML frontmatter block (--- ... ---) so it
// doesn't render as a horizontal rule and stray text.
func stripFrontmatter(s string) string {
if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
return s
}
rest := s[strings.IndexByte(s, '\n')+1:]
lines := strings.Split(rest, "\n")
for i, ln := range lines {
t := strings.TrimRight(ln, "\r")
if t == "---" || t == "..." {
return strings.Join(lines[i+1:], "\n")
}
}
return s // no closing fence — leave as-is
}
// markTaskListItems adds the task-list-item class to list items that hold a
// checkbox, matching the kit's postprocess + doc.css selector.
func markTaskListItems(html string) string {
return strings.ReplaceAll(html, "
(.*?)\s*\{\.([\w-]+)\}`)
// applyHeadingClasses turns a trailing `{.class}` suffix on a heading into a
// real class attribute (e.g. `## Section {.page-break}`), matching the kit.
func applyHeadingClasses(html string) string {
return headingClassRE.ReplaceAllString(html, `$2`)
}
var leadingH1RE = regexp.MustCompile(`(?s)^\s*(
.*?
)\n?(\s*
.*?
\n?)?`)
// wrapCover wraps a leading H1 (and an immediately following subtitle
// paragraph) in a `.cover` div, matching the kit's postprocess so doc.css's
// print rules render it as a centered cover page.
func wrapCover(html string) string {
loc := leadingH1RE.FindStringSubmatchIndex(html)
if loc == nil {
return html
}
matched := html[loc[0]:loc[1]]
inner := strings.TrimRight(matched, "\n")
return `
` + "\n" + inner + "\n
\n" + html[loc[1]:]
}
// MapTheme maps a glint theme name to the closest kit data-theme. Unknown or
// empty names fall back to flexoki (light).
func MapTheme(glintTheme string) string {
switch glintTheme {
case "flexoki-light":
return "flexoki"
case "flexoki-dark":
return "flexoki-dark"
case "charm":
return "flexoki-dark"
default:
return "flexoki"
}
}