diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index d7eb6eb..e6d96e2 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -8,7 +8,7 @@ jobs: container: image: node:alpine env: - ZONA: git+https://git.ficd.sh/ficd/zona.git@dacea2756af75d1151788cc0c1b2eefbead3c01f + ZONA: git+https://git.ficd.sh/ficd/zona.git@10d1772a2d7a14c977e8359e3df25a2a40948daa site: ficd.sh site_draft: draft.ficd.sh steps: diff --git a/README.md b/README.md index a5de020..77752ea 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is the source code for my personal website, [ficd.sh], built with the [zona] static site generator. It's hosted on [srht.site] and automatically -deployed via [builds.sr.ht] when changes are pushed to the `main` branch +deployed via Forgejo actions when changes are pushed to the `main` branch of this repository. I use `zona serve` to preview locally before pushing changes. @@ -10,9 +10,9 @@ changes. 2. The output is packaged into `.tar.gz` format. 3. The tarball is published with `hut pages publish`. -You can see the entire pipeline in [`.build.yml`](./.build.yml). +You can see the entire pipeline in +[here](./.forgejo/workflows/deploy.yml). [zona]: https://git.ficd.sh/ficd/zona [ficd.sh]: https://ficd.sh [srht.site]: https://srht.site -[builds.sr.ht]: https://builds.sr.ht diff --git a/content/blog/email-formatting.md b/content/blog/email-formatting.md new file mode 100644 index 0000000..0f97e80 --- /dev/null +++ b/content/blog/email-formatting.md @@ -0,0 +1,189 @@ +--- +title: Email Formatting Is Harder Than It Looks +date: 2025-07-13 +draft: true +--- + +*[UTF-8]: Unicode Transformation Format - 8 bit. + +[Kakoune]: https://kakoune.org + +As I've [mentioned before](./email-in-kakoune.md), I like using [Kakoune] for +reading & writing emails. Of course, Kakoune is a text editor, not a _rich text_ +editor. It operates on UTF-8 _plaintext_ --- which means that the emails I write +need to be in plaintext, too. + +As I went down this path, I quickly discovered that I needed an **email +formatter**. I eventually wrote [`mailfmt`](https://git.sr.ht/~ficd/mailfmt) to +fill this niche. It provides consistent paragraph spacing, hard-wrapping and +paragraph reflow, while preserving Markdown syntax, email headers, quotes, +sign-offs, and signature blocks. Additionally, the wrapped output can be made +safe for passing to a Markdown parser. This is useful if you want to build an +HTML email from plain-text. + +`mailfmt` open-source under the ISC license, and is available on +[PyPI](https://pypi.org/project/mailfmt/) for installation with tools like +`pipx` and `uv`. The source code is available on sourcehut at +[git.ficd.sh/ficd/mailfmt](https://git.ficd.sh/ficd/mailfmt). + +## Target Audience + +I wrote this tool primarily for myself. It's served me very well over the past +few months. `mailfmt` could be helpful for anyone that prefers writing email in +plain-text using text editors like Kakoune, Helix, and Vim. It can format via +`stdin`/`stdout` and read/write files, making `mailfmt` easy to configure as a +formatter for the `mail` filetype in your editor. + +I'm including a very lengthy explanation of exactly why I built this tool. You +may think it's overkill for such a small program — but I like to be crystal +clear about justifying my work. It reads like blog post rather than the +emoji-filled `README`/marketing style we're accustomed to seeing on this +platform. I've put a lot of thought into this, and I want to share my work. I +hope you enjoy reading about my thought process. + +## Why I Built It (Comparison) + +Unsurprisingly, it all started with a specific problem I was having composing +emails in plain-text format in my preferred text editor. As I searched for a +solution, I couldn't find anything that met all my needs, so I wrote it myself. + +Here's what I wanted: + +- A way to consistently format my outgoing emails in my text editor. +- Paragraph reflow and automatic line wrapping. + - Not all plain-text clients are capable of line-wrap. In some contexts, such + as mailing lists, the author is expected to wrap the text themselves. +- Inline Markdown syntax `can _still_ look great, **even** in plain-text!` Thus, + I wanted to use it: + - Without it being broken by reflow & wrap. + - While looking good and retaining the same semantics in _both_ rendered + **and** plain-text form — ideal for `multipart` emails. +- Ensure signature block is formatted properly. + - The single space after `--` and before the newline **must** be included. + +### `fmt` and Markdown Formatters Don't Work For Email + +The `fmt` utility provides great wrapping and reflow capabilities — I use it all +the time while writing LaTeX. However, it's syntax agnostic, and breaks +Markdown. For example, it completely mangles fenced code blocks. I figured: hey, +why not just use a Markdown formatter? It supports Markdown (obviously), _and_ +can reflow & wrap text! Here's the problem: it turns out treating your +**entire** email as a Markdown document isn't ideal. + +`mailfmt`'s approach is simple: detect when a line matches a known pattern of +Markdown block element syntax, such as leading `#` for headings, `-` for lists, +etc. If so, **leave the line untouched**. Similarly, **don't format anything +inside fenced code blocks**. + +#### Sign-Offs + +Consider the following sign-off: + +``` +Best wishes, +Daniel +``` + +A Markdown formatter considers this to be one paragraph, and reflows it +accordingly, causing it to lost semantic meaning: + +``` +Best wishes, Daniel +``` + +Within the confines of Markdown, I counted three ways of dealing with the +problem: + +1. Put an empty line between the two parts: + +``` +Best wishes, + +Daniel +``` + +> However, this empty line looks _awkward_ when viewed in plain-text. + +2. Put a backslash after the intentional line break: + +``` +Best wishes, \ +Daniel +``` + +> Again, this looks bad when the Markdown isn't rendered. + +3. Put two spaces after the intentional line break (• = space): + +``` +Best•wishes,•• +Daniel +``` + +> This syntax is **ambiguous, easy to forget**, and **not supported by editors +> that trim trailing whitespace.** + +`mailfmt` detects sign-offs using a very simple heuristic. First, we check if a +line has _5 or less_ words, and **ends with a comma**. If we find such a line, +we check the _next_ line. If it has 5 or less words **that all begin with an +uppercase letter**, then we assume these two lines are a _sign-off_, and we +don't reflow or wrap them. The heuristic matches a very simple pattern: + +``` +A courteous greeting, +First Middle Last Name +``` + +#### Signature Block + +The convention for signature blocks is as follows: + +1. Begins with two `-` characters followed by a single space, then a newline. +2. Everything that follows until the EOF is part of the signature. + +Here's an example (note the • = space): + +``` +--• +Daniel + +Software•Developer,•Company +email@website.com +``` + +As with sign-offs, such a signature block gets mangled by Markdown formatters. +Furthermore, the single space after the `--` token is important: if it's +missing, some clients won't recognize it is a valid signature — our formatter +should address this too. + +`mailfmt` detects when a line's _only_ content is `--`. It adds the required +trailing space if it's missing, and it treats the rest of the input as part of +the signature, leaving it completely untouched. + +### Consistent Multipart Emails + +Something you may want to do is generate a `multipart` email. This means that +_both_ an HTML **and** plain-text representation of the _same_ email are +included in the file — leaving it up to the reader's client to pick which one to +display. + +The plain-text email **must** be able to stand on its own, and _also_ render to +decent-looking HTML. Essentially, you want to write your email in plain-text +once, ensuring it has proper formatting, and then use a command to generate an +HTML email from it. For this, `mailfmt` provides the `--markdown-safe` flag, +which appends backslashes to the formatted output, making it safe for Markdown +parsing without messing up the line breaks after sign-offs and signature blocks. + +For example, I use the following in [aerc](https://aerc-mail.org/) to generate +an HTML multipart email whenever I want: + +```ini +[multipart-converters] +text/html=mailfmt --markdown-safe | pandoc -f markdown -t html --standalone +``` + +## Conclusion + +If you've made it this far, thanks for sticking with me and reading to the end! +Even if you don't plan to write plain-text email or use `mailfmt` at all, I hope +you learned something interesting. diff --git a/content/blog/email-in-kakoune.md b/content/blog/email-in-kakoune.md index cb00ad4..fb23e83 100644 --- a/content/blog/email-in-kakoune.md +++ b/content/blog/email-in-kakoune.md @@ -3,24 +3,18 @@ title: Writing Emails In Kakoune date: 2025-06-01 --- -This post will guide you through my setup for using Kakoune as an email composer -inside `aerc`. I'll also explain how to configure Kakoune to act as the _pager_ -for reading `text/plain` emails. If you only care about the final config, feel -free to skip to it [here](#final-configuration). +This post will guide you through my setup for using Kakoune as an email +composer inside `aerc`. I'll also explain how to configure Kakoune to act +as the _pager_ for reading `text/plain` emails. If you only care about the +final config, feel free to skip to it [here](#final-configuration). - - -- [Naive Approach](#naive-approach) -- [Composer Setup](#composer-setup) -- [Reader Setup](#reader-setup) -- [Final Configuration](#final-configuration) - - +[TOC] ## Naive Approach -Since `aerc` uses your `$EDITOR` for composition, you don't technically have to -do anything. I prefer setting it explicitly in `aerc.conf`, for good measure: +Since `aerc` uses your `$EDITOR` for composition, you don't technically +have to do anything. I prefer setting it explicitly in `aerc.conf`, for +good measure: ```ini [compose] @@ -31,8 +25,9 @@ The rest of the magic happens in your `kakrc`. ## Composer Setup -Essentially, we want to hook `filetype=mail` and set our buffer configuration -there. I'll share a recommended configuration with some explanation. +Essentially, we want to hook `filetype=mail` and set our buffer +configuration there. I'll share a recommended configuration with some +explanation. ```kak hook global WinSetOption filetype=mail %~ @@ -47,51 +42,53 @@ hook global WinSetOption filetype=mail %~ ~ ``` -I use a custom formatter to format emails. It automatically hard-wraps lines -while preserving certain markup elements, code blocks, sign-offs, and signature -blocks. For more details, check the formatting section of my post on -[Helix](/blog/email/helix#formatting). +I use a custom formatter to format emails. It automatically hard-wraps +lines while preserving certain markup elements, code blocks, sign-offs, +and signature blocks. For more details, check the formatting section of my +post on [Helix](/blog/email/helix#formatting). -I find that setting `>` as the `comment_line` token is convenient for working -with quotes in replies. +I find that setting `>` as the `comment_line` token is convenient for +working with quotes in replies. The `try autospell-enable` enables my -[kak-autospell](https://codeberg.org/ficd/kak-autospell) plugin for the buffer. -Essentially, it provides spellchecking that's continuously refreshed and hidden -in insert mode. +[kak-autospell](https://codeberg.org/ficd/kak-autospell) plugin for the +buffer. Essentially, it provides spellchecking that's continuously +refreshed and hidden in insert mode. -The remaining commands configure auto-formatting on save. I always prefer having -this on so I never forget to format my message before sending it. +The remaining commands configure auto-formatting on save. I always prefer +having this on so I never forget to format my message before sending it. ## Reader Setup -I find that using Kakoune to **read** emails is helpful because of how easy it -is to copy quotes, open links, etc. Configuring this is a tad hackier, however. -The basic idea is to set Kakoune as the viewer `pager` in `aerc.conf`. +I find that using Kakoune to **read** emails is helpful because of how +easy it is to copy quotes, open links, etc. Configuring this is a tad +hackier, however. The basic idea is to set Kakoune as the viewer `pager` +in `aerc.conf`. -However, all this does is pipe the email to `kak` through standard input, so we -need to tell the editor to treat it like an email: +However, all this does is pipe the email to `kak` through standard input, +so we need to tell the editor to treat it like an email: ```ini [viewer] pager=kak -e 'set buffer filetype mail' ``` -When you're using Kakoune as a pager, you'll probably want to configure some -things differently. In my case, I like to set the buffer as `readonly`, remove -the `number-lines` and `show-whitespaces` highlighters, disable soft-wrap & my -scrolloff settings, and _not_ set any formatters. +When you're using Kakoune as a pager, you'll probably want to configure +some things differently. In my case, I like to set the buffer as +`readonly`, remove the `number-lines` and `show-whitespaces` highlighters, +disable soft-wrap & my scrolloff settings, and _not_ set any formatters. -The `pager` command above sets the filetype, but we need to distinguish between -_composing_ and _reading_ in our Kakoune hook. When Kakoune is opened with input -through standard input, it loads a buffer that's conveniently named `*stdin*`. -Thus, we can check the buffer name before continuing. +The `pager` command above sets the filetype, but we need to distinguish +between _composing_ and _reading_ in our Kakoune hook. When Kakoune is +opened with input through standard input, it loads a buffer that's +conveniently named `*stdin*`. Thus, we can check the buffer name before +continuing. -If we're in "reading mode", we define a hidden command called `ismailreader` -which doesn't do anything. Why? If the command is defined, and we try to invoke -it... well, nothing happens! But if it's **not** defined, we get an error -instead. We can combine this with the `try` command to for some simple boolean -logic. +If we're in "reading mode", we define a hidden command called +`ismailreader` which doesn't do anything. Why? If the command is defined, +and we try to invoke it... well, nothing happens! But if it's **not** +defined, we get an error instead. We can combine this with the `try` +command to for some simple boolean logic. ```kak evaluate-commands %sh{ diff --git a/content/static/style.css b/content/static/style.css index a8b1e09..284a5ce 100644 --- a/content/static/style.css +++ b/content/static/style.css @@ -1,20 +1,20 @@ :root { - --main-text-color: #b4b4b4; - --main-bg-color: #121212; - --main-link-color: #df6464; - --main-heading-color: #df6464; - --main-bullet-color: #d87c4a; - --main-transparent: rgba(255, 255, 255, 0.15); - --main-small-text-color: rgba(255, 255, 255, 0.45); + --main-text-color: #b4b4b4; + --main-bg-color: #121212; + --main-link-color: #df6464; + --main-heading-color: #df6464; + --main-bullet-color: #d87c4a; + --main-transparent: rgba(255, 255, 255, 0.15); + --main-small-text-color: rgba(255, 255, 255, 0.45); } body { - line-height: 1.6; - font-size: 18px; - font-family: sans-serif; - background: var(--main-bg-color); - color: var(--main-text-color); - padding-left: calc(100vw - 100%); + line-height: 1.6; + font-size: 18px; + font-family: sans-serif; + background: var(--main-bg-color); + color: var(--main-text-color); + padding-left: calc(100vw - 100%); } .toclink { @@ -63,277 +63,312 @@ h3, h4, h5, h6 { - color: var(--main-heading-color); + color: var(--main-heading-color); } h1 { - margin-block-start: 0.67rem; - margin-block-end: 0.67rem; - font-size: 2rem; - font-weight: bold; + margin-block-start: 0.67rem; + margin-block-end: 0.67rem; + font-size: 2rem; + font-weight: bold; } article h1:first-of-type { - margin-block-start: 1.67rem; + margin-block-start: 1.67rem; } h2 { - margin-block-start: 0.83rem; - margin-block-end: 0.83rem; - font-size: 1.5rem; - font-weight: bold; + margin-block-start: 0.83rem; + margin-block-end: 0.83rem; + font-size: 1.5rem; + font-weight: bold; } h3 { - margin-block-start: 1rem; - margin-block-end: 1rem; - font-size: 1.17em; - font-weight: bold; + margin-block-start: 1rem; + margin-block-end: 1rem; + font-size: 1.17em; + font-weight: bold; } h4 { - margin-block-start: 1.33rem; - margin-block-end: 1.33rem; - font-size: 1rem; - font-weight: bold; + margin-block-start: 1.33rem; + margin-block-end: 1.33rem; + font-size: 1rem; + font-weight: bold; } -article h1+h4:first-of-type { - margin-block-start: 0rem; +article h1 + h4:first-of-type { + margin-block-start: 0rem; } h5 { - margin-block-start: 1.67rem; - margin-block-end: 1.67rem; - font-size: 0.83rem; - font-weight: bold; + margin-block-start: 1.67rem; + margin-block-end: 1.67rem; + font-size: 0.83rem; + font-weight: bold; } h6 { - margin-block-start: 2.33rem; - margin-block-end: 2.33rem; - font-size: 0.67rem; - font-weight: bold; + margin-block-start: 2.33rem; + margin-block-end: 2.33rem; + font-size: 0.67rem; + font-weight: bold; } ul { - list-style-type: disc; - /* or any other list style */ + list-style-type: disc; + /* or any other list style */ } li::marker { - color: var(--main-bullet-color); - /* Change this to your desired color */ + color: var(--main-bullet-color); + /* Change this to your desired color */ } a { - color: var(--main-link-color); + color: var(--main-link-color); + text-decoration: underline; } a:hover { - background: var(--main-transparent); + background: var(--main-transparent); } max-width: 100%; overflow: hidden; img { - display: block; - margin-left: auto; - margin-right: auto; - width: auto; - height: auto; + display: block; + margin-left: auto; + margin-right: auto; + width: auto; + height: auto; } blockquote { - color: var(--main-small-text-color); - border-left: 3px solid var(--main-transparent); - padding: 0 1rem; - margin-left: 0; - margin-right: 0; + color: var(--main-small-text-color); + border-left: 3px solid var(--main-transparent); + padding: 0 1rem; + margin-left: 0; + margin-right: 0; } hr { - border: none; - height: 1px; - background: var(--main-small-text-color); + border: none; + height: 1px; + background: var(--main-small-text-color); } code { - background: var(--main-transparent); - border-radius: 0.1875rem; - /* padding: .0625rem .1875rem; */ - /* margin: 0 .1875rem; */ + background: var(--main-transparent); + border-radius: 0.1875rem; + /* padding: .0625rem .1875rem; */ + /* margin: 0 .1875rem; */ } code, pre { - white-space: pre; - word-wrap: break-word; - overflow-wrap: break-word; - font-family: monospace; - font-size: 0.95em; + white-space: pre; + word-wrap: break-word; + overflow-wrap: break-word; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; + font-size: 0.95em; } pre { - background-color: #151515; - color: #d5d5d5; - padding: 1em; - border-radius: 5px; - line-height: 1.5; - overflow-x: auto; + background-color: #151515; + color: #d5d5d5; + padding: 1em; + border-radius: 5px; + line-height: 1.5; + overflow-x: auto; } /* Inline code styling */ :not(pre) > code { - padding: 0.2em 0.4em; - border-radius: 3px; - background-color: #1d1d1d; - color: #d5d5d5; - font-size: 0.85em; + padding: 0.2em 0.4em; + font-size: 0.85em; + line-height: 1; + background-color: #1d1d1d; + border-radius: 6px; + vertical-align: middle; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; } /* Block code styling (inherits from pre) */ pre code { - padding: 0; - border-radius: 0; - background: none; + padding: 0; + border-radius: 0; + background: none; } small { - font-size: 0.95rem; - color: var(--main-small-text-color); + font-size: 0.95rem; + color: var(--main-small-text-color); } small a { - color: inherit; - /* Inherit the color of the surrounding text */ - text-decoration: underline; - /* Optional: Keep the underline to indicate a link */ + color: inherit; + /* Inherit the color of the surrounding text */ + text-decoration: underline; + /* Optional: Keep the underline to indicate a link */ } .title-container { - display: flex; - justify-content: center; - align-items: center; - text-align: center; + display: flex; + justify-content: center; + align-items: center; + text-align: center; } .title-container h1 { - margin: 0; + margin: 0; } .image-container { - text-align: center; - margin: 20px 0; - max-width: 100%; - overflow: hidden; - /* Optional: add some spacing around the image container */ + text-align: center; + margin: 20px 0; + max-width: 100%; + overflow: hidden; + /* Optional: add some spacing around the image container */ } .image-container img { - max-width: 100%; - width: auto; - max-height: 100%; - height: auto; + max-width: 100%; + width: auto; + max-height: 100%; + height: auto; } .fixed .image-container img { - max-width: 308px; - max-height: 308px; + max-width: 308px; + max-height: 308px; } .image-container small { - display: block; - /* Ensure the caption is on a new line */ - margin-top: 5px; - /* Optional: adjust spacing between image and caption */ + display: block; + /* Ensure the caption is on a new line */ + margin-top: 5px; + /* Optional: adjust spacing between image and caption */ } .image-container small a { - color: inherit; - /* Ensure the link color matches the small text */ - text-decoration: underline; - /* Optional: underline to indicate a link */ + color: inherit; + /* Ensure the link color matches the small text */ + text-decoration: underline; + /* Optional: underline to indicate a link */ } #header ul { - list-style-type: none; - padding-left: 0; + list-style-type: none; + padding-left: 0; } #header li { - display: inline; - font-size: 1.2rem; - margin-right: 1.2rem; + display: inline; + font-size: 1.2rem; + margin-right: 1.2rem; } #container { - margin: 2.5rem auto; - width: 90%; - max-width: 60ch; + margin: 2.5rem auto; + width: 90%; + max-width: 60ch; } #postlistdiv ul { - list-style-type: none; - padding-left: 0; + list-style-type: none; + padding-left: 0; } .moreposts { - font-size: 0.95rem; - padding-left: 0.5rem; + font-size: 0.95rem; + padding-left: 0.5rem; } #nextprev { - text-align: center; - margin-top: 1.4rem; - font-size: 0.95rem; + text-align: center; + margin-top: 1.4rem; + font-size: 0.95rem; } #footer { - color: var(--main-small-text-color); + color: var(--main-small-text-color); } table { - border-collapse: collapse; - margin: 1.5rem auto; - width: 100%; - max-width: 100%; - font-size: 0.85rem; - text-align: left; /* Use center if you prefer */ + border-collapse: collapse; + margin: 1.5rem auto; + width: 100%; + max-width: 100%; + font-size: 0.85rem; + text-align: left; /* Use center if you prefer */ } th, td { - border: 1px solid var(--main-transparent); - /*border: 1px solid var(--main-bullet-color);*/ - padding: 0.4rem 0.8rem; - vertical-align: middle; + border: 1px solid var(--main-transparent); + /*border: 1px solid var(--main-bullet-color);*/ + padding: 0.4rem 0.8rem; + vertical-align: middle; } thead th { - font-weight: bold; - background-color: rgba(255, 255, 255, 0.05); - color: var(--main-text-color); + font-weight: bold; + background-color: rgba(255, 255, 255, 0.05); + color: var(--main-text-color); } tbody tr:nth-child(even) { - background-color: rgba(255, 255, 255, 0.02); + background-color: rgba(255, 255, 255, 0.02); } tbody tr:hover { - background-color: rgba(255, 255, 255, 0.05); + background-color: rgba(255, 255, 255, 0.05); } table code { - font-family: monospace; - font-size: 0.85em; - background: #1d1d1d; - padding: 0.1em 0.25em; - border-radius: 3px; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; + font-size: 0.85em; + background: #1d1d1d; + padding: 0.1em 0.25em; + border-radius: 3px; } caption { - margin-top: 0.5rem; - font-size: 0.8rem; - color: var(--main-small-text-color); + margin-top: 0.5rem; + font-size: 0.8rem; + color: var(--main-small-text-color); } +a > code { + text-decoration: none; + color: inherit; +} +a:has(> code) { + text-decoration: none; +} + +a:hover > code { + background-color: var(--main-transparent); +} diff --git a/justfile b/justfile index b1c5504..adaadda 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,4 @@ -zonaref := `rg "^.*(git\+.*).*$" -r '$1' .build.yml` +zonaref := `rg "^.*(git\+.*).*$" -r '$1' .forgejo/workflows/deploy.yml` echo: echo {{zonaref}}