// 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" } }