From ba001e305f5d53d09bf860a65a3b09bfa75e2c9e Mon Sep 17 00:00:00 2001 From: Dominik Schrempf Date: Sat, 18 Jul 2026 15:23:54 +0200 Subject: [PATCH 1/5] HIW/HEW videos are online --- events/2026-haskell-ecosystem-workshop.markdown | 2 +- events/2026-haskell-implementors-workshop.markdown | 5 +---- news/2025-03-24 | 1 - news/2026-07-18/hiw-hew-2026-videos.markdown | 14 ++++++++++++++ 4 files changed, 16 insertions(+), 6 deletions(-) delete mode 100644 news/2025-03-24 create mode 100644 news/2026-07-18/hiw-hew-2026-videos.markdown diff --git a/events/2026-haskell-ecosystem-workshop.markdown b/events/2026-haskell-ecosystem-workshop.markdown index 9baf8b93..0d4a6bfa 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 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/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..7ccfcc26 --- /dev/null +++ b/news/2026-07-18/hiw-hew-2026-videos.markdown @@ -0,0 +1,14 @@ +--- +title: Haskell Implementors' Workshop 2026 and Haskell Ecosystem Workshop 2026 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. + +- [Haskell Implementors' Workshop 2026 playlist](https://www.youtube.com/playlist?list=PLV9Q98f69SkA) +- [Haskell Ecosystem Workshop 2026 playlist](https://www.youtube.com/playlist?list=PLApZHRDK3cFc) + +From GHC internals and tooling to hard-won lessons running Haskell at scale, +there's something for everyone. Enjoy! From e1ac6395035a2192d05c643b8e3e264d9a992ad5 Mon Sep 17 00:00:00 2001 From: Dominik Schrempf Date: Mon, 20 Jul 2026 13:34:21 +0200 Subject: [PATCH 2/5] Create news-pages, make the HIW/HEW announcement a proper page --- .../2026-haskell-ecosystem-workshop.markdown | 2 +- news/2026-07-18/hiw-hew-2026-videos.markdown | 56 +++++++++++++++-- site.hs | 62 +++++++------------ templates/news/frontpage.html | 8 ++- templates/news/page.html | 3 + templates/news/tile.html | 8 ++- 6 files changed, 87 insertions(+), 52 deletions(-) diff --git a/events/2026-haskell-ecosystem-workshop.markdown b/events/2026-haskell-ecosystem-workshop.markdown index 0d4a6bfa..7641a181 100644 --- a/events/2026-haskell-ecosystem-workshop.markdown +++ b/events/2026-haskell-ecosystem-workshop.markdown @@ -54,7 +54,7 @@ Please make sure that you have [checked out and built](https://gitlab.haskell.or - + diff --git a/news/2026-07-18/hiw-hew-2026-videos.markdown b/news/2026-07-18/hiw-hew-2026-videos.markdown index 7ccfcc26..07a52925 100644 --- a/news/2026-07-18/hiw-hew-2026-videos.markdown +++ b/news/2026-07-18/hiw-hew-2026-videos.markdown @@ -1,14 +1,58 @@ --- -title: Haskell Implementors' Workshop 2026 and Haskell Ecosystem Workshop 2026 Videos Now Online +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. +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 2026 playlist](https://www.youtube.com/playlist?list=PLV9Q98f69SkA) -- [Haskell Ecosystem Workshop 2026 playlist](https://www.youtube.com/playlist?list=PLApZHRDK3cFc) +## Haskell Implementors' Workshop -From GHC internals and tooling to hard-won lessons running Haskell at scale, -there's something for everyone. Enjoy! +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..57d8dc52 100644 --- a/site.hs +++ b/site.hs @@ -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 @@ -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 @@ -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/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)$
+ $if(link)$ >> Read more + $else$ + >> Read more + $endif$
- $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$
+

$teaser$

- $if(link)$
+ $if(link)$ >> Read more + $else$ + >> Read more + $endif$
- $endif$ From 7bd4542805b198705a9956b97d32d5ade20e7a6d Mon Sep 17 00:00:00 2001 From: Dominik Schrempf Date: Thu, 23 Jul 2026 10:41:59 +0200 Subject: [PATCH 3/5] Add social cross-post script For details, see tools/social-crossposting/README.md. --- .github/workflows/main.yml | 6 +- haskell-foundation.cabal | 12 +- stack.yaml | 7 +- tools/social-crossposting/Main.hs | 179 ++++++++++++++++++++++++++++ tools/social-crossposting/README.md | 67 +++++++++++ 5 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 tools/social-crossposting/Main.hs create mode 100644 tools/social-crossposting/README.md 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/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/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/tools/social-crossposting/Main.hs b/tools/social-crossposting/Main.hs new file mode 100644 index 00000000..1e71d41b --- /dev/null +++ b/tools/social-crossposting/Main.hs @@ -0,0 +1,179 @@ +{-# 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) +import qualified Data.Text as T +import System.Directory (doesFileExist, listDirectory) +import System.Environment (getArgs) +import System.Exit (die) +import System.FilePath (dropExtension, (<.>), ()) +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 + putStr (renderDrafts 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 + +renderDrafts :: NewsItem -> String +renderDrafts item = + unlines . intercalate [""] $ + [ channel "Discourse" (discourseDraft item) + , channel "Reddit" (redditDraft item) + , channel "Twitter/X" (twitterDraft item) + , channel "LinkedIn" (linkedInDraft item) + , channel "Mastodon" (mastodonDraft item) + , channel "haskell-cafe" (haskellCafeDraft item) + ] + where + channel name body = ("=== " ++ name ++ " ===") : lines (T.unpack body) + +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] + +twitterDraft :: NewsItem -> T.Text +twitterDraft NewsItem{niTitle, niBlurb, niUrl} = + withBudget 280 niTitle niBlurb niUrl hashtags + +mastodonDraft :: NewsItem -> T.Text +mastodonDraft NewsItem{niTitle, niBlurb, niUrl} = + withBudget 500 niTitle niBlurb niUrl hashtags + +-- | 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..d2a4c551 --- /dev/null +++ b/tools/social-crossposting/README.md @@ -0,0 +1,67 @@ +# 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. Output is grouped under `=== Channel ===` headers; +copy each block into the corresponding channel by hand. + +## 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. From 3c5ea1dbc191f2d72b569716916daffe57926de6 Mon Sep 17 00:00:00 2001 From: Dominik Schrempf Date: Thu, 23 Jul 2026 11:14:20 +0200 Subject: [PATCH 4/5] Fix compilation warnings --- site.hs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site.hs b/site.hs index 57d8dc52..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), @@ -241,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 @@ -551,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 From 2878e1f31e390ac861401b5e6b29703dcea631de Mon Sep 17 00:00:00 2001 From: Dominik Schrempf Date: Thu, 23 Jul 2026 11:32:17 +0200 Subject: [PATCH 5/5] Improve user-experience of social cross-posting script --- contact/index.html | 2 +- templates/footer.html | 2 +- tools/social-crossposting/Main.hs | 197 +++++++++++++++++++++++++--- tools/social-crossposting/README.md | 30 ++++- 4 files changed, 206 insertions(+), 25 deletions(-) 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/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/tools/social-crossposting/Main.hs b/tools/social-crossposting/Main.hs index 1e71d41b..555d63d9 100644 --- a/tools/social-crossposting/Main.hs +++ b/tools/social-crossposting/Main.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -11,12 +12,13 @@ module Main (main) where import Control.Monad (filterM) import Data.List (intercalate) -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isNothing) import qualified Data.Text as T import System.Directory (doesFileExist, listDirectory) -import System.Environment (getArgs) +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), @@ -60,7 +62,8 @@ main = do _ -> die "usage: social-draft " path <- resolvePath arg item <- loadNewsItem path - putStr (renderDrafts item) + 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. @@ -111,18 +114,178 @@ plainFirstPara blocks = case runPure (writePlain plainOpts (Pandoc nullMeta (fir firstPara (b@Plain{} : _) = [b] firstPara (_ : bs) = firstPara bs -renderDrafts :: NewsItem -> String -renderDrafts item = - unlines . intercalate [""] $ - [ channel "Discourse" (discourseDraft item) - , channel "Reddit" (redditDraft item) - , channel "Twitter/X" (twitterDraft item) - , channel "LinkedIn" (linkedInDraft item) - , channel "Mastodon" (mastodonDraft item) - , channel "haskell-cafe" (haskellCafeDraft item) +-- 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 - channel name body = ("=== " ++ name ++ " ===") : lines (T.unpack body) + -- 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} = @@ -154,14 +317,6 @@ haskellCafeDraft :: NewsItem -> T.Text haskellCafeDraft NewsItem{niTitle, niBlurb, niUrl} = T.unlines ["Subject: " <> niTitle, "", niBlurb, "", niUrl] -twitterDraft :: NewsItem -> T.Text -twitterDraft NewsItem{niTitle, niBlurb, niUrl} = - withBudget 280 niTitle niBlurb niUrl hashtags - -mastodonDraft :: NewsItem -> T.Text -mastodonDraft NewsItem{niTitle, niBlurb, niUrl} = - withBudget 500 niTitle niBlurb niUrl hashtags - -- | 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. diff --git a/tools/social-crossposting/README.md b/tools/social-crossposting/README.md index d2a4c551..2bcb8241 100644 --- a/tools/social-crossposting/README.md +++ b/tools/social-crossposting/README.md @@ -18,8 +18,34 @@ cabal run social-draft -- news/2026-07-18/hiw-hew-2026-videos.markdown cabal run social-draft -- hiw-hew-2026-videos ``` -Run from the repo root. Output is grouped under `=== Channel ===` headers; -copy each block into the corresponding channel by hand. +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)
2025-06-05
2026-06-04
8:30-9:00 Registration
9:00-9:20 Welcome and IntroFarhad Mehta and Laurent P. René de Cotret
9:20-10:20 Optimising for fast builds with GHCTeo Camarasu