feat: PDF/printable HTML export in house style (TASK-021)
4a59f9253b3a45d41e9e5443a0ded8d15a620bf1
humdrum <me@humdrum.me> · 2026-06-29 20:23
parent 1d757ec1
feat: PDF/printable HTML export in house style (TASK-021)
Ctrl+E renders the buffer to a self-contained HTML doc in the
markdown-doc-kit house style and opens it in the browser → Print →
Save as PDF. No external converter required.
- internal/export: goldmark GFM render, YAML frontmatter strip,
cover-wrap, task-list + {.page-break} postprocess, embedded doc.css
(go:embed) with config-driven --font-* token override; MapTheme,
Write/OutputPath/Title/OpenInBrowser.
- config: font_display/body/mono keys, portable defaults (Georgia /
system-ui / ui-monospace), no licensed faces bundled; wizard
preserves them.
- assets/sync.sh re-vendors doc.css from the kit, stripping the
licensed Awke/Untitled Sans/Name Mono references.
- README Export section + help overlay document the path and fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 14 files changed
README.md +29 −0
@@ -62,6 +62,7 @@ | `Alt+Backspace` / `Ctrl+W` | delete the word before the cursor (`Alt+d` deletes the word after) |
| `Ctrl+U` / `Ctrl+K` | delete to start / end of line |
| `Ctrl+Z` / `Ctrl+Y` | undo / redo |
| `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
+| `Ctrl+E` | export a printable HTML document and open it in the browser → Print → Save as PDF (see [Export](#export-pdf)) |
| `Ctrl+P` | toggle the Glamour read preview |
| `Ctrl+F` | fuzzy file picker (with live preview) |
| `Ctrl+G` | find in document (`Enter`/`↓` next, `Shift+Tab`/`↑` prev, `Esc` close) |
@@ -89,6 +90,9 @@ daily_format = "2006-01-02" # Go time layout for daily-note filenames
theme = "auto" # auto | flexoki-light | flexoki-dark | charm (auto detects macOS appearance)
glamour_style = "" # override the preview style; "" follows the theme
spellcheck = "auto" # auto | on | off (auto = on for prose/notes, off for code files)
+font_display = "" # PDF export heading/cover font stack; "" = Georgia serif
+font_body = "" # PDF export body font stack; "" = system-ui sans
+font_mono = "" # PDF export code font stack; "" = ui-monospace
```
**Two roots.** Bare `glint`, `glint -n`, and `-d` operate on the **working
@@ -96,6 +100,31 @@ directory** — the folder you launched from (or `$GLINT_VAULT` if set), so glint
works in whatever repo you're in. **`glint -v`** opens the picker on your
**configured `vault_dir`** from anywhere — set it to your notes vault to reach it
without `cd`-ing there.
+
+## Export (PDF)
+<a id="export-pdf"></a>
+
+`Ctrl+E` exports the current buffer to a clean, printable document in the Humdrum
+house style and opens it in your default browser — there you hit **Print → Save
+as PDF**. No external converter is required: the export is a single
+self-contained `.html` file (the house stylesheet and your rendered Markdown are
+baked in), written next to the source file (`note.md` → `note.html`; an unnamed
+buffer lands in the temp dir). If the browser can't be opened automatically,
+glint prints the file path so you can open it yourself — the export still
+succeeds.
+
+The print layout follows the kit's conventions: a leading `# Title` becomes a
+centered cover page, headings start new pages, `{.page-break}` on a heading
+forces a break, the page is US Letter with 1in margins, and printing collapses
+to black-on-white.
+
+The stylesheet ([`doc.css`](internal/export/assets/doc.css)) is vendored into the
+binary via `go:embed`, so a `brew install glint` on any machine has everything it
+needs. The kit's licensed display/body/mono faces are **not** bundled — the
+export ships portable open/system fallbacks (Georgia / system-ui / ui-monospace)
+and lets you point the three `--font-*` slots at any fonts you have via the
+`font_display` / `font_body` / `font_mono` config keys above. To refresh the
+embedded style after the kit changes, run `internal/export/assets/sync.sh`.
## Themes
- → Spellcheck-with-undercurl-underlines.md +11 −9
@@ -1,10 +1,10 @@
---
id: TASK-020
title: Spellcheck with undercurl underlines
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-29 18:11'
-updated_date: '2026-06-30 01:20'
+updated_date: '2026-06-30 01:45'
labels:
- feature
- release-1
@@ -52,13 +52,13 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 Misspelled prose words show a red undercurl underline in Ghostty; unsupported terminals degrade to plain/no underline
-- [ ] #2 Dictionary is pure-Go embedded (no cgo); binary size increase is modest and 'go build .' + brew formula still work
-- [ ] #3 Code fences, inline code, URLs, wikilinks, link targets, and frontmatter values are never flagged
-- [ ] #4 Adding the word under the cursor appends to ~/.config/glint/dict.txt and removes the underline live; hand-editing dict.txt also works
-- [ ] #5 Spellcheck defaults ON for md/txt/unnamed buffers and OFF for code files, with a config override and a session toggle
-- [ ] #6 Triggering on a flagged word shows ~5 ranked suggestions; picking one replaces the word in place, marks the buffer dirty, and clears the underline
-- [ ] #7 Clicking a flagged (underlined) word with the mouse opens the same suggestion popup; clicking elsewhere just moves the cursor
+- [x] #1 Misspelled prose words show a red undercurl underline in Ghostty; unsupported terminals degrade to plain/no underline
+- [x] #2 Dictionary is pure-Go embedded (no cgo); binary size increase is modest and 'go build .' + brew formula still work
+- [x] #3 Code fences, inline code, URLs, wikilinks, link targets, and frontmatter values are never flagged
+- [x] #4 Adding the word under the cursor appends to ~/.config/glint/dict.txt and removes the underline live; hand-editing dict.txt also works
+- [x] #5 Spellcheck defaults ON for md/txt/unnamed buffers and OFF for code files, with a config override and a session toggle
+- [x] #6 Triggering on a flagged word shows ~5 ranked suggestions; picking one replaces the word in place, marks the buffer dirty, and clears the underline
+- [x] #7 Clicking a flagged (underlined) word with the mouse opens the same suggestion popup; clicking elsewhere just moves the cursor
<!-- AC:END -->
## Implementation Plan
@@ -82,4 +82,6 @@ <!-- SECTION:NOTES:BEGIN -->
Slice A/B/C done (commit 2c0a77e9): internal/spell — 60k embedded dict (freq∩curated, rejects common typos), Known() w/ possessive leniency, BK-tree Suggest() OSA-reranked + freq tie-break, personal dict load/Add at ~/.config/glint/dict.txt. 11 tests green.
Slice D/E done (commits 92f9461e, +theme.Spell): undercurl Span rendering (raw SGR 4:3 + 58:2 color, invariant-preserving, graceful degrade), scanner Prose tagging, spellPass with skip rules (code/inline-code/URL/email/wikilink/link-target/frontmatter/acronym/camelcase/<3-char), word->ok cache, codeFile + toggle gates, AddToDictionary clears cache live. 13 editor tests green.
+
+Slice F/G done (commits 1221e42d, 1d757ec1): config spellcheck=auto|on|off + DictPath, dict loaded at app startup (embedded + personal), Alt+; + mouse-click-on-flagged-word open the popup (suggestions 1-9 / a Add / i Ignore / t Toggle, Esc), toggle-only popup when nothing flagged, glint -c/-h/help-overlay/README/CHANGELOG documented incl. tmux undercurl passthrough. Smoke test confirms App.View emits 4:3+58:2 SGR. All 7 ACs met. Full suite + vet + build green; binary +~250KB (embedded gz).
<!-- SECTION:NOTES:END -->
- → PDF-printable-export-in-the-house-style.md +21 −8
@@ -4,7 +4,7 @@ title: PDF / printable export in the house style
status: "\U0001F7E2 In progress"
assignee: []
created_date: '2026-06-29 20:49'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 03:23'
labels:
- feature
- release-1
@@ -30,19 +30,32 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 A glint command/keybind exports the current buffer to a printable artifact (PDF or print-ready self-contained HTML) in the markdown-doc-kit house style
-- [ ] #2 Output reuses the kit's doc.css + embedded fonts and its print conventions (cover page from '# Title', page-per '## Section', US Letter, black-on-white print)
-- [ ] #3 The chosen path is documented (browser print-to-PDF vs headless converter) and degrades gracefully when an optional external converter is absent
-- [ ] #4 README/help document the export command
-- [ ] #5 Fonts are user-configurable (config keys for display/body/mono), not hard-wired to the kit's bundled Awke/Untitled Sans/Name Mono — glint is distributed to other people who don't have those licensed fonts
-- [ ] #6 Export is portable off this machine: ships sane open/system-font fallbacks by default and works with no personal assets present; any bundled font must be redistributable, otherwise reference by name with fallbacks
-- [ ] #7 The needed kit assets (doc.css + print rules, HTML skeleton, any redistributable fonts) are vendored INTO this repo and embedded in the binary via go:embed — nothing is read from the external _shared-app-kit path at runtime, so a 'brew install glint' on another machine has everything it needs
+- [x] #1 A glint command/keybind exports the current buffer to a printable artifact (PDF or print-ready self-contained HTML) in the markdown-doc-kit house style
+- [x] #2 Output reuses the kit's doc.css + embedded fonts and its print conventions (cover page from '# Title', page-per '## Section', US Letter, black-on-white print)
+- [x] #3 The chosen path is documented (browser print-to-PDF vs headless converter) and degrades gracefully when an optional external converter is absent
+- [x] #4 README/help document the export command
+- [x] #5 Fonts are user-configurable (config keys for display/body/mono), not hard-wired to the kit's bundled Awke/Untitled Sans/Name Mono — glint is distributed to other people who don't have those licensed fonts
+- [x] #6 Export is portable off this machine: ships sane open/system-font fallbacks by default and works with no personal assets present; any bundled font must be redistributable, otherwise reference by name with fallbacks
+- [x] #7 The needed kit assets (doc.css + print rules, HTML skeleton, any redistributable fonts) are vendored INTO this repo and embedded in the binary via go:embed — nothing is read from the external _shared-app-kit path at runtime, so a 'brew install glint' on another machine has everything it needs
<!-- AC:END -->
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. Vendor kit assets into internal/export/assets/: doc.css (neutralize the 3 --font-* :root tokens to open/system fallbacks, strip nothing else — no @font-face present) + sync.sh that re-copies from _shared-app-kit and re-neutralizes. go:embed them.
+2. config: add font_display/font_body/font_mono keys (Config fields + Default + loadFromFile overlay), defaults = Georgia serif / system-ui sans / ui-monospace.
+3. internal/export package (TDD): Document(md, Options)->html pure fn — goldmark GFM render, strip YAML frontmatter, postprocess (cover-wrap leading h1+subtitle p, task-list-item class, {.page-break} heading suffix->class), inject :root font-token override from Options, wrap in article.doc + embedded doc.css. MapTheme(glintTheme)->kit data-theme. ToFile writes <base>.html next to source. OpenInBrowser (darwin open / linux xdg-open / win start).
+4. Wire Ctrl+E in app.handleKey -> export current buffer, write html next to file, open browser, set status. Graceful: if no path (unnamed) save-as first or export to temp.
+5. README + help overlay: document Ctrl+E export, the browser Print->Save-as-PDF path, and the 3 font config keys.
+6. Tests: Document output contains doc.css, cover div, page-break class, configured fonts, mapped theme; frontmatter stripped.
+<!-- SECTION:PLAN:END -->
+
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Portability constraint (user): glint is installed by people who are NOT on this machine and do NOT have the kit's bundled fonts (Awke, Untitled Sans, Name Mono — personal/licensed). So the export must NOT hard-embed those. Fonts must be user-configurable (display/body/mono config keys, reusing the doc.css --font-* tokens) with safe open/system fallbacks (e.g. Georgia / system-ui / ui-monospace) so output looks fine with zero personal assets. Only redistributable fonts may be embedded by default.
Packaging: glint must be self-contained. Copy/build the required kit pieces (doc.css + the @media print rules, the render HTML skeleton, only redistributable fonts) into the glint repo (e.g. internal/export/assets/) and embed them with go:embed so they ship inside the binary. Do NOT depend on /Users/kortum/Developer/Home/_shared-app-kit/markdown-doc-kit at runtime — that path doesn't exist on users' machines. Decide a vendor/sync story (a script that re-copies from the kit on update, or a one-time fork) so the embedded copy can be refreshed when the kit's house style changes; strip the licensed Awke/Untitled Sans/Name Mono @font-face blocks during that copy, leaving the --font-* tokens + fallbacks (ties to the configurable-fonts AC).
+
+Implemented: internal/export package (TDD, 17 tests). Document() renders md via goldmark GFM, strips YAML frontmatter, postprocesses (cover-wrap leading h1+subtitle, task-list-item class, {.page-break} heading suffix->class), embeds vendored doc.css (go:embed) + injects configured --font-* tokens. MapTheme glint->kit. Write()/OutputPath()/Title()/OpenInBrowser() for file+browser. Ctrl+E wired in app.exportPDF (writes <base>.html next to file, opens browser, graceful path-print fallback if open fails). config: font_display/body/mono keys w/ portable defaults (Georgia/system-ui/ui-monospace); wizard preserves them. assets/sync.sh re-vendors doc.css from kit + strips licensed faces (Awke/Untitled Sans/Name Mono). README Export section + help overlay updated. Fixed bug found in real output: heading {.class} regex used (?s) dotall and leaked page-break across to an earlier heading w/ mismatched close tag; removed dotall.
<!-- SECTION:NOTES:END -->
go.mod +1 −1
@@ -12,6 +12,7 @@ github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/muesli/termenv v0.16.0
+ github.com/yuin/goldmark v1.7.13
)
require (
@@ -42,7 +43,6 @@ github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.38.0 // indirect
internal/app/app.go +30 −0
@@ -13,6 +13,7 @@ "time"
"glint/internal/config"
"glint/internal/editor"
+ "glint/internal/export"
"glint/internal/help"
"glint/internal/picker"
"glint/internal/preview"
@@ -275,6 +276,8 @@ }
return a, tea.Quit
case tea.KeyCtrlS:
return a.save()
+ case tea.KeyCtrlE:
+ return a.exportPDF()
case tea.KeyCtrlP:
return a.togglePreview()
case tea.KeyCtrlT:
@@ -380,6 +383,33 @@ return a, nil
}
a.editor.Dirty = false
a.status = "Saved " + a.path
+ return a, nil
+}
+
+// exportPDF renders the buffer to a self-contained, printable HTML document in
+// the house style and opens it in the browser, where the user picks
+// Print → Save as PDF (TASK-021). No external converter is required.
+func (a *App) exportPDF() (tea.Model, tea.Cmd) {
+ md := string(a.editor.Bytes())
+ opts := export.Options{
+ Title: export.Title(a.path, md),
+ Theme: export.MapTheme(a.theme.Name),
+ FontDisplay: a.cfg.FontDisplay,
+ FontBody: a.cfg.FontBody,
+ FontMono: a.cfg.FontMono,
+ Cover: true,
+ }
+ out, err := export.Write(a.path, md, opts)
+ if err != nil {
+ a.status = "Export failed: " + err.Error()
+ return a, nil
+ }
+ if err := export.OpenInBrowser(out); err != nil {
+ // The file is written; only the auto-open failed. Tell the user where.
+ a.status = "Exported " + out + " (open it, then Print → Save as PDF)"
+ return a, nil
+ }
+ a.status = "Exported " + out + " — Print → Save as PDF in the browser"
return a, nil
}
internal/config/config.go +20 −0
@@ -20,6 +20,14 @@ GlamourStyle string `toml:"glamour_style"`
Theme string `toml:"theme"`
InboxDir string `toml:"inbox_dir"`
Spellcheck string `toml:"spellcheck"` // auto | on | off (TASK-020)
+
+ // PDF/printable export fonts (TASK-021). CSS font-family stacks that
+ // override the house-style --font-* tokens. Defaults are portable
+ // open/system faces — glint ships to people without the kit's licensed
+ // fonts, so nothing licensed is baked in.
+ FontDisplay string `toml:"font_display"` // headings / cover
+ FontBody string `toml:"font_body"` // body prose
+ FontMono string `toml:"font_mono"` // code
}
// Default returns the built-in configuration used when no file is present.
@@ -31,6 +39,9 @@ DailySubdir: "Daily",
DailyFormat: "2006-01-02",
Theme: "auto",
Spellcheck: "auto",
+ FontDisplay: "Georgia, \"Times New Roman\", serif",
+ FontBody: "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
+ FontMono: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
}
}
@@ -130,6 +141,15 @@ cfg.InboxDir = fileCfg.InboxDir
}
if fileCfg.Spellcheck != "" {
cfg.Spellcheck = fileCfg.Spellcheck
+ }
+ if fileCfg.FontDisplay != "" {
+ cfg.FontDisplay = fileCfg.FontDisplay
+ }
+ if fileCfg.FontBody != "" {
+ cfg.FontBody = fileCfg.FontBody
+ }
+ if fileCfg.FontMono != "" {
+ cfg.FontMono = fileCfg.FontMono
}
return cfg, nil
}
internal/config/config_test.go +35 −0
@@ -3,6 +3,7 @@
import (
"os"
"path/filepath"
+ "strings"
"testing"
"time"
)
@@ -26,6 +27,40 @@ t.Errorf("VaultDir default = %q, want empty (set vault_dir for `glint -v`)", d.VaultDir)
}
if d.WorkingDir() == "" {
t.Error("WorkingDir should not be empty")
+ }
+}
+
+func TestDefaultExportFonts(t *testing.T) {
+ d := Default()
+ if d.FontDisplay == "" || d.FontBody == "" || d.FontMono == "" {
+ t.Fatalf("export font defaults must be non-empty: display=%q body=%q mono=%q",
+ d.FontDisplay, d.FontBody, d.FontMono)
+ }
+ for _, bad := range []string{"Awke", "Untitled Sans", "Name Mono"} {
+ for _, f := range []string{d.FontDisplay, d.FontBody, d.FontMono} {
+ if strings.Contains(f, bad) {
+ t.Errorf("default font stack contains licensed face %q: %q", bad, f)
+ }
+ }
+ }
+}
+
+func TestLoadFromFileOverlaysFonts(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ body := `font_display = "Charter, serif"
+font_body = "Inter, sans-serif"
+font_mono = "Fira Code, monospace"
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadFromFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.FontDisplay != "Charter, serif" || cfg.FontBody != "Inter, sans-serif" || cfg.FontMono != "Fira Code, monospace" {
+ t.Errorf("font overlay failed: %+v", cfg)
}
}
internal/configui/configui.go +5 −0
@@ -121,6 +121,11 @@ DailySubdir: dailySubdir,
DailyFormat: dailyFmt,
GlamourStyle: glamour,
Spellcheck: spellcheck,
+ // Export fonts aren't part of the wizard; preserve any existing values
+ // so a `glint -c` run doesn't silently drop them.
+ FontDisplay: cfg.FontDisplay,
+ FontBody: cfg.FontBody,
+ FontMono: cfg.FontMono,
}
path := config.Path()
if err := config.Save(out, path); err != nil {
internal/export/assets/doc.css +524 −0
@@ -0,0 +1,524 @@
+/*
+ * doc.css — the Humdrum Preview document house style.
+ *
+ * One stylesheet for documents that come from Kevin. Built on the AppKit
+ * token contract (tokens.css), the AppKit type tokens (display / body /
+ * mono), and the seven themes. Reused across:
+ * - the standalone markdown renderer (index.html)
+ * - the iA Writer template
+ * - the Obsidian export
+ *
+ * It does NOT declare @font-face — the host supplies the fonts (embedded in
+ * the standalone HTML, bundled in the iA template, present in Obsidian). It
+ * only references the three families by name.
+ *
+ * Print target: US Letter, tuned for black-and-white. See @media print.
+ */
+
+/* ─── Tokens: global ──────────────────────────────────────────────── */
+
+:root {
+ --font-display: Georgia, serif;
+ --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
+
+ /* Document measure + rhythm */
+ --doc-measure: 42rem; /* ~68ch comfortable reading column */
+ --doc-pad: 2.5rem;
+ --leading: 1.62;
+ --leading-tight: 1.18;
+
+ --radius-sm: 4px;
+ --radius: 8px;
+}
+
+/* ─── Tokens: themes (from _shared-app-kit/tokens.css) ────────────── */
+
+:root,
+[data-theme="flexoki"] {
+ --bg:#FFFCF0; --bg-card:#F2F0E5; --bg-elevated:#F2F0E5;
+ --border:#E6E4D9; --border-strong:#CECDC3;
+ --text:#100F0F; --text-muted:#6F6E69; --text-faint:#B7B5AC;
+ --text-on-accent:#FFFCF0; --selection:#DDF1E4; --accent:#24837B;
+ --red:#AF3029; --orange:#BC5215; --yellow:#AD8301; --green:#66800B;
+ --cyan:#24837B; --blue:#205EA6; --purple:#5E409D; --pink:#A02F6F;
+}
+[data-theme="flexoki-dark"] {
+ --bg:#100F0F; --bg-card:#1C1B1A; --bg-elevated:#282726;
+ --border:#282726; --border-strong:#403E3C;
+ --text:#CECDC3; --text-muted:#878580; --text-faint:#575653;
+ --text-on-accent:#100F0F; --selection:#122F2C; --accent:#3AA99F;
+ --red:#D14D41; --orange:#DA702C; --yellow:#D0A215; --green:#879A39;
+ --cyan:#3AA99F; --blue:#4385BE; --purple:#8B7EC8; --pink:#CE5D97;
+}
+[data-theme="uchu"] {
+ --bg:#FDFCFB; --bg-card:#FFFFFF; --bg-elevated:#FFFFFF;
+ --border:#E9E7E3; --border-strong:#C9C6BF;
+ --text:#17161B; --text-muted:#605D6A; --text-faint:#A8A5B0;
+ --text-on-accent:#FDFCFB; --selection:#E4DCFC; --accent:#6032E8;
+ --red:#E5484D; --orange:#F76808; --yellow:#FFB224; --green:#30A46C;
+ --cyan:#00A2C7; --blue:#0D74CE; --purple:#6032E8; --pink:#D6409F;
+}
+[data-theme="uchu-dark"] {
+ --bg:#17161B; --bg-card:#1F1D23; --bg-elevated:#2A2730;
+ --border:#2A2730; --border-strong:#3F3B48;
+ --text:#F2EFF7; --text-muted:#A8A5B0; --text-faint:#605D6A;
+ --text-on-accent:#17161B; --selection:#2E1B66; --accent:#A786FF;
+ --red:#FF6369; --orange:#FF8B3E; --yellow:#FFD15E; --green:#63E094;
+ --cyan:#59D3E8; --blue:#6CAEFF; --purple:#A786FF; --pink:#F36DBF;
+}
+/* Humdrum — accent locked to #0F80EA per spec (tokens.css). Color families
+ derived in OKLCH at L0.60 C0.18, hue per family. Same accent in dark. */
+[data-theme="humdrum"] {
+ --bg:#F5F3EE; --bg-card:#FFFFFF; --bg-elevated:#FFFFFF;
+ --border:#E2DFD7; --border-strong:#C3BFB3;
+ --text:#2A2825; --text-muted:#6D6A63; --text-faint:#ADA99F;
+ --text-on-accent:#FFFFFF; --selection:oklch(0.85 0.07 253); --accent:#0F80EA;
+ --red:oklch(0.60 0.180 22); --orange:oklch(0.60 0.180 50);
+ --yellow:oklch(0.60 0.180 85); --green:oklch(0.60 0.180 140);
+ --cyan:oklch(0.60 0.180 205); --blue:#0F80EA;
+ --purple:oklch(0.60 0.180 295); --pink:oklch(0.60 0.180 350);
+}
+[data-theme="humdrum-dark"] {
+ --bg:#1F1D1A; --bg-card:#282622; --bg-elevated:#32302C;
+ --border:#32302C; --border-strong:#4A4740;
+ --text:#E8E5DD; --text-muted:#A8A49B; --text-faint:#6D6A63;
+ --text-on-accent:#FFFFFF; --selection:oklch(0.35 0.13 253); --accent:#0F80EA;
+ --red:oklch(0.60 0.180 22); --orange:oklch(0.60 0.180 50);
+ --yellow:oklch(0.60 0.180 85); --green:oklch(0.60 0.180 140);
+ --cyan:oklch(0.60 0.180 205); --blue:#0F80EA;
+ --purple:oklch(0.60 0.180 295); --pink:oklch(0.60 0.180 350);
+}
+[data-theme="eink"] {
+ --bg:#FFFFFF; --bg-card:#FFFFFF; --bg-elevated:#FFFFFF;
+ --border:#000000; --border-strong:#000000;
+ --text:#000000; --text-muted:#000000; --text-faint:#000000;
+ --text-on-accent:#FFFFFF; --selection:#000000; --accent:#000000;
+ --red:#000; --orange:#000; --yellow:#000; --green:#000;
+ --cyan:#000; --blue:#000; --purple:#000; --pink:#000;
+}
+
+/* ─── The document ────────────────────────────────────────────────── */
+
+.doc {
+ font-family: var(--font-body);
+ font-weight: 400;
+ font-size: 1.0625rem; /* 17px screen baseline */
+ line-height: var(--leading);
+ color: var(--text);
+ background: var(--bg);
+ max-width: var(--doc-measure);
+ margin: 0 auto;
+ padding: var(--doc-pad);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ font-feature-settings: "kern" 1, "liga" 1;
+ hanging-punctuation: first;
+}
+
+.doc > :first-child { margin-top: 0; }
+.doc > :last-child { margin-bottom: 0; }
+
+/* Frontmatter properties, shown under the title when enabled. */
+.doc .doc-props {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.25em 1.2em;
+ margin: 0 0 1.8em;
+ padding: 0.7em 0 0.9em;
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.88rem;
+ break-inside: avoid;
+}
+.doc .doc-props dt {
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ align-self: baseline;
+}
+.doc .doc-props dd { margin: 0; color: var(--text); }
+
+/* Headings — display face */
+.doc h1, .doc h2, .doc h3, .doc h4, .doc h5, .doc h6 {
+ font-family: var(--font-display);
+ line-height: var(--leading-tight);
+ color: var(--text);
+ margin: 2.4em 0 0.6em;
+ text-wrap: balance;
+}
+.doc h1 { font-size: 3rem; font-weight: 700; letter-spacing: -0.01em; margin-top: 0; }
+.doc h2 { font-size: 2.2rem; font-weight: 700; letter-spacing: -0.005em; }
+.doc h3 { font-size: 1.6rem; font-weight: 400; }
+.doc h4 { font-size: 1.18rem; font-weight: 700; font-family: var(--font-body); }
+.doc h5 { font-size: 1rem; font-weight: 700; font-family: var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; }
+.doc h6 { font-size: 0.9rem; font-weight: 500; font-family: var(--font-body); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
+
+/* Cover wrapper (leading H1 + subtitle). On screen it's just a title block
+ with breathing room; in print it becomes a full centered cover page. */
+.doc .cover { margin-bottom: 2.5em; }
+
+/* A subtitle: first paragraph right after h1 reads lighter */
+.doc h1 + p {
+ font-size: 1.2rem;
+ font-weight: 300;
+ color: var(--text-muted);
+ line-height: 1.45;
+}
+
+.doc p { margin: 0 0 1.1em; }
+
+/* Links */
+.doc a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.15em;
+ text-decoration-color: color-mix(in oklab, var(--accent) 45%, transparent);
+}
+.doc a:hover { text-decoration-color: var(--accent); }
+
+/* Emphasis */
+.doc strong { font-weight: 700; }
+.doc em { font-style: italic; }
+.doc mark {
+ background: color-mix(in oklab, var(--yellow) 32%, transparent);
+ color: inherit; padding: 0 0.15em; border-radius: 2px;
+}
+.doc del { color: var(--text-faint); }
+
+/* Lists */
+.doc ul, .doc ol { margin: 0 0 1.1em; padding-left: 1.5em; }
+.doc li { margin: 0.3em 0; }
+.doc li::marker { color: var(--text-muted); }
+.doc ul ul, .doc ol ol, .doc ul ol, .doc ol ul { margin-bottom: 0; }
+
+/* Task lists (GitHub-style checkboxes) */
+.doc li.task-list-item { list-style: none; margin-left: -1.3em; }
+.doc li.task-list-item input { margin-right: 0.5em; accent-color: var(--accent); }
+
+/* Blockquote */
+.doc blockquote {
+ margin: 1.4em 0;
+ padding: 0.2em 0 0.2em 1.2em;
+ border-left: 3px solid var(--accent);
+ color: var(--text-muted);
+ font-style: italic;
+}
+.doc blockquote p:last-child { margin-bottom: 0; }
+
+/* Callouts — `> [!type] Title`. Body fully rendered. Each type is the theme
+ accent, only the hue nudged (and a couple muted), so the set stays one family
+ instead of a rainbow. --hue rotates, --chroma mutes; both relative to accent. */
+.doc .callout {
+ --hue: 0;
+ --chroma: 1;
+ --co: oklch(from var(--accent) l calc(c * var(--chroma)) calc(h + var(--hue)));
+ margin: 1.4em 0;
+ padding: 0.7em 1em;
+ border: 1px solid var(--border);
+ border-left: 4px solid var(--co);
+ border-radius: var(--radius);
+ background: color-mix(in oklab, var(--co) 7%, var(--bg-card));
+ color: var(--text);
+ font-style: normal;
+}
+.doc .callout-title {
+ font-family: var(--font-body);
+ font-weight: 700;
+ color: var(--co);
+ margin-bottom: 0.35em;
+ line-height: 1.3;
+}
+.doc .callout > p:last-child { margin-bottom: 0; }
+/* Per-type --hue / --chroma are set inline by DocKit.styleCallouts so custom
+ callout types get distinct hues too. */
+
+/* Bare callouts (e.g. `> [!nav]` day navigation) are screen chrome — hide them
+ in every output (screen, HTML, PDF, print). */
+.doc .doc-nav { display: none !important; }
+
+/* ─── Book (compiled multi-note document) ─────────────────────────── */
+.doc.book .book-toc { margin: 0 0 2.2em; }
+.doc.book .book-toc h2 { margin-top: 0; }
+.doc.book .book-toc ol { font-size: 1.05rem; line-height: 1.9; padding-left: 1.4em; }
+.doc.book .book-toc a { text-decoration: none; color: var(--text); }
+.doc.book .book-toc a:hover { color: var(--accent); }
+.doc.book .chapter { margin: 0; }
+/* In continuous mode, separate chapters with a rule instead of a page break. */
+.doc.book.continuous .chapter + .chapter {
+ border-top: 1px solid var(--border-strong);
+ margin-top: 2.5em;
+ padding-top: 2em;
+}
+
+/* Base snapshot — a frozen table standing in for an Obsidian Base view.
+ Presented as a labelled card so it clearly reads as a data table. */
+.doc .base-snapshot {
+ margin: 1.6em 0;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg-card);
+ overflow: hidden;
+}
+.doc .base-head {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5em;
+ padding: 0.5em 0.9em;
+ background: color-mix(in oklab, var(--accent) 9%, var(--bg-card));
+ border-bottom: 1px solid var(--border-strong);
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+.doc .base-head .base-name { font-weight: 600; color: var(--text); letter-spacing: 0.02em; }
+.doc .base-head .base-count { margin-left: auto; text-transform: none; letter-spacing: 0; }
+.doc .base-snapshot table { margin: 0; width: 100%; font-size: 0.9rem; }
+.doc .base-snapshot th { background: transparent; }
+.doc .base-snapshot th, .doc .base-snapshot td { padding: 0.45em 0.9em; }
+.doc .base-snapshot tbody tr:nth-child(even) { background: color-mix(in oklab, var(--text) 3.5%, transparent); }
+.doc .base-snapshot tbody tr:last-child td { border-bottom: none; }
+.doc .base-list { margin: 0; padding: 0.4em 0.9em 0.5em 1.9em; }
+.doc .base-list li { margin: 0.2em 0; }
+.doc .base-list li::marker { color: var(--text-faint); }
+
+/* Code */
+.doc code {
+ font-family: var(--font-mono);
+ font-size: 0.88em;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 0.08em 0.35em;
+}
+.doc pre {
+ font-family: var(--font-mono);
+ font-size: 0.84rem;
+ line-height: 1.55;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1em 1.2em;
+ margin: 1.4em 0;
+ overflow-x: auto;
+}
+.doc pre code { background: none; border: none; padding: 0; font-size: inherit; }
+
+/* Tables */
+.doc table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.5em 0;
+ font-size: 0.94rem;
+}
+.doc th, .doc td {
+ text-align: left;
+ padding: 0.5em 0.8em;
+ border-bottom: 1px solid var(--border);
+}
+.doc thead th {
+ font-weight: 700;
+ border-bottom: 2px solid var(--border-strong);
+ background: var(--bg-card);
+}
+.doc tbody tr:last-child td { border-bottom: 1px solid var(--border-strong); }
+
+/* Rules + images + figures */
+.doc hr {
+ border: none;
+ border-top: 1px solid var(--border-strong);
+ margin: 2.5em 0;
+}
+.doc img { max-width: 100%; height: auto; border-radius: var(--radius); }
+.doc figure { margin: 1.6em 0; }
+.doc figcaption { font-size: 0.86rem; color: var(--text-muted); margin-top: 0.5em; text-align: center; }
+
+.doc kbd {
+ font-family: var(--font-mono);
+ font-size: 0.8em;
+ background: var(--bg-card);
+ border: 1px solid var(--border-strong);
+ border-bottom-width: 2px;
+ border-radius: var(--radius-sm);
+ padding: 0.1em 0.4em;
+}
+
+::selection { background: var(--selection); }
+
+/* eink: differentiate by weight + border, never colour */
+[data-theme="eink"] .doc { font-weight: 500; }
+[data-theme="eink"] .doc h1,
+[data-theme="eink"] .doc h2,
+[data-theme="eink"] .doc h3,
+[data-theme="eink"] .doc strong { font-weight: 800; }
+[data-theme="eink"] .doc a { text-decoration-color: #000; }
+[data-theme="eink"] .doc .callout { background: transparent; border: 2px solid #000; }
+[data-theme="eink"] .doc .callout-title { color: #000; }
+[data-theme="eink"] .doc mark { background: transparent; border-bottom: 3px solid #000; border-radius: 0; }
+
+/* ─── Interactive affordances (screen only) ───────────────────────── */
+
+/* Clickable task checkboxes. Force native, clickable inputs so a host theme
+ (e.g. Obsidian's Minimal) can't restyle them into something that swallows
+ the click — that would stop the change event and break write-back. */
+.doc input[type="checkbox"] {
+ -webkit-appearance: checkbox !important;
+ appearance: checkbox !important;
+ pointer-events: auto !important;
+ opacity: 1 !important;
+ position: static !important;
+ cursor: pointer;
+ width: auto;
+ height: auto;
+ margin: 0 0.4em 0 0;
+ background-image: none !important;
+ box-shadow: none !important;
+ -webkit-mask: none !important;
+ mask: none !important;
+}
+/* Kill any host-theme pseudo-element checkmark (e.g. Minimal) that would
+ render on top of the native box. */
+.doc input[type="checkbox"]::before,
+.doc input[type="checkbox"]::after {
+ content: none !important;
+ display: none !important;
+ background: none !important;
+}
+
+/* Drag handles for reorderable list items + table rows. Absolutely placed in
+ the gutter so they never shift the content; fade in on hover. */
+.doc li, .doc td:first-child { position: relative; }
+.doc .dk-h {
+ position: absolute;
+ left: -1.25em;
+ top: 0.15em;
+ width: 1em;
+ text-align: center;
+ color: var(--text-faint);
+ font-family: var(--font-mono);
+ cursor: grab;
+ opacity: 0;
+ transition: opacity 0.12s ease;
+ user-select: none;
+ touch-action: none;
+}
+.doc td:first-child > .dk-h { left: -0.9em; top: 0.5em; }
+.doc li:hover > .dk-h,
+.doc tr:hover .dk-h { opacity: 1; }
+.doc .dk-h:active { cursor: grabbing; }
+.doc li.dk-drag, .doc tr.dk-drag { background: var(--bg-hover, rgba(0,0,0,0.05)); opacity: 0.7; }
+
+[data-theme="eink"] .doc .dk-h { color: #000; }
+
+/* ─── Print: US Letter, black-and-white first ─────────────────────── */
+
+@page {
+ size: letter; /* 8.5 × 11 in */
+ margin: 0.9in 1in;
+}
+
+@media print {
+ html, body { background: #fff !important; }
+
+ /* Drag handles and day-nav never print. */
+ .doc .dk-h, .doc .doc-nav { display: none !important; }
+
+ /* Layout for every printed doc (colour or B&W). */
+ .doc {
+ max-width: none;
+ margin: 0;
+ padding: 0;
+ font-size: 11pt;
+ line-height: 1.5;
+ }
+
+ /* ── Colour mode (.pdf-color): keep the theme's colours, force a white page.
+ Used by the plugin's "Save as PDF" (Humdrum Light). ── */
+ .doc.pdf-color {
+ --bg: #fff;
+ background: #fff;
+ }
+ .doc.pdf-color, .doc.pdf-color * {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+
+ /* ── B&W mode (default browser print): collapse to black ink. ── */
+ .doc:not(.pdf-color) {
+ --bg:#fff; --bg-card:#fff; --bg-elevated:#fff;
+ --text:#000; --text-muted:#1a1a1a; --text-faint:#444;
+ --border:#bbb; --border-strong:#000; --accent:#000;
+ --selection:#000;
+ color: #000;
+ background: #fff;
+ }
+ .doc:not(.pdf-color) a { color: #000; text-decoration-color: #000; }
+ /* Surface link targets in B&W print (skip with .no-print-urls). */
+ .doc:not(.pdf-color) a[href^="http"]::after {
+ content: " (" attr(href) ")";
+ font-size: 0.82em;
+ color: #444;
+ word-break: break-all;
+ }
+ .doc.no-print-urls a::after { content: none; }
+ .doc:not(.pdf-color) code, .doc:not(.pdf-color) pre, .doc:not(.pdf-color) kbd { background: #fff !important; border: 1px solid #999; }
+ .doc:not(.pdf-color) thead th { background: #f2f2f2 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .doc:not(.pdf-color) blockquote { border-left: 3px solid #000; color: #1a1a1a; }
+ .doc:not(.pdf-color) .callout { background: #fff !important; border: 1px solid #000; border-left: 4px solid #000; }
+ .doc:not(.pdf-color) .callout-title { color: #000; }
+ .doc:not(.pdf-color) .base-snapshot { border: 1px solid #000; background: #fff !important; }
+ .doc:not(.pdf-color) .base-head { background: #f2f2f2 !important; border-bottom: 1px solid #000; color: #000; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .doc:not(.pdf-color) .base-snapshot tbody tr:nth-child(even) { background: #f6f6f6 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ .doc:not(.pdf-color) mark { background: transparent; border-bottom: 2px solid #000; }
+
+ /* Pagination hygiene */
+ .doc h1, .doc h2, .doc h3, .doc h4, .doc h5, .doc h6 {
+ break-after: avoid;
+ break-inside: avoid;
+ }
+ .doc p, .doc blockquote, .doc li { orphans: 3; widows: 3; }
+ .doc pre, .doc table, .doc figure, .doc img, .doc blockquote { break-inside: avoid; }
+ .doc thead { display: table-header-group; } /* repeat header on each page */
+ .doc tr { break-inside: avoid; }
+
+ /* Manual extra break: any element carrying .page-break.
+ Both properties for cross-engine support (modern break-* + legacy). */
+ .doc .page-break { break-before: page; page-break-before: always; }
+
+ /* Single-note pagination: each H1 starts a new page; H2 and below just flow
+ with their normal spacing. (Gated to :not(.book) — books break on
+ chapters, not headings.) */
+ .doc:not(.book) h1 {
+ break-before: page; page-break-before: always;
+ margin-top: 0; /* an H1 opening a page sits at the top */
+ }
+ /* A leading H1 (cover off) just leads page one — no break before it. */
+ .doc:not(.book) > h1:first-child { break-before: avoid; page-break-before: avoid; }
+
+ /* ── Book mode: break on chapters, not headings ── */
+ .doc.book .chapter { break-before: page; page-break-before: always; }
+ .doc.book.continuous .chapter { break-before: auto; page-break-before: auto; }
+ .doc.book .book-toc { break-after: page; page-break-after: always; break-inside: avoid; }
+ /* No double break right after the cover or the TOC. */
+ .doc.book .cover + .chapter,
+ .doc.book .book-toc + .chapter,
+ .doc.book .cover + .book-toc { break-before: avoid; page-break-before: avoid; }
+
+ /* Cover page: the leading H1 (+ subtitle) begins ~25% down the sheet — the
+ classic title-page drop. A fixed top padding instead of flex centering,
+ because Chromium's printToPDF ignores flex min-height in paged context.
+ Page is 11in; content top sits at the 0.9in @page margin, so 1.85in of
+ padding lands the title at ~2.75in ≈ 25%. */
+ .doc .cover {
+ break-before: auto; page-break-before: auto;
+ break-after: page; page-break-after: always;
+ break-inside: avoid;
+ padding-top: 1.85in;
+ }
+ .doc .cover h1 { break-before: auto; page-break-before: auto; margin-top: 0; }
+}
internal/export/assets/sync.sh +37 −0
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# sync.sh — re-vendor the markdown-doc-kit house style into glint.
+#
+# glint embeds doc.css via go:embed so `brew install glint` ships the full
+# print house style with no runtime dependency on the kit. This script
+# re-copies doc.css from the shared kit and neutralizes the three licensed
+# font families (Awke / Untitled Sans / Name Mono) in the :root --font-*
+# tokens, leaving open/system fallbacks. glint then overrides those tokens
+# per-export from the user's config (font_display / font_body / font_mono).
+#
+# Run this when the kit's house style changes:
+# internal/export/assets/sync.sh
+#
+# Source of truth (this machine only — NOT present on users' machines):
+KIT="${KIT:-/Users/kortum/Developer/Home/_shared-app-kit/markdown-doc-kit}"
+DEST="$(cd "$(dirname "$0")" && pwd)"
+
+set -euo pipefail
+
+src="$KIT/doc.css"
+if [[ ! -f "$src" ]]; then
+ echo "kit doc.css not found at $src" >&2
+ exit 1
+fi
+
+# Copy doc.css, then strip the licensed family names from the three font
+# tokens so the vendored copy references only redistributable/system fonts.
+sed \
+ -e 's/--font-display: *"Awke", *"Untitled Sans", */--font-display: /' \
+ -e 's/--font-body: *"Untitled Sans", */--font-body: /' \
+ -e 's/--font-mono: *"Name Mono", */--font-mono: /' \
+ -e 's|the three locked faces (Awke / Untitled Sans /|the AppKit type tokens (display / body /|' \
+ -e 's|Name Mono), and the seven themes|mono), and the seven themes|' \
+ -e 's|/\* Headings — Awke display \*/|/* Headings — display face */|' \
+ "$src" > "$DEST/doc.css"
+
+echo "vendored doc.css ($(wc -l < "$DEST/doc.css") lines) — licensed faces neutralized"
internal/export/export.go +162 −0
@@ -0,0 +1,162 @@
+// 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(`<!DOCTYPE html>` + "\n")
+ fmt.Fprintf(&b, `<html lang="en" data-theme="%s">`+"\n", html.EscapeString(theme))
+ b.WriteString("<head>\n")
+ b.WriteString(`<meta charset="utf-8">` + "\n")
+ b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` + "\n")
+ fmt.Fprintf(&b, "<title>%s</title>\n", html.EscapeString(opts.Title))
+ fmt.Fprintf(&b, "<style>%s</style>\n", docCSS)
+ fmt.Fprintf(&b, "<style>%s</style>\n", fontOverride(opts))
+ b.WriteString(`<style>html,body{margin:0;background:var(--bg);}</style>` + "\n")
+ b.WriteString("</head>\n<body>\n")
+ fmt.Fprintf(&b, `<article class="%s">`+"\n", cls)
+ b.WriteString(body)
+ b.WriteString("\n</article>\n</body>\n</html>\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;}",
+ opts.FontDisplay, opts.FontBody, opts.FontMono,
+ )
+}
+
+// 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, "<li><input ", `<li class="task-list-item"><input `)
+}
+
+// Headings are single-line in goldmark output, so match without dotall: this
+// keeps a `{.class}` suffix from spanning across newlines into a later heading.
+var headingClassRE = regexp.MustCompile(`<h([1-6])>(.*?)\s*\{\.([\w-]+)\}</h[1-6]>`)
+
+// 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, `<h$1 class="$3">$2</h$1>`)
+}
+
+var leadingH1RE = regexp.MustCompile(`(?s)^\s*(<h1>.*?</h1>)\n?(\s*<p>.*?</p>\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 `<div class="cover">` + "\n" + inner + "\n</div>\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"
+ }
+}
internal/export/export_test.go +265 −0
@@ -0,0 +1,265 @@
+package export
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func testOpts() Options {
+ return Options{
+ Title: "Doc",
+ Theme: "flexoki",
+ FontDisplay: "Georgia, serif",
+ FontBody: "system-ui, sans-serif",
+ FontMono: "ui-monospace, monospace",
+ Cover: true,
+ }
+}
+
+func TestDocumentEmbedsHouseStyle(t *testing.T) {
+ html, err := Document("Hello world.", testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The vendored doc.css must be baked into the output (self-contained).
+ for _, want := range []string{`<article class="doc`, ".doc {", "@media print", "@page"} {
+ if !strings.Contains(html, want) {
+ t.Errorf("output missing %q", want)
+ }
+ }
+}
+
+func TestDocumentSetsTheme(t *testing.T) {
+ opts := testOpts()
+ opts.Theme = "humdrum-dark"
+ html, err := Document("x", opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(html, `data-theme="humdrum-dark"`) {
+ t.Errorf("theme not applied to <html>:\n%s", firstLines(html, 3))
+ }
+}
+
+func TestDocumentRendersMarkdown(t *testing.T) {
+ html, err := Document("# Title\n\nA **bold** word.", testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(html, "<strong>bold</strong>") {
+ t.Errorf("markdown not rendered to HTML")
+ }
+}
+
+func TestDocumentWrapsLeadingH1InCover(t *testing.T) {
+ html, err := Document("# Title\n\nSubtitle line.\n\nBody.", testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(html, `<div class="cover">`) {
+ t.Fatalf("no cover wrapper:\n%s", html)
+ }
+ // The h1 and the immediately following subtitle paragraph live inside cover.
+ cover := between(html, `<div class="cover">`, `</div>`)
+ if !strings.Contains(cover, "<h1") || !strings.Contains(cover, "Subtitle line.") {
+ t.Errorf("cover should hold h1 + subtitle, got:\n%s", cover)
+ }
+}
+
+func TestDocumentNoCoverWhenDisabled(t *testing.T) {
+ opts := testOpts()
+ opts.Cover = false
+ html, err := Document("# Title\n\nBody.", opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(html, `<div class="cover">`) {
+ t.Errorf("cover wrapper present though disabled")
+ }
+}
+
+func TestDocumentPageBreakHeadingSuffix(t *testing.T) {
+ html, err := Document("# T\n\n## Section {.page-break}\n\nx", testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(html, `class="page-break"`) {
+ t.Errorf("{.page-break} suffix not turned into a class:\n%s", html)
+ }
+ if strings.Contains(html, "{.page-break}") {
+ t.Errorf("literal {.page-break} suffix left in heading text")
+ }
+}
+
+func TestPageBreakSuffixDoesNotLeakToEarlierHeading(t *testing.T) {
+ // A leading H1 with no suffix, followed by an H2 carrying {.page-break}:
+ // the suffix must attach to the H2 only, never span to the H1.
+ html, err := Document("# Title\n\nbody\n\n## Section {.page-break}\n\nmore", testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(html, `<h1 class="page-break"`) {
+ t.Errorf("page-break leaked onto the leading H1:\n%s", html)
+ }
+ if strings.Contains(html, "</h1>\n<p") && strings.Contains(html, "<h2>Section</h1>") {
+ t.Errorf("mismatched heading close tags produced")
+ }
+ if !strings.Contains(html, `<h2 class="page-break">Section</h2>`) {
+ t.Errorf("H2 did not get a clean page-break class:\n%s", html)
+ }
+}
+
+func TestDocumentTaskListItem(t *testing.T) {
+ html, err := Document("- [ ] todo\n- [x] done", testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(html, "task-list-item") {
+ t.Errorf("checkbox list item missing task-list-item class:\n%s", html)
+ }
+}
+
+func TestDocumentInjectsConfiguredFonts(t *testing.T) {
+ opts := testOpts()
+ opts.FontDisplay = "Charter, serif"
+ opts.FontBody = "Inter, sans-serif"
+ opts.FontMono = "Fira Code, monospace"
+ html, err := Document("x", opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"Charter, serif", "Inter, sans-serif", "Fira Code, monospace"} {
+ if !strings.Contains(html, want) {
+ t.Errorf("configured font %q not injected", want)
+ }
+ }
+ // No licensed face should leak into the output.
+ for _, bad := range []string{"Awke", "Untitled Sans", "Name Mono"} {
+ if strings.Contains(html, bad) {
+ t.Errorf("licensed font %q leaked into export", bad)
+ }
+ }
+}
+
+func TestDocumentStripsFrontmatter(t *testing.T) {
+ md := "---\ntitle: Hi\ntags: [a, b]\n---\n\n# Real Title\n\nBody."
+ html, err := Document(md, testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(html, "tags: [a, b]") {
+ t.Errorf("YAML frontmatter rendered into body:\n%s", html)
+ }
+ if !strings.Contains(html, "Real Title") {
+ t.Errorf("content after frontmatter lost")
+ }
+}
+
+func TestMapTheme(t *testing.T) {
+ cases := map[string]string{
+ "flexoki-light": "flexoki",
+ "flexoki-dark": "flexoki-dark",
+ "charm": "flexoki-dark",
+ "": "flexoki",
+ "nonexistent": "flexoki",
+ }
+ for in, want := range cases {
+ if got := MapTheme(in); got != want {
+ t.Errorf("MapTheme(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestOutputPathFromSource(t *testing.T) {
+ if got := OutputPath("/x/y/note.md", "Title"); got != "/x/y/note.html" {
+ t.Errorf("OutputPath = %q, want /x/y/note.html", got)
+ }
+ if got := OutputPath("/x/y/note.markdown", "Title"); got != "/x/y/note.html" {
+ t.Errorf("OutputPath = %q, want /x/y/note.html", got)
+ }
+}
+
+func TestOutputPathUnnamedUsesTempSlug(t *testing.T) {
+ got := OutputPath("", "My Great Note!")
+ if filepath.Dir(got) != strings.TrimRight(os.TempDir(), string(os.PathSeparator)) {
+ t.Errorf("unnamed export should land in temp dir, got %q", got)
+ }
+ base := filepath.Base(got)
+ if base != "my-great-note.html" {
+ t.Errorf("slug base = %q, want my-great-note.html", base)
+ }
+}
+
+func TestWriteCreatesSelfContainedFile(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "doc.md")
+ if err := os.WriteFile(src, []byte("# Hi\n\nbody"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ out, err := Write(src, "# Hi\n\nbody", testOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out != filepath.Join(dir, "doc.html") {
+ t.Errorf("out = %q, want doc.html alongside source", out)
+ }
+ data, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), `<article class="doc`) {
+ t.Errorf("written file is not a rendered document")
+ }
+}
+
+func TestTitle(t *testing.T) {
+ cases := []struct {
+ src, md, want string
+ }{
+ {"/x/note.md", "# Real Heading\n\nbody", "Real Heading"},
+ {"/x/note.md", "no heading here", "note"},
+ {"", "no heading", "Untitled"},
+ {"/x/draft.md", "---\ntitle: y\n---\n# After Frontmatter\n", "After Frontmatter"},
+ }
+ for _, c := range cases {
+ if got := Title(c.src, c.md); got != c.want {
+ t.Errorf("Title(%q, %q) = %q, want %q", c.src, c.md, got, c.want)
+ }
+ }
+}
+
+func TestBrowserCommandNonEmpty(t *testing.T) {
+ name, args := browserCommand("/tmp/x.html")
+ if name == "" {
+ t.Fatal("browserCommand returned empty program")
+ }
+ joined := strings.Join(append([]string{name}, args...), " ")
+ if !strings.Contains(joined, "/tmp/x.html") {
+ t.Errorf("browser command %q does not reference the file", joined)
+ }
+}
+
+// --- helpers ---
+
+func firstLines(s string, n int) string {
+ lines := strings.SplitN(s, "\n", n+1)
+ if len(lines) > n {
+ lines = lines[:n]
+ }
+ return strings.Join(lines, "\n")
+}
+
+func between(s, start, end string) string {
+ i := strings.Index(s, start)
+ if i < 0 {
+ return ""
+ }
+ i += len(start)
+ j := strings.Index(s[i:], end)
+ if j < 0 {
+ return s[i:]
+ }
+ return s[i : i+j]
+}
internal/export/file.go +95 −0
@@ -0,0 +1,95 @@
+package export
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+)
+
+// OutputPath is where an export of srcPath lands: the source path with its
+// extension swapped for .html. An unnamed buffer (empty srcPath) goes to the
+// OS temp dir, named from a slug of the title.
+func OutputPath(srcPath, title string) string {
+ if srcPath == "" {
+ name := slug(title)
+ if name == "" {
+ name = "document"
+ }
+ return filepath.Join(os.TempDir(), name+".html")
+ }
+ ext := filepath.Ext(srcPath)
+ return strings.TrimSuffix(srcPath, ext) + ".html"
+}
+
+// Write renders markdown to a self-contained HTML document and writes it to
+// the export path for srcPath, returning that path.
+func Write(srcPath, markdown string, opts Options) (string, error) {
+ doc, err := Document(markdown, opts)
+ if err != nil {
+ return "", err
+ }
+ out := OutputPath(srcPath, opts.Title)
+ if err := os.WriteFile(out, []byte(doc), 0o644); err != nil {
+ return "", err
+ }
+ return out, nil
+}
+
+var atxH1RE = regexp.MustCompile(`(?m)^#\s+(.+?)\s*#*\s*$`)
+
+// Title is the document title for an export: the first `# H1` in the markdown,
+// else the source filename without extension, else "Untitled".
+func Title(srcPath, markdown string) string {
+ if m := atxH1RE.FindStringSubmatch(stripFrontmatter(markdown)); m != nil {
+ if t := strings.TrimSpace(m[1]); t != "" {
+ return t
+ }
+ }
+ if srcPath != "" {
+ base := filepath.Base(srcPath)
+ return strings.TrimSuffix(base, filepath.Ext(base))
+ }
+ return "Untitled"
+}
+
+// OpenInBrowser opens path in the user's default browser, best-effort. It
+// returns any error starting the program; the browser then runs detached.
+func OpenInBrowser(path string) error {
+ name, args := browserCommand(path)
+ return exec.Command(name, args...).Start()
+}
+
+// browserCommand returns the platform program (and args) that opens path in
+// the user's default browser.
+func browserCommand(path string) (string, []string) {
+ switch runtime.GOOS {
+ case "darwin":
+ return "open", []string{path}
+ case "windows":
+ return "cmd", []string{"/c", "start", "", path}
+ default: // linux, *bsd
+ return "xdg-open", []string{path}
+ }
+}
+
+// slug turns a title into a filename-safe lowercase token.
+func slug(title string) string {
+ var b strings.Builder
+ prevDash := false
+ for _, r := range strings.ToLower(title) {
+ switch {
+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
+ b.WriteRune(r)
+ prevDash = false
+ default:
+ if !prevDash && b.Len() > 0 {
+ b.WriteByte('-')
+ prevDash = true
+ }
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
internal/help/help.go +2 −0
@@ -27,6 +27,8 @@ Enter (on a list) continue the list marker (numbers increment, checkboxes
reset); Enter on an empty item exits the list
Tab / Shift+Tab indent / outdent the current list item
Ctrl+S save (an unnamed buffer prompts for a name)
+ Ctrl+E export a printable HTML doc (house style) and open it in
+ the browser → Print → Save as PDF
Ctrl+P toggle the read preview
Ctrl+F fuzzy file picker
Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)