Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions tools/social-crossposting/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import Control.Monad (filterM)
import Data.List (intercalate)
import Data.Maybe (fromMaybe, isNothing)
import qualified Data.Text as T
import System.Directory (doesFileExist, listDirectory)
import System.Directory (doesFileExist, listDirectory, makeRelativeToCurrentDirectory)
import System.Environment (getArgs, lookupEnv)
import System.Exit (die)
import System.FilePath (dropExtension, (<.>), (</>))
import System.FilePath (dropExtension, normalise, (<.>), (</>))
import System.IO (hIsTerminalDevice, stdout)
import Text.Pandoc (
Block (Para, Plain),
Expand All @@ -41,8 +41,7 @@ import Text.Pandoc.Shared (stringify)
siteRoot :: String
siteRoot = "https://haskell.foundation"

-- | Hashtags to append; same set for every microblog for now (see the
-- "Hashtag set per channel" open question in the todo).
-- | Hashtags to append; same set for every microblog for now.
hashtags :: [T.Text]
hashtags = ["#haskell", "#haskellfoundation"]

Expand Down Expand Up @@ -78,12 +77,13 @@ main = do
putStr (renderDrafts style item)

-- | Accept either a path to a markdown file or a bare slug, looked up under
-- every `news/YYYY-MM-DD/` directory.
-- every `news/YYYY-MM-DD/` directory. A path comes back relative to the repo
-- root and normalised, since 'loadNewsItem' derives the site URL from it.
resolvePath :: FilePath -> IO FilePath
resolvePath arg = do
isFile <- doesFileExist arg
if isFile
then pure arg
then normalise <$> makeRelativeToCurrentDirectory arg
else do
dates <- listDirectory "news"
let candidates = [("news" </> d </> arg) <.> "markdown" | d <- dates]
Expand Down Expand Up @@ -176,7 +176,6 @@ data PostAs
| AsPrivate
| AsPrivateEmail
| AsPrivateGitHub
| AsUnknown

data Channel = Channel
{ chName :: T.Text
Expand Down Expand Up @@ -206,7 +205,9 @@ discourse =
, chAccount = AsPrivate
, chColor = 74
, chDraft = \NewsItem{niTitle, niBlurb, niUrl} ->
[section "post" (T.unlines ["# " <> niTitle, "", niBlurb, "", niUrl])]
[ section "title" niTitle
, section "body" (T.unlines [niBlurb, "", niUrl])
]
}

reddit :: Channel
Expand Down Expand Up @@ -249,7 +250,7 @@ linkedIn =
, chKind = Microblog
, chConstraint = Constraint{conFormat = PlainText, conLength = PostAtMost 3000, conEtiquette = []}
, chUrl = "https://www.linkedin.com/company/haskell-foundation-inc"
, chAccount = AsUnknown
, chAccount = AsHF
, chColor = 62
, chDraft = \NewsItem{niTitle, niBlurb, niUrl} ->
[ section
Expand Down Expand Up @@ -399,16 +400,15 @@ constraintText Constraint{conFormat, conLength, conEtiquette} =
ImportantNewsOnly -> "important news only"

-- | Human-readable "who posts this" line, coloured as a traffic light:
-- green = HF-owned/safe, yellow = unconfirmed, red = needs your personal
-- identity (the easy thing to get wrong). These three saturated colours are
-- reserved for this flag and never used for channel chrome (see 'chColor').
-- green = HF-owned/safe, red = needs your personal identity (the easy thing
-- to get wrong). These saturated colours are reserved for this flag and never
-- used for channel chrome (see 'chColor').
accountText :: Style -> PostAs -> T.Text
accountText s = \case
AsHF -> fg s 40 "Haskell Foundation account"
AsPrivate -> boldFg s 203 "your PRIVATE account"
AsPrivateEmail -> boldFg s 203 "your PRIVATE email address"
AsPrivateGitHub -> boldFg s 203 "your PRIVATE GitHub account (as a pull request)"
AsUnknown -> boldFg s 220 "HF or private? \x2014 unconfirmed, check before posting"
AsPrivate -> boldFg s 203 "Your private account"
AsPrivateEmail -> boldFg s 203 "Your private email address"
AsPrivateGitHub -> boldFg s 203 "Your private GitHub account (as a pull request)"

displayUrl :: T.Text -> T.Text
displayUrl url = fromMaybe url (T.stripPrefix "mailto:" url)
Expand Down Expand Up @@ -446,14 +446,19 @@ link Style{styLinks} url label
(with an ellipsis) so the whole post fits @limit@ characters. Title, url and
hashtags are never trimmed. The character count goes in the section's chrome
label rather than the body, so what you paste is exactly the post.

Twitter/X and Mastodon, the two channels using this, both count a link as
'linkWeight' characters whatever its real length, so the count does too.
-}
budgetedPost :: Int -> NewsItem -> Section
budgetedPost limit NewsItem{niTitle, niBlurb, niUrl} =
section ("post \x00b7 " <> tshow (T.length post) <> "/" <> tshow limit <> " chars") post
section ("post \x00b7 " <> tshow weightedLen <> "/" <> tshow limit <> " chars") post
where
linkWeight = 23
weightedLen = T.length post - T.length niUrl + linkWeight
tagsLine = T.unwords hashtags
sep = "\n\n" :: T.Text
fixedLen = T.length niTitle + T.length niUrl + T.length tagsLine + 3 * T.length sep
fixedLen = T.length niTitle + linkWeight + T.length tagsLine + 3 * T.length sep
blurbBudget = limit - fixedLen
blurb
| blurbBudget <= 0 = ""
Expand Down
173 changes: 70 additions & 103 deletions tools/social-crossposting/README.md
Original file line number Diff line number Diff line change
@@ -1,120 +1,87 @@
# Social cross-posting draft generator
# Social cross-posting drafts

Prints ready-to-copy-paste drafts of a news item for each external channel
(Discourse, Reddit, Twitter/X, LinkedIn, Mastodon, haskell-cafe, Haskell
Weekly). It only prints text — there is no posting, no API keys, no bot
behavior.
`social-draft` takes a news item and prints a draft post for each channel we
announce news on. You copy each draft and post it by hand. The tool never
posts anything itself and needs no API keys.

It's a second executable in the root `haskell-foundation.cabal` (not a
separate package): it re-parses the news item's front matter + first
paragraph directly with `pandoc`, the same library `site.hs` already uses,
rather than re-implementing Markdown/YAML parsing or standing up a second
build.
It is a second executable in `haskell-foundation.cabal`. It reads the news
item with `pandoc`, the library `site.hs` uses, so the blurb is the same
first paragraph the site shows in its link previews.

