diff --git a/README.md b/README.md index 0dc5a44..55f6ce4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,32 @@ We're using [elm-test-rs](https://github.com/mpizenberg/elm-test-rs) to run [elm - About Us profile text is in `Copy.AboutUs` - Page templates for each `Route` are defined in e.g. `Page.Index` and `Page.CaseStudy` +### Writing blog posts + +All blog posts are just markdown text files in `/blog-posts`. When you have your content +written just copy an existing post file and replace that content/meta data as needed. + +Then run `npm run build_blog`. This will validate that all post files have the correct +meta fields and output a `blog-data.json` payload. You can then reload your local server +and the new post should appear at the front of the list. + +There is also the RSS feed output file written to `blog-feed.rss`. + +Any added posts and changes to the payload or feed files should be pushed to main branch +to make live on the public site. + +The meta fields are (currently): +- `author` Who wrote it. Can be any string. +- `title` The title of the post. Don't put the title in the content. Also appears in the + browser title field. +- `publish_date` Must be in YYYY-MM-DD format. +- `teaser` Short paragraph giving a brief overview of the main subject. Also used in the + page meta field for SEO. +- `keywords` A list of comma seperated key-words. Also used in the + page meta field for SEO. + +At this point there is no established way of embedding images. + ### Styling & layouts - TBC diff --git a/blog-posts/blog-introduction.md b/blog-posts/blog-introduction.md new file mode 100644 index 0000000..c099703 --- /dev/null +++ b/blog-posts/blog-introduction.md @@ -0,0 +1,22 @@ +--- +slug: 'introducing-the-cookie-blog' +author: 'Ivan Kocienski' +title: 'Introducing the Cookie Blog' +publish_date: '2026-01-14' +teaser: 'A little hello from us with an introduction to our plans for the coming year.' +keywords: 'introduction, thoughts, plan' +--- + +Hello! + +And welcome to the cookiewolf blog- our little slice of the internet where we can post ideas and updates about all the things we are getting up to. (Or that is the plan!) + +We are an active group in our local communities so it will be nice to take some of those experiences and put them into words. + +We also hope this will be where we can share resources with the wider community. + +Or maybe one week we just want to share our cookie recipes. + +With love, + +The Cookie Wolf humans. diff --git a/blog-posts/escaping-big-tech.md b/blog-posts/escaping-big-tech.md new file mode 100644 index 0000000..eab9623 --- /dev/null +++ b/blog-posts/escaping-big-tech.md @@ -0,0 +1,45 @@ +--- +slug: 'escaping-big-tech' +author: 'Ivan Kocienski' +title: 'Escaping Big Tech' +publish_date: '2026-01-01' +teaser: 'BigTech: over-dependence on centralised platforms' +keywords: 'introduction, thoughts, plan' +--- + +## Introduction + +Apple, Microsoft, Meta, Google, etc. are increasing their grip on more of our lives, both professional and personal. The problem is they're very convenient and have well established integration in our day-to-day activities. + +I think worrying about such things can seem unecassery and as long as our rights (and their legal obligations) are followed then there is nothing to worry about? + +But it doesn't take a lot of looking around to find examples of Big Tech abusing its status as a defacto monopoly and how they only seem happy to protect our rights when it aligns with their profit margins and quarterly performance reviews. + +## You are not alone + +[RABT] + +## So what can we do? + +Time to take our data back into our own hands. Maybe not overnight, but just a little, here and there. + +## Our tech stack + +At Cookiewolf we try to avoid as much of this as possible. Here are a few bits of tech we use +- [ghandi]() DNS +- [Hetzner]() VPS hosting +- [Netlify]() hosting +- [cloudron]() platform +- [whereby]() video conferencing +- [zulip]() chat platform +- [linux]() OS + +## We can do better + +Unfortunately a big part of our day-to-day software engineering efforts take place on Microsofts Github. We have all our repositories there and in turn all our bug tracking. Moving will not be easy. + +We hope to move to [gitlab] (either self hosted or as a paid PaaS) in 2026. + +## Conclusion + +I hope \ No newline at end of file diff --git a/elm.json b/elm.json index 8f9dc6d..1367f99 100644 --- a/elm.json +++ b/elm.json @@ -8,6 +8,7 @@ "direct": { "elm/browser": "1.0.2", "elm/core": "1.0.5", + "elm/json": "1.1.3", "elm/url": "1.0.0", "elm-explorations/markdown": "1.0.0", "hmsk/elm-vite-plugin-helper": "1.0.1", @@ -15,7 +16,6 @@ }, "indirect": { "elm/html": "1.0.0", - "elm/json": "1.1.3", "elm/time": "1.0.0", "elm/virtual-dom": "1.0.3", "robinheghan/murmur3": "1.0.0", diff --git a/index.html b/index.html index 447167e..34b1b8b 100644 --- a/index.html +++ b/index.html @@ -9,13 +9,18 @@ Cookiewolf Co-op - - + + - + + + diff --git a/package.json b/package.json index 982f28f..d75cb75 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "serve": "vite preview", "format": "elm-format src/ --yes", "lint": "elm-review", - "test": "elm-test-rs" + "test": "elm-test-rs", + "build_blog": "node scripts/convert-blog-posts-to-json-payload.js blog-posts/ src/assets/blog-data.json public/blog-feed.rss" }, "devDependencies": { "elm-review": "^2.13.2", @@ -21,4 +22,4 @@ "@fontsource/poppins": "^5.2.6", "elm-debug-transformer": "^1.2.1" } -} +} \ No newline at end of file diff --git a/public/blog-feed.rss b/public/blog-feed.rss new file mode 100644 index 0000000..3146b2a --- /dev/null +++ b/public/blog-feed.rss @@ -0,0 +1,27 @@ + + + + Cookiewolf blog + The place for cookiewolf thoughts of all kinds. + https://cookiewolf.coop + Cookiewolf Coop + 2026-01-28T10:54:08.551Z + 2026-01-28T10:54:08.551Z + 1800 + + + Introducing the Cookie Blog + A little hello from us with an introduction to our plans for the coming year. + https://cookiewolf.coop/blog/introducing-the-cookie-blog + introducing-the-cookie-blog + 2026-02-14T12:00:00.000Z + + + Escaping Big Tech + BigTech: over-dependence on centralised platforms + https://cookiewolf.coop/blog/escaping-big-tech + escaping-big-tech + 2026-02-01T12:00:00.000Z + + + \ No newline at end of file diff --git a/scripts/convert-blog-posts-to-json-payload.js b/scripts/convert-blog-posts-to-json-payload.js new file mode 100644 index 0000000..718ba7a --- /dev/null +++ b/scripts/convert-blog-posts-to-json-payload.js @@ -0,0 +1,251 @@ +/* + +Don't run this script directly, use `npm run build_blog`! + +scripts/convert-blog-posts-to-json-payload.js + +Looks for markdown files in src directory and bundles all that data +into one json file for deployment. + +NOTE: yes i know this is a bit verbose as it does everything manually but it +also has ZERO external dependencies, which makes distribution simpler (i hope). +*/ + +const fs = require('node:fs/promises'); +const process = require('process'); + +const propPattern = /^([^:]+):\s+'([^']+)'/; + +function isMarkdownFile(filename) { + return filename.endsWith(".md"); +} + +function readSourcePostFile(basePath, filename) { + return new Promise((resolve, reject) => { + + fs.readFile(basePath + "/" + filename, 'utf8') + .catch(reject) + .then(data => { + + let props = {}; + const lines = data.split("\n"); + let pos = 0; + + if (lines[pos] === "---") { + pos++; + + while (lines[pos] !== "---" && pos < lines.length) { + const found = propPattern.exec(lines[pos]); + if (found) { + const key = found[1]; + const value = found[2]; + props[key] = value; + } + + pos++; + } + if (lines[pos] === "---") { + pos++; + } + } + const payload = { + srcFile: filename, + props: props, + lines: lines.slice(pos) + }; + resolve(payload); + }); + }); +} + +const datePattern = /^(\d{4})-(\d{2})-(\d{2})$/; + +function normalizePostData(rawPostData) { + return new Promise((resolve, reject) => { + + let postBySlug = {}; + + const postData = + rawPostData + .map(rawPost => { + + const lookup = name => (rawPost.props[name] || '').trim(); + const badEnd = fieldName => reject("Bad/missing `" + fieldName + "` in `" + rawPost.srcFile + "`"); + + // FIXME? reject posts with no content? + + const title = lookup('title'); + if (title.length == 0) { badEnd('title'); } + + const author = lookup('author'); + if (author.length == 0) { badEnd('author'); } + + const teaser = lookup('teaser'); + if (teaser.length == 0) { badEnd('teaser'); } + + const keywords = lookup('keywords'); + if (keywords.length == 0) { badEnd('keywords'); } + + const rawPublishDateValue = lookup('publish_date'); + // if (rawPublishDateValue.length == 0) { badEnd('publish_date'); } + + const dateMatch = datePattern.exec(rawPublishDateValue); + let publishDate; + if (dateMatch) { + const year = parseInt(dateMatch[1]); + const month = parseInt(dateMatch[2]); + const day = parseInt(dateMatch[3]); + + publishDate = new Date(year, month, day); + + } else { + badEnd('publish_date'); + } + + const slug = + title + .toLowerCase() + .replaceAll(/\W+/g, '-') + .replace(/-*$/, ''); + + if (slug.length == 0) { + reject("Title produces empty slug in " + rawPost.srcFile); + return; + } + + if (postBySlug[slug]) { + reject("Slug is not unique in " + rawPost.srcFile); + return; + + } else { + postBySlug[slug] = true; + } + + const post = { + title: title, + author: author, + teaser: teaser, + keywords: keywords, + slug: slug, + publish_date: publishDate.toISOString().slice(0, 10), + content: rawPost['lines'].join("\n") + }; + return post; + }) + + postData.sort((a, b) => { + // by date (reversed) + if (a.publish_date < b.publish_date) return 1; + if (a.publish_date > b.publish_date) return -1; + + // by title + if (a.title < b.title) return -1; + if (a.title > b.title) return 1; + + // (should never be this) + return 0; + }); + + resolve(postData); + }); +} + +function savePostData(postData, targetFile) { + return fs.writeFile(targetFile, JSON.stringify(postData)); + + /* + return new Promise((resolve, reject) => { + fs + .writeFile(targetFile, JSON.stringify(postData)) + .then(resolve) + .catch(reject); + }); + */ +} + +function saveFeed(postData, targetFile) { + + let postList = + postData + .map( + post => + ` + ${post.title} + ${post.teaser} + https://cookiewolf.coop/blog/${post.slug} + ${post.slug} + ${post.publish_date}T12:00:00.000Z + `) + .join("\n"); + + const builtOn = new Date().toISOString(); + + const output = + ` + + + Cookiewolf blog + The place for cookiewolf thoughts of all kinds. + https://cookiewolf.coop + Cookiewolf Coop + ${builtOn} + ${builtOn} + 1800 + + ${postList} + +`; + + // console.log("feed:"); + // console.log(output); + + return fs.writeFile(targetFile, output); +} + +function main() { + if (process.argv.length < 5) { + console.error("Missing src / output / feed output arguments"); + process.exit(-1); + } + + const srcDir = process.argv[2]; + const targetPayloadFile = process.argv[3]; + const targetFeedFile = process.argv[4]; + + console.log('convert-blog-posts-to-json-payload'); + console.log(" srcDir=" + srcDir); + console.log(" targetPayloadFile=" + targetPayloadFile) + console.log(" targetFeedFile=" + targetFeedFile) + + fs.readdir(srcDir) + .then(files => { + const postFileData = + files + .filter(isMarkdownFile) + .map(file => readSourcePostFile(srcDir, file)); + + if (postFileData.length == 0) { + console.error("No post files found?"); + return; + } + + Promise + .all(postFileData) + .then(normalizePostData) + .then(data => + Promise.all( + [savePostData(data, targetPayloadFile), saveFeed(data, targetFeedFile)] + )) + .then(() => { + console.log("all done."); + }) + .catch(problem => { + console.error("error:", problem); + process.exit(-1); + }); + + }); +} + +main(); + diff --git a/src/Copy/CaseStudy.elm b/src/Copy/CaseStudy.elm index d7b2a41..2c1ceff 100644 --- a/src/Copy/CaseStudy.elm +++ b/src/Copy/CaseStudy.elm @@ -1,40 +1,17 @@ -module Copy.CaseStudy exposing (CaseStudyKey(..), caseStudyFromId, caseStudyIdFromSlug) +module Copy.CaseStudy exposing (caseStudyFromSlug, codeReadingClub, foyer, newProjectInvite) import Model import Route -type CaseStudyKey - = CodeReadingClub - | Foyer - | NewProjectInvite - | FourZeroFour - - -caseStudyIdFromSlug : String -> CaseStudyKey -caseStudyIdFromSlug slug = +caseStudyFromSlug : String -> Maybe Model.CaseStudy +caseStudyFromSlug slug = case slug of "foyer" -> - Foyer + Just foyer _ -> - FourZeroFour - - -caseStudyFromId : CaseStudyKey -> Model.CaseStudy -caseStudyFromId id = - case id of - CodeReadingClub -> - codeReadingClub - - Foyer -> - foyer - - NewProjectInvite -> - newProjectInvite - - FourZeroFour -> - fourZeroFour + Nothing codeReadingClub : Model.CaseStudy @@ -125,18 +102,3 @@ newProjectInvite = , metaUrl = Nothing , metaImageSrc = Nothing } - - -fourZeroFour : Model.CaseStudy -fourZeroFour = - { name = "Project not found" - , title = "Project not found" - , teaserBackgroundSrc = "" - , teaserSummary = "" - , teaserLinkText = "" - , teaserHref = "mailto:hello@cookiewolf.coop" - , maybePageContent = Nothing - , metaTitle = "" - , metaUrl = Nothing - , metaImageSrc = Nothing - } diff --git a/src/Copy/Keys.elm b/src/Copy/Keys.elm index 8f88f52..1f40944 100644 --- a/src/Copy/Keys.elm +++ b/src/Copy/Keys.elm @@ -15,6 +15,8 @@ type Key -- Header = SiteTitle + | HomeTitle + | WindowTitle String | Strapline | Category ContentType | HomeMetaDescription @@ -24,16 +26,33 @@ type | WhoWeAreHeading | WhoWeAreMarkdown1 | WhoWeAreMarkdown2 + -- Case study + | CaseStudyTitle + | CaseStudySlug -- About Us | AboutUsSlug | AboutUsTitle | AboutUsMetaDescription | AboutUsSection Section | AboutUsProfileProjectsLabel - | CaseStudySlug | WhatWeDidHeading | ResultsHeading -- Footer | ContactUsHeading | ContactUsMarkdown | CompanyInformation + -- blog bits + | BlogSlug + | BlogNotFoundThing + | BlogHomeTitle + | BlogHomeReadMoreLink + | BlogIndexTitle + | BlogMetaDescription + | BlogCardReadMoreLink + | BlogByLineBy + | BlogByLineOn + -- "not found" bits + | NotFoundTitle + | NotFoundThing + | NotFoundDescription1 String + | NotFoundDescription2 diff --git a/src/Copy/Text.elm b/src/Copy/Text.elm index 2d50cc5..9ef6556 100644 --- a/src/Copy/Text.elm +++ b/src/Copy/Text.elm @@ -13,6 +13,12 @@ t key = SiteTitle -> "Cookiewolf" + HomeTitle -> + "Home" + + WindowTitle pageTitle -> + pageTitle ++ " - Cookiewolf" + Strapline -> "Building useful digital tools with you" @@ -53,6 +59,12 @@ Our members’ award-winning work spans projects with clients across the arts, t [Meet the people behind Cookiewolf](/about-us) """ + CaseStudyTitle -> + "Case study" + + CaseStudySlug -> + "case-study" + ThingsWeWorkOnHeading -> "Things We're Working On" @@ -79,9 +91,6 @@ Our members’ award-winning work spans projects with clients across the arts, t AboutUsProfileProjectsLabel -> "**Selected projects**: " - CaseStudySlug -> - "case-study" - WhatWeDidHeading -> "What We Did" @@ -100,3 +109,42 @@ Got a project? Questions? Just want a chat? Email us at: CompanyInformation -> "Cookiewolf Co-op Ltd is registered in England & Wales (No. 13865007)" + + BlogSlug -> + "blog" + + BlogNotFoundThing -> + "Blog post" + + BlogHomeTitle -> + "Our Latest Thoughts" + + BlogHomeReadMoreLink -> + "Read more of our blog" + + BlogIndexTitle -> + "Blog" + + BlogMetaDescription -> + "Blog desription" + + BlogCardReadMoreLink -> + "Read more" + + BlogByLineBy -> + "By " + + BlogByLineOn -> + " on " + + NotFoundTitle -> + "Page not found" + + NotFoundThing -> + "Page" + + NotFoundDescription1 thing -> + "The " ++ thing ++ " you were looking for does not exist" + + NotFoundDescription2 -> + "Please check the URL and try again" diff --git a/src/Main.elm b/src/Main.elm index 59df401..ebb96fa 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -1,5 +1,8 @@ module Main exposing (main) +-- import Json.Decode.Field as Field + +import Array exposing (Array) import Browser import Browser.Dom import Browser.Events @@ -8,12 +11,15 @@ import Copy.CaseStudy import Copy.Keys exposing (Key(..)) import Copy.Text exposing (t) import Html.Styled exposing (Html, toUnstyled) +import Json.Decode as JD import MetaTags -import Model exposing (Model) +import Model exposing (BlogPost, Model, PageResource) import Msg exposing (Msg(..)) import Page.AboutUs +import Page.Blog as Blog import Page.CaseStudy import Page.Index +import Page.NotFound import Route exposing (Route(..)) import Set import Task @@ -22,10 +28,11 @@ import Url type alias Flags = - () + { posts : Array BlogPost + } -main : Program Flags Model Msg +main : Program JD.Value Model Msg main = Browser.application { init = init @@ -37,12 +44,66 @@ main = } -init : Flags -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) -init _ url key = +setupResourceForPage : Array BlogPost -> Route -> PageResource +setupResourceForPage blogPosts route = + let + baseResource = + Model.emptyPageResource + in + case route of + Index -> + { baseResource + | meta = MetaTags.metaForRoot + } + + AboutUs -> + { baseResource + | meta = MetaTags.metaForAboutUs + } + + CaseStudy slug -> + case Copy.CaseStudy.caseStudyFromSlug slug of + Just foundCaseStudy -> + { baseResource + | caseStudy = Just foundCaseStudy + , meta = MetaTags.metaForCaseStudy foundCaseStudy + } + + Nothing -> + { baseResource + | meta = MetaTags.metaForNotFound (t CaseStudyTitle) + } + + BlogIndex -> + { baseResource + | meta = MetaTags.metaForBlogIndex + } + + BlogShowPost slug -> + case Blog.findBlogFromSlug blogPosts slug of + Just blogPost -> + { baseResource + | blogPost = Just blogPost + , meta = MetaTags.metaForBlogShowPost blogPost + } + + Nothing -> + { baseResource + | meta = MetaTags.metaForNotFound (t BlogNotFoundThing) + } + + NotFound -> + { baseResource + | meta = MetaTags.metaForNotFound (t NotFoundThing) + } + + +init : JD.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) +init rawFlags url key = let route : Route route = - Maybe.withDefault Index <| Route.fromUrl url + Maybe.withDefault NotFound <| Route.fromUrl url openSections : Set.Set String openSections = @@ -52,14 +113,25 @@ init _ url key = Nothing -> Set.empty + + flags = + rawFlags + |> JD.decodeValue flagsDecoder + --|> Result.mapError (\err -> Debug.log "flags decode error: " err) + |> Result.withDefault { posts = Array.empty } + + resource = + setupResourceForPage flags.posts route in ( { key = key , page = route , viewportHeightWidth = ( 800, 800 ) , openSections = openSections + , blogPosts = flags.posts + , pageResource = resource } , Cmd.batch - [ MetaTags.setMetadata <| MetaTags.metaForPage route + [ MetaTags.setMetadata <| resource.meta , Task.perform GotViewport Browser.Dom.getViewport ] ) @@ -109,7 +181,7 @@ update msg model = newRoute = -- If not a valid route, go to index -- could 404 instead depends on desired behaviour - Maybe.withDefault Index (Route.fromUrl url) + Maybe.withDefault NotFound (Route.fromUrl url) openSections : Set.Set String openSections = @@ -119,10 +191,17 @@ update msg model = Nothing -> Set.empty + + resource = + setupResourceForPage model.blogPosts newRoute in - ( { model | page = newRoute, openSections = openSections } + ( { model + | page = newRoute + , openSections = openSections + , pageResource = resource + } , Cmd.batch - [ MetaTags.setMetadata <| MetaTags.metaForPage newRoute + [ MetaTags.setMetadata <| resource.meta , possiblyScrollToTop url ] ) @@ -155,35 +234,67 @@ subscriptions _ = viewDocument : Model -> Browser.Document Msg viewDocument model = - { title = MetaTags.titleForPage model.page + { title = model.pageResource.meta.title , body = [ toUnstyled (view model) ] } view : Model -> Html Msg view model = - case model.page of - Index -> - Theme.View.viewPageWrapper (t SiteTitle) Page.Index.view + let + innerPage = + case model.page of + Index -> + Page.Index.view model.blogPosts + + AboutUs -> + Page.AboutUs.view model + + CaseStudy _ -> + -- FIXME. this is jank + -- we know case study exists + -- we should move the meta data out of the embedded CaseStudy + -- and just pass that along + case model.pageResource.caseStudy of + Just caseStudy -> + caseStudy.maybePageContent + |> Maybe.withDefault Model.emptyCaseStudyContent + |> Page.CaseStudy.view caseStudy.title - AboutUs -> - Theme.View.viewPageWrapper (t AboutUsTitle) (Page.AboutUs.view model) + Nothing -> + Page.NotFound.view (t CaseStudyTitle) - CaseStudy slug -> - let - caseStudy : Model.CaseStudy - caseStudy = - Copy.CaseStudy.caseStudyIdFromSlug slug - |> Copy.CaseStudy.caseStudyFromId - - maybeContent : Maybe Model.CaseStudyContent - maybeContent = - caseStudy.maybePageContent - in - case maybeContent of - Just content -> - Theme.View.viewPageWrapper caseStudy.title (Page.CaseStudy.view caseStudy.title content) + BlogIndex -> + Blog.viewBlogIndex model - Nothing -> - -- Replace with global 404 ? - Theme.View.viewPageWrapper (t SiteTitle) Page.Index.view + BlogShowPost _ -> + case model.pageResource.blogPost of + Just post -> + Blog.viewShowBlogPost post + + Nothing -> + Page.NotFound.view (t BlogNotFoundThing) + + NotFound -> + Page.NotFound.view (t NotFoundThing) + in + Theme.View.viewPageWrapper + model.pageResource.meta.title + innerPage + + +flagsDecoder : JD.Decoder Flags +flagsDecoder = + let + postDecoder = + JD.map7 BlogPost + (JD.field "slug" JD.string) + (JD.field "author" JD.string) + (JD.field "publish_date" JD.string) + (JD.field "title" JD.string) + (JD.field "teaser" JD.string) + (JD.field "keywords" JD.string) + (JD.field "content" JD.string) + in + JD.map Flags + (JD.field "blog_posts" <| JD.array postDecoder) diff --git a/src/MetaTags.elm b/src/MetaTags.elm index 4b79978..ff8c68d 100644 --- a/src/MetaTags.elm +++ b/src/MetaTags.elm @@ -1,65 +1,79 @@ -port module MetaTags exposing (PageMetadata, metaForPage, setMetadata, titleForPage) +port module MetaTags exposing + ( metaForAboutUs + , metaForBlogIndex + , metaForBlogShowPost + , metaForCaseStudy + , metaForNotFound + , metaForRoot + , setMetadata + ) -import Copy.CaseStudy import Copy.Keys as Keys import Copy.Text exposing (t) -import Route +import Model exposing (PageMetadata) -type alias PageMetadata = - { title : String - , description : String - , url : Maybe String - , imageSrc : Maybe String +metaForRoot : PageMetadata +metaForRoot = + { title = t <| Keys.WindowTitle <| t Keys.HomeTitle + , description = t Keys.HomeMetaDescription + , url = Nothing + , imageSrc = Nothing } -titleForPage : Route.Route -> String -titleForPage route = - case route of - Route.AboutUs -> - t Keys.AboutUsTitle ++ " - " ++ t Keys.SiteTitle - - _ -> - t Keys.SiteTitle - - -metaForPage : Route.Route -> PageMetadata -metaForPage route = - case route of - Route.Index -> - { title = titleForPage route - , description = t Keys.HomeMetaDescription - , url = Nothing - , imageSrc = Nothing - } - - Route.AboutUs -> - { title = titleForPage route - , description = t Keys.AboutUsMetaDescription - , url = Nothing - , imageSrc = Nothing - } - - Route.CaseStudy slug -> - let - caseStudy = - Copy.CaseStudy.caseStudyIdFromSlug slug - |> Copy.CaseStudy.caseStudyFromId - - metaDescription = - case caseStudy.maybePageContent of - Just pageContent -> - pageContent.metaDescription - - Nothing -> - t Keys.HomeMetaDescription - in - { title = caseStudy.title - , description = metaDescription - , url = caseStudy.metaUrl - , imageSrc = caseStudy.metaImageSrc - } +metaForAboutUs : PageMetadata +metaForAboutUs = + { title = t <| Keys.WindowTitle <| t Keys.AboutUsTitle + , description = t Keys.AboutUsMetaDescription + , url = Nothing + , imageSrc = Nothing + } + + +metaForCaseStudy : Model.CaseStudy -> PageMetadata +metaForCaseStudy caseStudy = + let + metaDescription = + case caseStudy.maybePageContent of + Just pageContent -> + pageContent.metaDescription + + Nothing -> + t Keys.HomeMetaDescription + in + { title = t <| Keys.WindowTitle <| caseStudy.title -- could be caseStudie.title ++ " - " ++ t Keys.CaseStudyTitle + , description = metaDescription + , url = caseStudy.metaUrl + , imageSrc = caseStudy.metaImageSrc + } + + +metaForBlogIndex : PageMetadata +metaForBlogIndex = + { title = t <| Keys.WindowTitle <| t Keys.BlogIndexTitle + , description = t Keys.BlogMetaDescription + , url = Nothing + , imageSrc = Nothing + } + + +metaForBlogShowPost : Model.BlogPost -> PageMetadata +metaForBlogShowPost post = + { title = t <| Keys.WindowTitle <| post.title + , description = post.teaser + , url = Nothing + , imageSrc = Nothing + } + + +metaForNotFound : String -> PageMetadata +metaForNotFound thing = + { title = t <| Keys.WindowTitle <| t Keys.NotFoundTitle + , description = t <| Keys.NotFoundDescription1 thing + , url = Nothing + , imageSrc = Nothing + } port setMetadata : PageMetadata -> Cmd msg diff --git a/src/Model.elm b/src/Model.elm index 556bd56..2b2ab5a 100644 --- a/src/Model.elm +++ b/src/Model.elm @@ -1,16 +1,62 @@ -module Model exposing (CaseStudy, CaseStudyContent, Image, Model, ProfileInfo, Quote) +module Model exposing (BlogPost, CaseStudy, CaseStudyContent, Image, Model, PageMetadata, PageResource, ProfileInfo, Quote, emptyCaseStudy, emptyCaseStudyContent, emptyPageMetadata, emptyPageResource) +import Array exposing (Array) import Browser.Navigation import Copy.Keys import Route exposing (Route) import Set exposing (Set) +type alias PageMetadata = + { title : String + , description : String + , url : Maybe String + , imageSrc : Maybe String + } + + +emptyPageMetadata : PageMetadata +emptyPageMetadata = + { title = "" + , description = "" + , url = Nothing + , imageSrc = Nothing + } + + type alias Model = { key : Browser.Navigation.Key , page : Route , viewportHeightWidth : ( Float, Float ) , openSections : Set String + , blogPosts : Array BlogPost + , pageResource : PageResource + } + + +type alias PageResource = + { blogPost : Maybe BlogPost + , caseStudy : Maybe CaseStudy + , meta : PageMetadata + } + + +emptyPageResource : PageResource +emptyPageResource = + { blogPost = Nothing + , caseStudy = Nothing + , meta = emptyPageMetadata + } + + +type alias BlogPost = + { slug : String + , author : String + , publishDate : String -- YYYY-MM-DD + , title : String + , teaser : String + , keywords : String + , content : String } @@ -41,6 +87,21 @@ type alias CaseStudy = } +emptyCaseStudy : CaseStudy +emptyCaseStudy = + { name = "" + , title = "" + , teaserBackgroundSrc = "" + , teaserSummary = "" + , teaserLinkText = "" + , teaserHref = "" + , maybePageContent = Nothing + , metaTitle = "" + , metaUrl = Nothing + , metaImageSrc = Nothing + } + + type alias CaseStudyContent = { introMarkdown : String , metaDescription : String @@ -52,6 +113,18 @@ type alias CaseStudyContent = } +emptyCaseStudyContent : CaseStudyContent +emptyCaseStudyContent = + { introMarkdown = "" + , metaDescription = "" + , maybeIntroImage = Nothing + , whatWeDidMarkdown = "" + , maybeWhatWeDidImage = Nothing + , resultsMarkdown = "" + , maybeQuote = Nothing + } + + type alias ProfileInfo = { section : Copy.Keys.Section , name : String diff --git a/src/Page/Blog.elm b/src/Page/Blog.elm new file mode 100644 index 0000000..cfe27a8 --- /dev/null +++ b/src/Page/Blog.elm @@ -0,0 +1,172 @@ +module Page.Blog exposing (blogCard, findBlogFromSlug, viewBlogCardPromo, viewBlogIndex, viewShowBlogPost) + +import Array exposing (Array) +import Copy.Keys exposing (Key(..), Section(..)) +import Copy.Text exposing (t) +import Css exposing (..) +import Html.Styled exposing (Html, a, div, h1, h3, p, text) +import Html.Styled.Attributes exposing (css, href) +import Model exposing (BlogPost, Model) +import Msg exposing (Msg) +import Route +import Theme.Style exposing (green, pink, shadow, withMediaTablet) +import Theme.View exposing (contentContainer) + + +findBlogFromSlug : Array Model.BlogPost -> String -> Maybe Model.BlogPost +findBlogFromSlug blogPosts slug = + blogPosts + |> Array.filter (\p -> p.slug == slug) + |> Array.get 0 + + +viewBlogIndex : Model -> Html Msg +viewBlogIndex model = + div [] + [ -- title + div [ css [ titleBackgroundStyle ] ] + [ div [ css [ contentContainer, titleAreaStyle ] ] + [ h1 [ css [ titleStyle ] ] [ text <| t BlogIndexTitle ] + ] + ] + , div [ css [ contentContainer, contentAreaStyle ] ] + (model.blogPosts + |> Array.toList + |> List.map + (\post -> + div [ css [ blogCardStyle ] ] + [ blogCard post ] + ) + ) + ] + + +viewShowBlogPost : Model.BlogPost -> Html Msg +viewShowBlogPost post = + div [] + [ -- title + div [ css [ titleBackgroundStyle ] ] + [ div [ css [ contentContainer, titleAreaStyle ] ] + [ h1 [ css [ titleStyle ] ] [ text post.title ] + , byLine post + ] + ] + + -- content + , div [ css [ contentContainer, contentAreaStyle ] ] + [ Theme.View.markdownToHtml post.content + ] + ] + + +blogCard : BlogPost -> Html Msg +blogCard post = + div [] + [ h3 [] [ text post.title ] + , byLine post + , p [] [ text post.teaser ] + , p [] [ a [ href <| Route.toString <| Route.BlogShowPost post.slug ] [ text <| t BlogCardReadMoreLink ] ] + ] + + +byLine : BlogPost -> Html Msg +byLine post = + p [ css [ byLineStyle ] ] + [ text <| t BlogByLineBy + , Html.Styled.em [] [ text post.author ] + , text <| t BlogByLineOn + , Html.Styled.em [] [ text post.publishDate ] + ] + + +capArrayLength : Int -> Array v -> Array v +capArrayLength max array = + if Array.length array > max then + Array.slice 0 max array + + else + array + + +viewBlogCardPromo : Array BlogPost -> Html Msg +viewBlogCardPromo blogPosts = + -- as seen on the homepage + blogPosts + |> capArrayLength 3 + |> Array.toList + |> List.map blogCard + |> div [ css [ blogCardPromoAreaStyle ] ] + + + +-- Styles + + +titleBackgroundStyle : Style +titleBackgroundStyle = + batch + [ backgroundColor pink.light + , boxShadow4 (px 0) (px 0) (px 20) shadow + , withMediaTablet + [ padding2 (rem 2) (rem 1) + ] + ] + + +titleAreaStyle : Style +titleAreaStyle = + batch + [ padding2 (rem 2) (rem 1) + ] + + +titleStyle : Style +titleStyle = + batch + [ fontSize (rem 2.5) + , fontWeight bold + , lineHeight (rem 3) + , withMediaTablet + [ fontSize (rem 3.75) + , lineHeight (rem 4.5) + ] + ] + + +contentAreaStyle : Style +contentAreaStyle = + batch + [ padding2 (rem 2) (rem 1) + ] + + +blogCardStyle : Style +blogCardStyle = + batch + [ paddingBottom (rem 2) + ] + + +blogCardPromoAreaStyle : Style +blogCardPromoAreaStyle = + batch + [ displayFlex + , flexDirection column + , fontSize (rem 1) + , margin auto + , maxWidth (px 1000) + , padding zero + , Css.property "gap" "2rem" + , withMediaTablet + [ flexDirection row + , Css.property "gap" "0" + ] + ] + + +byLineStyle : Style +byLineStyle = + batch + [ fontSize (Css.em 0.9) + , color green.dark + ] diff --git a/src/Page/Index.elm b/src/Page/Index.elm index b464c93..311bb16 100644 --- a/src/Page/Index.elm +++ b/src/Page/Index.elm @@ -1,7 +1,8 @@ module Page.Index exposing (view) +import Array exposing (Array) import Copy.AboutUs -import Copy.CaseStudy exposing (CaseStudyKey(..)) +import Copy.CaseStudy import Copy.Keys exposing (Key(..)) import Copy.Text exposing (t) import Css exposing (..) @@ -9,26 +10,36 @@ import Html.Styled exposing (Html, a, div, h1, h2, img, li, p, section, text, ul import Html.Styled.Attributes exposing (alt, class, css, href, src) import Model import Msg exposing (Msg) -import Theme.Style exposing (fuchsia, pink, shadow, white, withMediaTablet) +import Page.Blog as Blog +import Route +import Theme.Style exposing (fuchsia, green, pink, shadow, white, withMediaTablet) import Theme.View exposing (generateId) -featuredCaseStudyList : List Copy.CaseStudy.CaseStudyKey +featuredCaseStudyList : List Model.CaseStudy featuredCaseStudyList = - [ Foyer, CodeReadingClub, NewProjectInvite ] + [ Copy.CaseStudy.foyer + , Copy.CaseStudy.codeReadingClub + , Copy.CaseStudy.newProjectInvite + ] -view : Html Msg -view = +view : Array Model.BlogPost -> Html Msg +view blogPosts = div [] - [ section [ css [ sectionStyle ], class "home-section" ] + [ -- hero banner + section [ css [ sectionStyle ], class "home-section" ] [ h1 [ css [ sectionHeadingStyle ] ] [ text (t WhatWeDoHeading) ] , Theme.View.markdownToHtml (t WhatWeDoMarkdown) ] + + -- case studies , section [ css [ sectionStyle, sectionHighlightStyle ], class "home-section" ] [ h2 [ css [ sectionHeadingStyle ] ] [ text (t ThingsWeWorkOnHeading) ] , ul [ css [ workListStyle ] ] (viewWorkingOnList featuredCaseStudyList) ] + + -- who we are , section [ css [ sectionStyle ], class "home-section" ] [ h2 [ css [ sectionHeadingStyle ] ] [ text (t WhoWeAreHeading) ] , Theme.View.markdownToHtml (t WhoWeAreMarkdown1) @@ -37,21 +48,23 @@ view = (viewWhoWeAreList Copy.AboutUs.profiles) , Theme.View.markdownToHtml (t WhoWeAreMarkdown2) ] + + -- blog sample + , section [ css [ sectionStyle, sectionAltHighlightStyle ], class "home-section" ] + [ h2 [ css [ sectionHeadingStyle ] ] [ text <| t BlogHomeTitle ] + , Blog.viewBlogCardPromo blogPosts + , p [] [ a [ href <| Route.toString Route.BlogIndex ] [ text <| t BlogHomeReadMoreLink ] ] + ] ] -viewWorkingOnList : List Copy.CaseStudy.CaseStudyKey -> List (Html Msg) +viewWorkingOnList : List Model.CaseStudy -> List (Html Msg) viewWorkingOnList featuredCaseStudies = - List.map - (\caseStudyId -> - let - caseStudy : Model.CaseStudy - caseStudy = - Copy.CaseStudy.caseStudyFromId caseStudyId - in - li [ css [ workingOnCardStyle ] ] (viewCaseStudyCard caseStudy) - ) - featuredCaseStudies + featuredCaseStudies + |> List.map + (\caseStudy -> + li [ css [ workingOnCardStyle ] ] (viewCaseStudyCard caseStudy) + ) viewCaseStudyCard : Model.CaseStudy -> List (Html Msg) @@ -121,6 +134,14 @@ sectionHighlightStyle = ] +sectionAltHighlightStyle : Style +sectionAltHighlightStyle = + batch + [ backgroundColor green.light + , boxShadow4 (px 0) (px 0) (px 20) shadow + ] + + sectionHeadingStyle : Style sectionHeadingStyle = batch @@ -146,6 +167,13 @@ workListStyle = ] +writingListStyle : Style +writingListStyle = + batch + [ displayFlex + ] + + workingOnCardStyle : Style workingOnCardStyle = batch diff --git a/src/Page/NotFound.elm b/src/Page/NotFound.elm new file mode 100644 index 0000000..9cd1399 --- /dev/null +++ b/src/Page/NotFound.elm @@ -0,0 +1,46 @@ +module Page.NotFound exposing (view) + +import Copy.Keys exposing (Key(..)) +import Copy.Text exposing (t) +import Css exposing (..) +import Html.Styled exposing (Html, div, h1, p, text) +import Html.Styled.Attributes exposing (css) +import Msg exposing (Msg) +import Theme.Style exposing (pink, shadow) + + +view : String -> Html Msg +view thing = + div [] + [ div [ css [ titleSectionStyle ] ] + [ h1 [] [ text <| t NotFoundTitle ] + ] + , div [ css [ contentAreaStyle ] ] + [ p [] [ text <| t <| NotFoundDescription1 thing ] + , p [] [ text <| t NotFoundDescription2 ] + ] + ] + + + +-- Styles + + +titleSectionStyle : Style +titleSectionStyle = + batch + [ backgroundColor pink.light + , boxShadow5 (px 0) (px 12) (px 20) (px -8) shadow + , color pink.dark + , padding2 (rem 2) zero + , textAlign center + ] + + +contentAreaStyle : Style +contentAreaStyle = + batch + [ padding2 (rem 2) (rem 1) + , textAlign center + , fontSize (em 1.5) + ] diff --git a/src/Route.elm b/src/Route.elm index a240a47..e216a91 100644 --- a/src/Route.elm +++ b/src/Route.elm @@ -10,6 +10,9 @@ type Route = Index | AboutUs | CaseStudy String + | BlogIndex + | BlogShowPost String + | NotFound fromUrl : Url.Url -> Maybe Route @@ -30,6 +33,15 @@ toString route = CaseStudy slug -> "/" ++ t CaseStudySlug ++ "/" ++ slug + BlogIndex -> + "/" ++ t BlogSlug + + BlogShowPost slug -> + "/" ++ t BlogSlug ++ "/" ++ slug + + NotFound -> + "/" + routeParser : Parser (Route -> a) a routeParser = @@ -37,4 +49,6 @@ routeParser = [ map Index top , map AboutUs (s (t AboutUsSlug)) , map CaseStudy (s (t CaseStudySlug) string) + , map BlogIndex (s (t BlogSlug)) + , map BlogShowPost (s (t BlogSlug) string) ] diff --git a/src/assets/blog-data.json b/src/assets/blog-data.json new file mode 100644 index 0000000..e8ff492 --- /dev/null +++ b/src/assets/blog-data.json @@ -0,0 +1 @@ +[{"title":"Introducing the Cookie Blog","author":"Ivan Kocienski","teaser":"A little hello from us with an introduction to our plans for the coming year.","keywords":"introduction, thoughts, plan","slug":"introducing-the-cookie-blog","publish_date":"2026-02-14","content":"\nHello!\n\nAnd welcome to the cookiewolf blog- our little slice of the internet where we can post ideas and updates about all the things we are getting up to. (Or that is the plan!)\n\nWe are an active group in our local communities so it will be nice to take some of those experiences and put them into words.\n\nWe also hope this will be where we can share resources with the wider community.\n\nOr maybe one week we just want to share our cookie recipes.\n\nWith love,\n\nThe Cookie Wolf humans.\n"},{"title":"Escaping Big Tech","author":"Ivan Kocienski","teaser":"BigTech: over-dependence on centralised platforms","keywords":"introduction, thoughts, plan","slug":"escaping-big-tech","publish_date":"2026-02-01","content":"\n## Introduction\n\nApple, Microsoft, Meta, Google, etc. are increasing their grip on more of our lives, both professional and personal. The problem is they're very convenient and have well established integration in our day-to-day activities.\n\nI think worrying about such things can seem unecassery and as long as our rights (and their legal obligations) are followed then there is nothing to worry about?\n\nBut it doesn't take a lot of looking around to find examples of Big Tech abusing its status as a defacto monopoly and how they only seem happy to protect our rights when it aligns with their profit margins and quarterly performance reviews.\n\n## You are not alone\n\n[RABT]\n\n## So what can we do?\n\nTime to take our data back into our own hands. Maybe not overnight, but just a little, here and there.\n\n## Our tech stack\n\nAt Cookiewolf we try to avoid as much of this as possible. Here are a few bits of tech we use\n- [ghandi]() DNS\n- [Hetzner]() VPS hosting\n- [Netlify]() hosting\n- [cloudron]() platform\n- [whereby]() video conferencing\n- [zulip]() chat platform\n- [linux]() OS\n\n## We can do better\n\nUnfortunately a big part of our day-to-day software engineering efforts take place on Microsofts Github. We have all our repositories there and in turn all our bug tracking. Moving will not be easy.\n\nWe hope to move to [gitlab] (either self hosted or as a paid PaaS) in 2026.\n\n## Conclusion\n\nI hope"}] \ No newline at end of file diff --git a/src/assets/main.js b/src/assets/main.js index 2524547..c4e1665 100644 --- a/src/assets/main.js +++ b/src/assets/main.js @@ -2,6 +2,7 @@ import './reset.css'; import { Elm } from '../Main.elm'; import '@fontsource/poppins/400.css'; import '@fontsource/poppins/800.css'; +import blogData from './blog-data.json'; if (process.env.NODE_ENV === 'development') { const ElmDebugTransform = await import('elm-debug-transformer'); @@ -11,8 +12,11 @@ if (process.env.NODE_ENV === 'development') { }); } +const flags = { blog_posts: blogData }; +console.log("JS: flags=", flags); + const root = document.querySelector('#app div'); -const app = Elm.Main.init({ node: root }); +const app = Elm.Main.init({ node: root, flags: flags }); app.ports.setMetadata.subscribe(function (pageMetadata) { const baseUrl =