Add note export options: PDF, markdown and HTML. Also add more themes

This commit is contained in:
mrkmntal 2026-09-29 15:30:10 -04:00
commit 0e0dcdd2c0
8 changed files with 710 additions and 109 deletions

View file

@ -85,7 +85,7 @@ eternalibre-notes/
│ ├── notes.rs # Note model + file-system persistence
│ ├── markdown.rs # Markdown → HTML with code block interception
│ ├── syntax.rs # syntect engine + per-theme tmTheme generation
│ ├── themes.rs # 24 predefined themes + CSS variable engine
│ ├── themes.rs # 34 predefined themes + CSS variable engine
│ └── storage.rs # Storage re-export module
├── static/
│ ├── alpine.min.js # Vendored Alpine.js
@ -121,7 +121,7 @@ pub struct ANSIPalette {
}
```
`PREDEFINED_THEMES` contains 24 themes: Dark, Light, Solarized Dark, Solarized Light, Dracula, Gruvbox Dark, Gruvbox Light, Nord, Monokai, Tokyo Night, One Dark, Cyberpunk 2077, Cyan on Black, Red on Black, Lime Green on Black, Chartreuse on Black, Amber on Black, Blueberry on Black, Spring Blossom, Summer Sunset, Autumn Forest, Winter Frost, Stormy Night, and Foggy Morning.
`PREDEFINED_THEMES` contains 34 alphabetically ordered themes: Amber on Black, Autumn Forest, Blueberry on Black, Breeze, Breeze Dark, Chartreuse on Black, Classic Windows 95, Cyan on Black, Cyberpunk 2077, Dark, Dracula, Foggy Morning, Gruvbox Dark, Gruvbox Light, Light, Lilac Paper, Lime Green on Black, Monokai, Nord, One Dark, Orchid Noir, Platinum Desktop, Red on Black, Rose Quartz, Solarized Dark, Solarized Light, Spring Blossom, Stormy Night, Summer Sunset, Tokyo Night, Velvet Dusk, Vista Aero, Windows XP, and Winter Frost.
"Sunny Day" was removed: a mid-green background with a pale yellow foreground left almost nothing legible on it, and no amount of syntax tuning rescues a palette that bad.
@ -222,7 +222,7 @@ Where no candidate clears the floor, the palette is at fault, not the code. **So
Comments also carry `fontStyle: italic` in the tmTheme, which syntect renders as `font-style:italic` on the span. Color alone is not a reliable signal, since a comment that falls back to the foreground is by definition the same color as the prose.
The three failure modes are each pinned by a test, and each test was verified to fail when the corresponding bug is reintroduced: `comments_are_readable_in_every_theme`, `no_theme_uses_its_background_for_comments`, and `comments_stay_distinct_from_the_foreground_when_they_can`. `syntax_color_always_picks_the_more_readable_variant` additionally pins the selection rule itself across all 24 themes and all 8 slots.
The three failure modes are each pinned by a test, and each test was verified to fail when the corresponding bug is reintroduced: `comments_are_readable_in_every_theme`, `no_theme_uses_its_background_for_comments`, and `comments_stay_distinct_from_the_foreground_when_they_can`. `syntax_color_always_picks_the_more_readable_variant` additionally pins the selection rule itself across all 34 themes and all 8 slots.
### The Light theme has a data ceiling, not a logic bug
@ -248,7 +248,7 @@ Implementation notes:
> **Do not reintroduce `overflow`, `transform`, `filter`, or `opacity` on `.menu-bar`, `.menu-list`, or `.menu-item`.** `overflow` on the dropdown *itself* is safe (it only clips its own contents), but on an ancestor it traps the dropdown.
The 24-entry Theme menu can exceed the viewport height, so `.menu-dropdown` itself carries `max-height` plus `overflow-y: auto`. This is the safe location for that property.
The 34-entry Theme menu can exceed the viewport height, so its `.menu-dropdown` carries `max-height` plus `overflow-y: auto`. The short File dropdown is the exception: `.file-menu-dropdown` uses `overflow: visible` so the Export Note As flyout can escape to the right; scrolling is confined to the flyout itself. Keep overflow off the menu-bar ancestors.
### Every screen needs a viewport meta, or the mobile CSS is dead
@ -269,7 +269,7 @@ The trap is that the CSS is *correct and present*. You can add a complete mobile
**Its version and theme counts are derived, never typed.** The version comes from `env!("CARGO_PKG_VERSION")`, and the same macro drives the `.app-title` in the menu bar — which previously carried a hand-written `"v0.1"` that had already drifted from the crate's `0.1.0`. The theme line is `PREDEFINED_THEMES.len()` split by `Theme::is_dark()`, so adding or removing a palette needs no edit here. Both are pinned by `about_page_reports_the_manifest_version` and `about_page_theme_counts_come_from_the_palette`.
`Theme::is_dark` classifies on the local gamma-encoded `relative_luminance` rather than the linearised one in `syntax.rs`. That is safe *only* because the shipped palettes are strongly bimodal: the lightest dark background scores 0.095 and the darkest light one 0.63, so any threshold in that gap agrees. If a future palette lands near 0.5, switch this to the WCAG function rather than nudging the threshold — the two agree on all 24 themes today, so the current answer is not a close call.
`Theme::is_dark` classifies on the local gamma-encoded `relative_luminance` rather than the linearised one in `syntax.rs`. That is safe *only* because the shipped palettes are strongly bimodal: the lightest dark background scores 0.095 and the darkest light one 0.63, so any threshold in that gap agrees. If a future palette lands near 0.5, switch this to the WCAG function rather than nudging the threshold — the two agree on all 34 themes today, so the current answer is not a close call.
**Every outbound link carries `rel="noopener noreferrer"`.** These are the only links that leave the machine, and the app advertises that it never talks to a third party. `about_page_external_links_are_hardened` walks every `<a href="https://` in the rendered page, so a link added later without the attributes fails rather than shipping quietly. It caught the Help menu's GPL link, which had `target="_blank"` and no `rel`.
@ -313,6 +313,9 @@ Outside-click detection is a manual `document` listener in an `init()` hook with
| `POST` | `/logout` | Drop the session server-side and clear the cookie |
| `GET` | `/` | App shell with note list (accepts `?q=` for search) |
| `GET` | `/notes/:id` | App shell with a note open in the editor |
| `GET` | `/notes/:id/export/markdown` | Download a saved note as Markdown |
| `GET` | `/notes/:id/export/html` | Download a self-contained HTML rendering in the selected theme |
| `GET` | `/notes/:id/export/pdf` | Open a theme-aware print view and invoke the browser print dialog |
| `POST` | `/notes` | Create a new note, redirect to it |
| `POST` | `/notes/:id` | Update or delete based on the `_method` form field |
| `POST` | `/preview` | Returns rendered HTML for live preview |
@ -321,6 +324,8 @@ Outside-click detection is a manual `document` listener in an `init()` hook with
| `GET` | `/export` | Downloads all notes as a single Markdown file |
| `*` | `/static/*` | Vendored Alpine.js, app.js, and style.css |
Single-note exports are authentication-gated and always use the saved note on disk; save the editor first to include pending edits. Markdown downloads include the note title as a heading. HTML exports inline both the selected theme variables and application stylesheet, so they remain styled when moved elsewhere. PDF export is a print-ready HTML document that opens the browser's print dialog (choose “Save as PDF”); `print-color-adjust: exact` requests themed backgrounds, though browser print settings may still omit them. No PDF generation dependency is required.
Notes are stored as `<uuid>.md` files in `notes/` with YAML frontmatter carrying `title`, `created`, and `updated` timestamps. When no frontmatter is present, the title is derived from the first `# ` heading. The directory is overridable via the `--notes-dir` flag or the `ETERNALIBRE_NOTES_DIR` env var, which is how the integration tests stay out of real user data.
---