## Usage

Run it from the repo root, with the path to a news item or just its slug:

```bash
cabal run social-draft -- news/2026-07-18/hiw-hew-2026-videos.markdown
# or, equivalently, by slug:
cabal run social-draft -- hiw-hew-2026-videos
```

Run from the repo root. Each channel gets a colour-coded header stating its
kind (forum / microblog / mailing list / newsletter), its format and length
constraint, **which account to post from**, and a clickable link to the
channel.
Each channel starts with a header. It names the kind of channel, the format
and length limit, the account to post from, and links to where you post.

Then come the parts of the draft, such as `title`, `link`, `post` or `body`.
Each part has a dim, indented label, followed by the text itself. Copy the
text and leave the label: anything indented or coloured is not part of the
draft. The character count of a microblog post is in its label, too.

Below the header come the draft's sections — `title`, `link`, `post`, `body`,
… — each an indented dim label on its own line, then the text itself at
column 0 with blank lines around it. So a line- or block-select grabs exactly
what you paste and never a label: everything indented or coloured is chrome,
including the microblog character count, which sits in the `post` label
rather than in the post.
## Channels and accounts

## Which account
Watch the `account` line. Green means the Haskell Foundation account, red
means your own.

The header's `account` line says who should post:
| Channel | Post as |
|---|---|
| [Discourse](https://discourse.haskell.org/c/haskell-foundation) | yourself |
| [r/haskell](https://www.reddit.com/r/haskell/) | yourself |
| [Twitter/X](https://twitter.com/haskellfound) | Haskell Foundation |
| [LinkedIn](https://www.linkedin.com/company/haskell-foundation-inc) | Haskell Foundation |
| [Mastodon](https://mastodon.social/@haskell_foundation) | Haskell Foundation |
| haskell-cafe mailing list | yourself, from your own email address |
| [Haskell Weekly](https://github.com/haskellweekly/haskellweekly) | yourself, as a pull request |

- **Haskell Foundation account** — the HF owns this channel. Currently
Twitter/X ([@haskellfound](https://twitter.com/haskellfound)) and Mastodon
([@haskell_foundation](https://mastodon.social/@haskell_foundation)).
- **your PRIVATE account / email / GitHub account** — no HF identity here;
post from your own account (Discourse, Reddit), send from your own address
(haskell-cafe), or open the pull request yourself (Haskell Weekly).
- **HF or private? — unconfirmed** — LinkedIn; not yet decided, check before
posting.
Haskell Weekly lives in a Git repository. To suggest an entry, open a pull
request that adds one list item to the issue being put together
(`data/newsletter/<year>/issue-NNN.markdown`). The editors decide where it
goes, and they only want important news.

## Colour and links

Two disjoint colour families keep the roles distinct: each channel's chrome
uses a muted, cool hue, while the `account` flag uses saturated traffic-light
colours (green = HF/safe, yellow = unconfirmed, red = your private identity).

Colour and clickable links are emitted only when writing to an interactive
terminal, so piping to a file or pager gives clean plain text. Colour also
honours the standard `NO_COLOR` environment variable.

## Known rough edges (first draft)

- Hashtags are a single fixed set (`#haskell #haskellfoundation`) applied to
every microblog; not yet varied per channel or per topic.
- Twitter/Mastodon trimming cuts the blurb with an ellipsis to fit the char
budget (280 / 500). Only the blurb is ever trimmed, so a title long enough
to blow the budget on its own comes out over — visibly, since the reported
count is exact (`301/280`).
- Only a whole-post limit is enforced; Reddit's title limit is displayed as
advice, not checked.
- The Haskell Weekly entry credits the `author` front matter field verbatim,
including any role ("Bryan Richter, DevOps Engineer") — the newsletter's own
style is bare names, so trim it when it reads oddly. Without an `author`
field it credits the HF for a site article, and prints a `??` placeholder
for a link-out (whoever wrote the linked post isn't recorded in the front
matter).
- The canonical URL is the item's `link` front-matter field if present
(link-out news items), otherwise `https://haskell.foundation/<route>.html`
mirroring `site.hs`'s route for that item — it isn't computed from the
actual Hakyll build.

## Channels to serve

Forums:
- Haskell Discourse
- The Haskell subreddit (r/haskell)

Microblogs:
- Twitter (X)
- LinkedIn
- Mastodon

Mailing list:
- haskell-cafe

Newsletter:
- [Haskell Weekly](https://github.com/haskellweekly/haskellweekly) — curated
in Git, so an entry is a pull request adding one Markdown list item to the
issue being assembled (`data/newsletter/<year>/issue-NNN.markdown`). Its
editors sort entries into the issue's sections.

## Analysis — why not one "post everywhere" script

The channels split into three tiers by how automatable they *should* be,
which differs from how automatable they *can* be:

- **Easy + appropriate**: Mastodon (trivial API, access token), Discourse
(clean REST API + key; HF already announces there), haskell-cafe (just
SMTP email — but a plain-text list with etiquette; a visibly automated
blast reads poorly).
- **Possible but painful/costly**: Twitter/X (write API is now paid,
~$100+/mo basic tier), LinkedIn (API needs app review + org-page
permissions, slow and awkward to maintain).
- **Possible but socially risky**: Reddit (auto-posted self-links look
spammy, trip spam filters, cut against community norms).

A single "post everywhere" script concentrates the worst APIs, adds
key-management + maintenance burden, and risks damaging automated posts to
community spaces where tone matters. Rejected.

Haskell Weekly is the one channel where automation is an option: a pull request
is reviewed by its editors before anything is published. If it ever becomes
routine, opening that pull request could be automated (`gh pr create` against
the current issue file).
Colour and clickable links only appear in a terminal. Pipe the output to a
file or a pager and you get plain text. Set `NO_COLOR` to turn colour off.

## Limitations

- Every microblog gets the same hashtags, `#haskell #haskellfoundation`.
- On Twitter/X (280) and Mastodon (500), the tool shortens the blurb to fit,
counting the link as 23 characters like both sites do. It never shortens
the title, so a very long title can still go over. The count then shows
it, for example `301/280`.
- Reddit allows at most 300 characters in a title. The tool shows the limit
but does not check it.
- The Haskell Weekly entry names the `author` field as written, including a
role like "Bryan Richter, DevOps Engineer". The newsletter uses plain
names, so shorten it if needed. With no `author`, a site article is
credited to the Haskell Foundation. A link to someone else's post gets a
`??` instead, because the news item does not say who wrote it.
- The link is the item's `link` field if it has one. Otherwise the tool
builds `https://haskell.foundation/<path>.html` the same way `site.hs`
routes news items, without running the site build.

## Why the tool doesn't post for you

We decided against one script that posts everywhere:

- Mastodon and Discourse have simple APIs. haskell-cafe is plain email, but
people on a mailing list notice an automated post, and it reads badly.
- Twitter/X charges for posting through its API (about $100 a month).
LinkedIn needs an app review and permissions for the company page.
- On Reddit, automated posts linking to your own site look like spam and
get caught by spam filters.

So such a script would mean API keys to manage, two awkward APIs to keep
working, and posts that sound like a bot in places where tone matters.

Haskell Weekly is the exception, since its editors review every pull
request. If we suggest entries often, the tool could open the pull request
itself with `gh pr create`.
Loading