diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 33a27f25..8d0aca7b 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -31,8 +31,8 @@ jobs:
strategy:
matrix:
- stack: ["2.7.3"]
- ghc: ["8.10.7"]
+ stack: ["3.11.1"]
+ ghc: ["9.10.3"]
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
@@ -71,7 +71,7 @@ jobs:
path: ~/.stack
key: ${{ runner.os }}-${{ matrix.ghc }}-${{ github.sha }}
- - name: Build site executable
+ - name: Build site executables
run: stack build --system-ghc
- name: Build site
diff --git a/contact/index.html b/contact/index.html
index 6425cb65..fa187d04 100644
--- a/contact/index.html
+++ b/contact/index.html
@@ -85,7 +85,7 @@
HF Discourse
>> Meeting Minutes
diff --git a/events/2026-haskell-ecosystem-workshop.markdown b/events/2026-haskell-ecosystem-workshop.markdown
index 9baf8b93..7641a181 100644
--- a/events/2026-haskell-ecosystem-workshop.markdown
+++ b/events/2026-haskell-ecosystem-workshop.markdown
@@ -24,7 +24,7 @@ The registration link is available [on Eventbrite](https://www.eventbrite.com/e/
## Video Recordings
-Recordings will be made available shortly after the event. Last year's recordings [are available on YouTube](https://www.youtube.com/playlist?list=PLQpeDZt0_xQe319u9EdkpxjibYFtGsugc).
+The recordings [are available online](https://www.youtube.com/playlist?list=PLApZHRDK3cFc).
## The Workshop
@@ -54,7 +54,7 @@ Please make sure that you have [checked out and built](https://gitlab.haskell.or
-| 2025-06-05 |
+| 2026-06-04 |
| 8:30-9:00 | Registration | |
| 9:00-9:20 | Welcome and Intro | Farhad Mehta and Laurent P. René de Cotret |
| 9:20-10:20 | Optimising for fast builds with GHC | Teo Camarasu |
diff --git a/events/2026-haskell-implementors-workshop.markdown b/events/2026-haskell-implementors-workshop.markdown
index f840afaf..b37440cf 100644
--- a/events/2026-haskell-implementors-workshop.markdown
+++ b/events/2026-haskell-implementors-workshop.markdown
@@ -161,10 +161,7 @@ Coffee, tea, and fruit will be provided. There is also easy access to a tap for
### Video Recordings and Live Streaming
-The event will be recorded.
-
+The recordings [are available online](https://www.youtube.com/playlist?list=PLV9Q98f69SkA).
## Call for Proposals
diff --git a/haskell-foundation.cabal b/haskell-foundation.cabal
index c3a41391..7e5689c3 100644
--- a/haskell-foundation.cabal
+++ b/haskell-foundation.cabal
@@ -9,7 +9,17 @@ executable site
, hakyll ^>= 4.14 || ^>= 4.15 || ^>= 4.16
, monadlist
, pandoc >=2.11 && <3.8
- , filepath ^>= 1.4
+ , filepath ^>= 1.5
, text
ghc-options: -threaded
default-language: Haskell2010
+
+executable social-draft
+ main-is: Main.hs
+ hs-source-dirs: tools/social-crossposting
+ build-depends: base == 4.*
+ , directory
+ , filepath ^>= 1.5
+ , pandoc >=2.11 && <3.8
+ , text
+ default-language: Haskell2010
diff --git a/news/2025-03-24 b/news/2025-03-24
deleted file mode 100644
index 8b137891..00000000
--- a/news/2025-03-24
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/news/2026-07-18/hiw-hew-2026-videos.markdown b/news/2026-07-18/hiw-hew-2026-videos.markdown
new file mode 100644
index 00000000..07a52925
--- /dev/null
+++ b/news/2026-07-18/hiw-hew-2026-videos.markdown
@@ -0,0 +1,58 @@
+---
+title: 2026 Haskell Workshop Videos Now Online
+---
+
+Couldn't make it to Rapperswil, or want to revisit a talk? The recordings from
+this year's [Haskell Implementors' Workshop](/events/2026-haskell-implementors-workshop.html)
+and [Haskell Ecosystem Workshop](/events/2026-haskell-ecosystem-workshop.html)
+are now available online. Both workshops were held on the lakeside campus of
+the University of Applied Sciences of Eastern Switzerland (OST) in Rapperswil
+on June 4th and 5th, alongside ZuriHac, and hosted by the Haskell Foundation.
+
+## Haskell Implementors' Workshop
+
+The Haskell Implementors' Workshop is a forum for everyone interested in the
+design and development of Haskell implementations, tools, libraries, and
+supporting infrastructure — whether you build them or just want to follow
+along. Watch the
+[Haskell Implementors' Workshop 2026 playlist](https://www.youtube.com/playlist?list=PLV9Q98f69SkA).
+
+This year's program ranged from the state of the compiler to concrete tooling
+you can use today:
+
+- **GHC Status Report** — Andreas Klebinger
+- **Stable Haskell**, on making GHC easier to build and ship — Julian Ospald
+- **MicroHs Dead or Alive? — A status report** — Lennart Augustsson
+- **Lightweight profiling with `ghc-stack-profiler`** — Hannes Siebenhandl
+- **Evolving the Haskell Debugger** — Rodrigo Mesquita
+- **Is There a Future for a Formally Specified Haskell Report** — David Binder
+- **Towards better implicit parameters** — Lorenzo Tabacchini
+- **GHC String interpolation** — Brandon Chinn
+
+The recordings also include a round of lightning talks. See the [workshop
+page](/events/2026-haskell-implementors-workshop.html) for the full schedule and
+abstracts.
+
+## Haskell Ecosystem Workshop
+
+The Haskell Ecosystem Workshop is for everyone interested in using Haskell to
+build real-world tools and applications — from side projects to large
+production systems. Speakers share hard-won lessons on developing and
+maintaining Haskell software, and stay around during ZuriHac to answer
+questions and dig into the details. Watch the
+[Haskell Ecosystem Workshop 2026 playlist](https://www.youtube.com/playlist?list=PLApZHRDK3cFc).
+
+The talks:
+
+- **Optimising for fast builds with GHC** — Teo Camarasu
+- **Servant at Bitnomial — Expanding type-safety from applications to systems** — Laurent P. René de Cotret
+- **Why you should want to contribute to the Haskell Ecosystem** — Andreas Klebinger
+- **Flora** — Théophile Choutri de Tarle
+- **50,000 Lines of LLM Haskell: What Broke, What Held, and Letting Go** — Jappie Klooster
+- **Stackage: a community Haskell source package distribution** — Jens Petersen
+- **Haskell Security** — Fraser Tweedale (published at a later date)
+
+The full program is on the
+[workshop page](/events/2026-haskell-ecosystem-workshop.html).
+
+Enjoy!
diff --git a/site.hs b/site.hs
index 65ba8b44..47de7269 100644
--- a/site.hs
+++ b/site.hs
@@ -6,13 +6,13 @@
import Control.Monad (filterM, guard)
import Control.Monad.ListM (sortByM)
import Data.List (sortOn, isPrefixOf)
-import Data.Maybe (fromJust, fromMaybe, isJust)
+import Data.Maybe (fromMaybe, isJust)
import qualified Data.Text as T
import Hakyll
import Hakyll.Web.Html.RelativizeUrls (relativizeUrls)
import Hakyll.Web.Template (loadAndApplyTemplate)
import Hakyll.Web.Template.Context (defaultContext)
-import System.FilePath (dropExtension, splitFileName, takeBaseName, (>))
+import System.FilePath (dropExtension, splitFileName, takeBaseName, takeDirectory, takeFileName, (>))
import System.IO (SeekMode (RelativeSeek))
import Text.Pandoc as Pandoc (
Block (Para, Plain),
@@ -130,13 +130,25 @@ main = hakyllWith config $ do
>>= relativizeUrls
-- news ------------------------------------------------------------------------------------------------
- match "news/**.markdown" $ compile pandocCompiler
+ -- An entry is either a full article (own page) or a headline linking out
+ -- (front matter `link`, no own page). `hasLink` splits the two.
+ let hasLink = isJust . lookupString "link"
+ matchMetadata "news/**.markdown" (not . hasLink) $ do
+ route $ setExtension "html"
+ compile $ do
+ sponsors <- buildBoilerplateCtx Nothing
+ pandocCompiler
+ >>= loadAndApplyTemplate "templates/news/page.html" newsItemCtx
+ >>= loadAndApplyTemplate "templates/boilerplate.html" sponsors
+ >>= relativizeUrls
+ matchMetadata "news/**.markdown" hasLink $ compile pandocCompiler
+
categories <- buildCategories "news/**.markdown" (fromCapture "news/categories/**.html")
tagsRules categories $ \category catId -> compile $ do
news <- recentFirst =<< loadAll catId
let ctx =
- listField "news" (newsWithCategoriesCtx categories) (pure news)
+ listField "news" newsItemCtx (pure news)
<> dateField "category" "%B %e, %Y"
<> defaultContext
@@ -159,19 +171,6 @@ main = hakyllWith config $ do
>>= loadAndApplyTemplate "templates/boilerplate.html" sponsors
>>= relativizeUrls
- match "news/*.markdown" $ do
- route $ setExtension "html"
- let ctxt =
- mconcat
- [defaultContext]
- compile $ do
- sponsors <- buildBoilerplateCtx Nothing
- pandocCompiler
- >>= applyAsTemplate sponsors
- >>= loadAndApplyTemplate "templates/news/page.html" ctxt
- >>= loadAndApplyTemplate "templates/boilerplate.html" sponsors
- >>= relativizeUrls
-
-- press -----------------------------------------------------------------------------------------------
match "press/**.markdown" $ compile pandocCompiler
create ["news/press/index.html"] $ do
@@ -242,8 +241,8 @@ main = hakyllWith config $ do
route $ setExtension "html"
compile $ do
sponsors <- buildBoilerplateCtx Nothing
- -- extract the captures path fragment. really no easier way?
- episode <- head . fromJust . capture "podcast/*/index.markdown" <$> getUnderlying
+ -- the episode slug is the directory name in podcast//index.markdown
+ episode <- takeFileName . takeDirectory . toFilePath <$> getUnderlying
let ctxt =
mconcat
@@ -500,30 +499,15 @@ slugField name =
-- news ------------------------------------------------------------------------------------------------
--- | build group of news inside date of publishing (category)
-newsWithCategoriesCtx :: Tags -> Context String
-newsWithCategoriesCtx categories =
- listField "categories" categoryCtx getAllCategories
+-- | Context for a single news entry, shared by the index tiles, the homepage
+-- announcement and the standalone article page. `teaser` is the entry's first
+-- paragraph as plain text (from the "description" version), used as a preview
+-- so the index links to the full article rather than inlining it.
+newsItemCtx :: Context String
+newsItemCtx =
+ field "teaser" (loadBody . setVersion (Just "description") . itemIdentifier)
+ <> dateField "date" "%B %e, %Y"
<> defaultContext
- where
- getAllCategories :: Compiler [Item (String, [Identifier])]
- getAllCategories = pure . map buildItemFromTag $ tagsMap categories
- where
- buildItemFromTag :: (String, [Identifier]) -> Item (String, [Identifier])
- buildItemFromTag c@(name, _) = Item (tagsMakeId categories name) c
- categoryCtx :: Context (String, [Identifier])
- categoryCtx =
- listFieldWith "news" newsCtx getNews
- <> metadataField
- <> urlField "link"
- <> pathField "path"
- <> titleField "title"
- <> missingField
- where
- getNews :: Item (String, [Identifier]) -> Compiler [Item String]
- getNews (itemBody -> (_, ids)) = mapM load ids
- newsCtx :: Context String
- newsCtx = newsWithCategoriesCtx categories
-- faq -------------------------------------------------------------------------------------------------
faqCtx :: [Item String] -> Context String
@@ -567,7 +551,7 @@ podcastListCtx episodes =
<> defaultContext
podcastOrd :: Item String -> Integer
-podcastOrd = read . head . fromJust . capture "podcast/*/index.markdown" . itemIdentifier
+podcastOrd = read . takeFileName . takeDirectory . toFilePath . itemIdentifier
-- careers ---------------------------------------------------------------------------------------------
careersCtx :: [Item String] -> Context String
@@ -585,7 +569,7 @@ hiringSponsorsCtx sponsors =
announcementsCtx :: [Item String] -> Context String
announcementsCtx ads =
- listField "announcements" defaultContext (pure ads)
+ listField "announcements" newsItemCtx (pure ads)
-- Events
diff --git a/stack.yaml b/stack.yaml
index c24316fb..747b8284 100644
--- a/stack.yaml
+++ b/stack.yaml
@@ -15,7 +15,7 @@
# resolver:
# name: custom-snapshot
# location: "./custom-snapshot.yaml"
-resolver: lts-18.28
+resolver: lts-24.51
# User packages to be built.
# Various formats can be used as shown in the example below.
@@ -39,10 +39,7 @@ packages:
- '.'
# Dependency packages to be pulled from upstream that are not in the resolver
# (e.g., acme-missiles-0.3)
-extra-deps: [
- hakyll-4.14.1.0
- , lrucache-1.2.0.1
-]
+extra-deps: []
# Override default flag values for local packages and extra-deps
flags: {}
diff --git a/templates/footer.html b/templates/footer.html
index 58f2cc1d..b5263c34 100644
--- a/templates/footer.html
+++ b/templates/footer.html
@@ -43,7 +43,7 @@
-
+
diff --git a/templates/news/frontpage.html b/templates/news/frontpage.html
index 22ee29c2..f77101c4 100644
--- a/templates/news/frontpage.html
+++ b/templates/news/frontpage.html
@@ -2,13 +2,15 @@
$title$
- $body$
+
$teaser$
- $if(link)$
- $endif$
diff --git a/templates/news/page.html b/templates/news/page.html
index 82e98585..968bd111 100644
--- a/templates/news/page.html
+++ b/templates/news/page.html
@@ -2,6 +2,9 @@
$title$
+
+ $if(author)$$author$, $endif$$date$
+
diff --git a/templates/news/tile.html b/templates/news/tile.html
index faba235c..e751eca4 100644
--- a/templates/news/tile.html
+++ b/templates/news/tile.html
@@ -18,13 +18,15 @@ $title$
$author$
$endif$
- $body$
+
- $if(link)$
- $endif$
diff --git a/tools/social-crossposting/Main.hs b/tools/social-crossposting/Main.hs
new file mode 100644
index 00000000..555d63d9
--- /dev/null
+++ b/tools/social-crossposting/Main.hs
@@ -0,0 +1,334 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Draft generator for cross-posting a news item to social channels.
+
+Reads a news item's front matter + first paragraph (the same source the
+site itself uses for its OpenGraph description) and prints a ready-to-copy
+draft per channel. It only prints text -- there is no posting, no API keys.
+-}
+module Main (main) where
+
+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.Environment (getArgs, lookupEnv)
+import System.Exit (die)
+import System.FilePath (dropExtension, (<.>), (>))
+import System.IO (hIsTerminalDevice, stdout)
+import Text.Pandoc (
+ Block (Para, Plain),
+ Extension (Ext_yaml_metadata_block),
+ Meta,
+ Pandoc (..),
+ ReaderOptions (readerExtensions),
+ WrapOption (WrapNone),
+ WriterOptions (writerWrapText),
+ def,
+ enableExtension,
+ lookupMeta,
+ nullMeta,
+ pandocExtensions,
+ readMarkdown,
+ runPure,
+ writePlain,
+ )
+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 :: [T.Text]
+hashtags = ["#haskell", "#haskellfoundation"]
+
+data NewsItem = NewsItem
+ { niTitle :: T.Text
+ , niBlurb :: T.Text
+ -- ^ First paragraph, plain text.
+ , niUrl :: T.Text
+ -- ^ Canonical site URL, or the external `link` for a link-out item.
+ }
+
+main :: IO ()
+main = do
+ args <- getArgs
+ arg <- case args of
+ [a] -> pure a
+ _ -> die "usage: social-draft "
+ path <- resolvePath arg
+ item <- loadNewsItem path
+ style <- detectStyle
+ 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.
+resolvePath :: FilePath -> IO FilePath
+resolvePath arg = do
+ isFile <- doesFileExist arg
+ if isFile
+ then pure arg
+ else do
+ dates <- listDirectory "news"
+ let candidates = [("news" > d > arg) <.> "markdown" | d <- dates]
+ matches <- filterM doesFileExist candidates
+ case matches of
+ [m] -> pure m
+ [] -> die ("no news item found for path or slug: " ++ arg)
+ ms -> die ("ambiguous slug " ++ arg ++ ", matches: " ++ intercalate ", " ms)
+
+loadNewsItem :: FilePath -> IO NewsItem
+loadNewsItem path = do
+ src <- T.pack <$> readFile path
+ let readerOpts = def{readerExtensions = enableExtension Ext_yaml_metadata_block pandocExtensions}
+ Pandoc meta blocks <- case runPure (readMarkdown readerOpts src) of
+ Left err -> die (path ++ ": " ++ show err)
+ Right doc -> pure doc
+ title <- case metaText meta "title" of
+ Just t -> pure t
+ Nothing -> die (path ++ ": missing 'title' front matter")
+ let url = fromMaybe (T.pack (siteRoot > dropExtension path <.> "html")) (metaText meta "link")
+ blurb = plainFirstPara blocks
+ pure NewsItem{niTitle = title, niBlurb = blurb, niUrl = url}
+
+metaText :: Meta -> T.Text -> Maybe T.Text
+metaText meta key = stringify <$> lookupMeta key meta
+
+-- | Render a document's first paragraph as plain text. Mirrors
+-- `firstPara`/`pandocPlainCompiler` in site.hs, minus the Hakyll `Item`/
+-- `Compiler` plumbing that isn't available outside a site build.
+plainFirstPara :: [Block] -> T.Text
+plainFirstPara blocks = case runPure (writePlain plainOpts (Pandoc nullMeta (firstPara blocks))) of
+ Left err -> error ("writePlain: " ++ show err)
+ Right t -> T.strip t
+ where
+ -- WrapNone: keep the blurb on one line, not hard-wrapped at 72 cols,
+ -- so it pastes cleanly into a microblog post.
+ plainOpts = def{writerWrapText = WrapNone}
+ firstPara [] = []
+ firstPara (b@Para{} : _) = [b]
+ firstPara (b@Plain{} : _) = [b]
+ firstPara (_ : bs) = firstPara bs
+
+-- PRESENTATION -------------------------------------------------------------
+
+-- | Terminal decoration switches, resolved once at startup. Colour and
+-- clickable links are emitted only for an interactive terminal, so piping
+-- to a file/pager yields clean plain text (the drafts stay copy-pasteable
+-- either way -- decoration lives only in the chrome, never in a draft body).
+data Style = Style
+ { styColor :: Bool
+ , styLinks :: Bool
+ }
+
+-- | Colour off when stdout is not a TTY or the standard @NO_COLOR@ env var is
+-- set; links off when not a TTY.
+detectStyle :: IO Style
+detectStyle = do
+ tty <- hIsTerminalDevice stdout
+ noColor <- lookupEnv "NO_COLOR"
+ pure Style{styColor = tty && isNothing noColor, styLinks = tty}
+
+-- | Which account/identity to post from. This is the main thing a human has
+-- to get right, so each channel states it explicitly.
+data PostAs
+ = AsHF
+ | AsPrivate
+ | AsPrivateEmail
+ | AsUnknown
+
+data Channel = Channel
+ { chName :: T.Text
+ , chKind :: T.Text
+ -- ^ "forum" / "microblog" / "mailing list".
+ , chConstraint :: T.Text
+ -- ^ Short note on the channel's main posting constraint (character
+ -- limit, format, etiquette), shown next to the kind. For a hard limit
+ -- that the draft also enforces, use the 'microblog' smart constructor so
+ -- the displayed number and the enforced budget share one source.
+ , chUrl :: T.Text
+ -- ^ Where to post; rendered as a clickable link.
+ , chAccount :: PostAs
+ , chColor :: Int
+ -- ^ 256-colour foreground code for this channel's chrome. Drawn from a
+ -- cool, muted palette that is disjoint from the saturated traffic-light
+ -- colours 'accountText' uses, so channel hue never clashes with the
+ -- "who posts this" flag.
+ , chDraft :: NewsItem -> T.Text
+ }
+
+-- | A microblog with a hard character limit. The limit feeds both the
+-- displayed constraint and the draft's budget, so the two can't drift.
+microblog :: T.Text -> T.Text -> PostAs -> Int -> Int -> Channel
+microblog name url account color limit =
+ Channel
+ { chName = name
+ , chKind = "microblog"
+ , chConstraint = "plain text \x00b7 \x2264 " <> T.pack (show limit) <> " chars"
+ , chUrl = url
+ , chAccount = account
+ , chColor = color
+ , chDraft = \NewsItem{niTitle, niBlurb, niUrl} -> withBudget limit niTitle niBlurb niUrl hashtags
+ }
+
+channels :: [Channel]
+channels =
+ [ Channel
+ { chName = "Discourse"
+ , chKind = "forum"
+ , chConstraint = "Markdown \x00b7 no length limit"
+ , chUrl = "https://discourse.haskell.org/c/haskell-foundation"
+ , chAccount = AsPrivate
+ , chColor = 74
+ , chDraft = discourseDraft
+ }
+ , Channel
+ { chName = "Reddit"
+ , chKind = "forum"
+ , chConstraint = "Markdown \x00b7 title \x2264 300"
+ , chUrl = "https://www.reddit.com/r/haskell/"
+ , chAccount = AsPrivate
+ , chColor = 173
+ , chDraft = redditDraft
+ }
+ , microblog "Twitter/X" "https://twitter.com/haskellfound" AsHF 80 280
+ , Channel
+ { chName = "LinkedIn"
+ , chKind = "microblog"
+ , chConstraint = "plain text \x00b7 \x2264 3000 chars"
+ , chUrl = "https://www.linkedin.com/company/haskell-foundation-inc"
+ , chAccount = AsUnknown
+ , chColor = 62
+ , chDraft = linkedInDraft
+ }
+ , microblog "Mastodon" "https://mastodon.social/@haskell_foundation" AsHF 140 500
+ , Channel
+ { chName = "haskell-cafe"
+ , chKind = "mailing list"
+ , chConstraint = "plain text \x00b7 list etiquette"
+ , chUrl = "mailto:haskell-cafe@haskell.org"
+ , chAccount = AsPrivateEmail
+ , chColor = 66
+ , chDraft = haskellCafeDraft
+ }
+ ]
+
+renderDrafts :: Style -> NewsItem -> String
+renderDrafts s item = T.unpack . T.intercalate "\n\n" $ banner : map (renderChannel s item) channels
+ where
+ banner =
+ T.intercalate
+ "\n"
+ [ dim s "Social drafts for"
+ , bold s (niTitle item)
+ , link s (niUrl item) (dim s (niUrl item))
+ ]
+
+renderChannel :: Style -> NewsItem -> Channel -> T.Text
+renderChannel s item Channel{chName, chKind, chConstraint, chUrl, chAccount, chColor, chDraft} =
+ T.intercalate
+ "\n"
+ [ boldFg s chColor ("\x2501\x2501\x2501 " <> chName <> " ") <> fg s chColor (T.replicate (max 3 (24 - T.length chName)) "\x2501") <> " " <> dim s chKind <> constraint
+ , " " <> dim s "account " <> accountText s chAccount
+ , " " <> dim s "post at " <> link s chUrl (fg s chColor (displayUrl chUrl))
+ , ""
+ , T.stripEnd (chDraft item)
+ ]
+ where
+ -- Kind stays dim; the constraint gets the channel's own hue so the
+ -- useful limit reads a touch louder without leaving the channel palette.
+ constraint
+ | T.null chConstraint = ""
+ | otherwise = dim s " \x00b7 " <> fg s chColor chConstraint
+
+-- | 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').
+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"
+ AsUnknown -> boldFg s 220 "HF or private? \x2014 unconfirmed, check before posting"
+
+displayUrl :: T.Text -> T.Text
+displayUrl url = fromMaybe url (T.stripPrefix "mailto:" url)
+
+-- ANSI helpers. Each is a no-op when the corresponding Style flag is off.
+
+sgr :: Style -> [Int] -> T.Text -> T.Text
+sgr Style{styColor} codes t
+ | styColor = "\ESC[" <> T.intercalate ";" (map (T.pack . show) codes) <> "m" <> t <> "\ESC[0m"
+ | otherwise = t
+
+bold :: Style -> T.Text -> T.Text
+bold s = sgr s [1]
+
+dim :: Style -> T.Text -> T.Text
+dim s = sgr s [2]
+
+fg :: Style -> Int -> T.Text -> T.Text
+fg s n = sgr s [38, 5, n]
+
+boldFg :: Style -> Int -> T.Text -> T.Text
+boldFg s n = sgr s [1, 38, 5, n]
+
+-- | Wrap @label@ in an OSC 8 hyperlink escape so it's clickable in modern
+-- terminals; plain @label@ otherwise.
+link :: Style -> T.Text -> T.Text -> T.Text
+link Style{styLinks} url label
+ | styLinks = "\ESC]8;;" <> url <> "\ESC\\" <> label <> "\ESC]8;;\ESC\\"
+ | otherwise = label
+
+-- DRAFTS ---------------------------------------------------------------------
+
+discourseDraft :: NewsItem -> T.Text
+discourseDraft NewsItem{niTitle, niBlurb, niUrl} =
+ T.unlines ["# " <> niTitle, "", niBlurb, "", niUrl]
+
+redditDraft :: NewsItem -> T.Text
+redditDraft NewsItem{niTitle, niBlurb, niUrl} =
+ T.unlines
+ [ "Title: " <> niTitle
+ , "Link: " <> niUrl
+ , ""
+ , "Suggested first comment:"
+ , niBlurb
+ ]
+
+linkedInDraft :: NewsItem -> T.Text
+linkedInDraft NewsItem{niTitle, niBlurb, niUrl} =
+ T.unlines
+ [ niTitle
+ , ""
+ , niBlurb
+ , ""
+ , "Read more: " <> niUrl
+ , ""
+ , T.unwords hashtags
+ ]
+
+haskellCafeDraft :: NewsItem -> T.Text
+haskellCafeDraft NewsItem{niTitle, niBlurb, niUrl} =
+ T.unlines ["Subject: " <> niTitle, "", niBlurb, "", niUrl]
+
+-- | Assemble title/blurb/url/hashtags on their own lines, trimming the
+-- blurb (with an ellipsis) so the whole post fits `budget` characters.
+-- Title, url and hashtags are never trimmed.
+withBudget :: Int -> T.Text -> T.Text -> T.Text -> [T.Text] -> T.Text
+withBudget budget title blurb url tags =
+ let tagsLine = T.unwords tags
+ sep = "\n\n" :: T.Text
+ fixedLen = T.length title + T.length url + T.length tagsLine + 3 * T.length sep
+ blurbBudget = budget - fixedLen
+ blurb'
+ | blurbBudget <= 0 = ""
+ | T.length blurb <= blurbBudget = blurb
+ | otherwise = T.stripEnd (T.take (blurbBudget - 1) blurb) <> "\x2026"
+ post = T.intercalate sep (filter (not . T.null) [title, blurb', url, tagsLine])
+ in post <> T.pack ("\n(" ++ show (T.length post) ++ "/" ++ show budget ++ " chars)")
diff --git a/tools/social-crossposting/README.md b/tools/social-crossposting/README.md
new file mode 100644
index 00000000..2bcb8241
--- /dev/null
+++ b/tools/social-crossposting/README.md
@@ -0,0 +1,93 @@
+# Social cross-posting draft generator
+
+Prints ready-to-copy-paste drafts of a news item for each external channel
+(Discourse, Reddit, Twitter/X, LinkedIn, Mastodon, haskell-cafe). It only prints
+text — there is no posting, no API keys, no bot behavior.
+
+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.
+
+## Usage
+
+```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), **which account to post from**, and
+a clickable link to the channel; below it is the plain draft body to copy.
+
+Copy only the draft body (between the header and the next header) — the
+coloured chrome is never part of what you paste.
+
+## Which account
+
+The header's `account` line says who should post:
+
+- **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** — no HF identity here; post from your own
+ account (Discourse, Reddit) or send from your own address (haskell-cafe).
+- **HF or private? — unconfirmed** — LinkedIn; not yet decided, check before
+ posting.
+
+## 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); the reported char count is a slight overestimate when
+ the blurb is dropped entirely, so it will never come in over budget.
+- The canonical URL is the item's `link` front-matter field if present
+ (link-out news items), otherwise `https://haskell.foundation/.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
+
+## 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.