From 7c265727a4dd109ea44486318d0a70caecfad1bd Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 16:15:07 +0200 Subject: [PATCH 1/2] fix: restore current blog RSS feed Sort feed entries by publication date and match current article routes, including archived posts. Serialize UTC dates without mutating them and cover feed selection, links, dates and discovery. --- apps/docs/__tests__/Meta_.test.res | 13 +++ .../docs/scripts/__tests__/BlogFeed_.test.res | 89 +++++++++++++++++++ apps/docs/src/common/DateStr.res | 3 + apps/docs/src/common/DateStr.resi | 1 + apps/docs/src/data/BlogApi.res | 62 +++---------- 5 files changed, 116 insertions(+), 52 deletions(-) create mode 100644 apps/docs/scripts/__tests__/BlogFeed_.test.res diff --git a/apps/docs/__tests__/Meta_.test.res b/apps/docs/__tests__/Meta_.test.res index 15cd8ed45..acb0e2e23 100644 --- a/apps/docs/__tests__/Meta_.test.res +++ b/apps/docs/__tests__/Meta_.test.res @@ -1,5 +1,18 @@ open Vitest +test("advertises the blog RSS feed in page metadata", async () => { + let _screen = await render( + + + , + ) + let feed = + document->WebAPI.Document.querySelector("link[rel='alternate'][type='application/rss+xml']") + expect(feed->Null.toOption->Option.map(WebAPI.Element.getAttribute(_, "href")))->toBe( + Some(Null.make("/blog/feed.xml")), + ) +}) + let getMetaContent = name => { switch document->WebAPI.Document.querySelector(`meta[name='${name}']`) { | Value(element) => diff --git a/apps/docs/scripts/__tests__/BlogFeed_.test.res b/apps/docs/scripts/__tests__/BlogFeed_.test.res new file mode 100644 index 000000000..9be7e5ab1 --- /dev/null +++ b/apps/docs/scripts/__tests__/BlogFeed_.test.res @@ -0,0 +1,89 @@ +open ScriptTest + +@val @scope("process") external chdir: string => unit = "chdir" + +let fixture = (title, date) => + `--- +author: rescript-team +title: ${title} +date: "${date}" +--- +Article content. +` + +let blogDirectory = async () => { + let directory = await temporaryDirectory("blog-feed-") + let blog = join([directory, "markdown-pages", "blog"]) + await mkdir(join([blog, "archived"]), {recursive: true}) + await write(join([blog, "z-old.mdx"]), fixture("Old post", "2019-11-28")) + await write(join([blog, "a-new.mdx"]), fixture("New post", "2026-04-28")) + await write(join([blog, "archived", "middle.mdx"]), fixture("Archived post", "2020-01-01")) + directory +} + +let latestFrom = (directory, ~max=10) => { + let previousDirectory = cwd() + chdir(directory) + let items = try { + BlogApi.RssFeed.getLatest(~max, ()) + } catch { + | exn => + chdir(previousDirectory) + throw(exn) + } + chdir(previousDirectory) + items +} + +test("RSS selects posts by publication date before applying the limit", async () => { + let directory = await blogDirectory() + let items = latestFrom(directory, ~max=2) + expect(items->Array.map(item => item.title))->toStrictEqual(["New post", "Archived post"]) + expect(latestFrom(directory, ~max=0))->toStrictEqual([]) +}) + +test("RSS links match article routes, including archived posts", async () => { + let directory = await blogDirectory() + let items = latestFrom(directory) + expect(items->Array.map(item => item.href))->toStrictEqual([ + "https://rescript-lang.org/blog/a-new", + "https://rescript-lang.org/blog/archived/middle", + "https://rescript-lang.org/blog/z-old", + ]) + let routes = MdxFile.scanPaths(~dir=join([directory, "markdown-pages", "blog"]), ~alias="blog") + items->Array.forEach(item => { + let route = item.href->String.replace("https://rescript-lang.org/", "") + expect(routes->Array.includes(route))->toBe(true) + }) +}) + +test("blog slugs preserve route filenames and existing slugs", async () => { + expect(BlogApi.blogPathToSlug("release-12-0-0.mdx"))->toBe("release-12-0-0") + expect(BlogApi.blogPathToSlug("archived/whats-new-in-7-pt2.mdx"))->toBe( + "archived/whats-new-in-7-pt2", + ) + expect(BlogApi.blogPathToSlug("archived/2020-01-01-post.mdx"))->toBe("archived/2020-01-01-post") + expect(BlogApi.blogPathToSlug("archived/middle"))->toBe("archived/middle") +}) + +test("RSS dates use midnight UTC and XML serialization leaves dates unchanged", async () => { + let directory = await blogDirectory() + let items = latestFrom(directory) + let before = items->Array.map(item => item.pubDate->Date.getTime) + let xml = items->BlogApi.RssFeed.toXmlString + expect(xml)->toContain("Tue, 28 Apr 2026 00:00:00 GMT") + expect(xml)->toContain("Thu, 28 Nov 2019 00:00:00 GMT") + expect(items->Array.map(item => item.pubDate->Date.getTime))->toStrictEqual(before) + expect(items->BlogApi.RssFeed.toXmlString)->toBe(xml) +}) + +test("the current blog feed contains ten recent posts with existing routes", async () => { + let items = BlogApi.RssFeed.getLatest() + let routes = MdxFile.scanPaths(~dir="markdown-pages/blog", ~alias="blog") + expect(items->Array.length)->toBe(10) + items->Array.forEach(item => { + expect( + routes->Array.includes(item.href->String.replace("https://rescript-lang.org/", "")), + )->toBe(true) + }) +}) diff --git a/apps/docs/src/common/DateStr.res b/apps/docs/src/common/DateStr.res index a4d50d0fd..2eaf948ba 100644 --- a/apps/docs/src/common/DateStr.res +++ b/apps/docs/src/common/DateStr.res @@ -6,4 +6,7 @@ let parse = (dateStr: string): Date.t => dateStr->String.replaceRegExp(/-/g, "/" let fromDate = date => Date.toString(date) let toDate = dateStr => parse(dateStr) +// ISO date-only strings represent midnight UTC, independent of the build timezone. +let toUTCDate = dateStr => Date.fromString(dateStr) + external fromString: string => t = "%identity" diff --git a/apps/docs/src/common/DateStr.resi b/apps/docs/src/common/DateStr.resi index 80dfc9407..0c44564f4 100644 --- a/apps/docs/src/common/DateStr.resi +++ b/apps/docs/src/common/DateStr.resi @@ -5,3 +5,4 @@ external fromString: string => t = "%identity" let fromDate: Date.t => t let toDate: t => Date.t +let toUTCDate: t => Date.t diff --git a/apps/docs/src/data/BlogApi.res b/apps/docs/src/data/BlogApi.res index 4afd795c3..b52eb148d 100644 --- a/apps/docs/src/data/BlogApi.res +++ b/apps/docs/src/data/BlogApi.res @@ -1,27 +1,5 @@ -// Same design as in the official nextjs blog starter template -// https://github.com/zeit/next.js/blob/canary/examples/blog-starter/lib/api.js - -/* - BlogApi gives access to the data within the markdown-pages/blog directory. - - The markdown-pages/blog content is treated as a flat directory. There is a - set of whitelisted directories (for each category) to help declutter - huge file masses. - - Every filename within markdown-pages/blog must be unique, even when nested in - different directories. The filename will be sluggified, that means that e.g. - for the filepath "markdown-pages/blog/compiler/2021-03-12-my-post.mdx", slug = "my-post" - - The reason for the flat hierarchy is that we want to keep a flat URL - hierarchy with `https://rescript-lang.org/blog/[some-slug]`, without carrying - over the full subdirectory hierarchy. This allows us to restructure - internally more easily without breaking permalinks. - - This module offers functionality to query all slug information, and even - gives access to fully parsed blog posts with frontmatter data etc. - - See `pages/blog.re` for more context on why we need this API. - */ +// Blog paths are relative to markdown-pages/blog. Article routes preserve the +// filename and any archived/ prefix, removing only the .mdx extension. type post = { path: string, @@ -30,7 +8,7 @@ type post = { } let blogPathToSlug = path => { - path->String.replaceRegExp(/^(archive\/)?\d\d\d\d-\d\d-\d\d-(.+)\.mdx$/, "$2") + path->String.replaceRegExp(/\.mdx$/, "") } let mdxFiles = dir => { @@ -68,7 +46,8 @@ let getAllPosts = () => { }) Array.concat(nonArchivedPosts, archivedPosts)->Array.toSorted((a, b) => - String.compare(Node.Path.basename(b.path), Node.Path.basename(a.path)) + b.frontmatter.date->DateStr.toUTCDate->Date.getTime -. + a.frontmatter.date->DateStr.toUTCDate->Date.getTime ) } @@ -116,17 +95,6 @@ let getArchivedPosts = () => { } module RssFeed = { - // Module inspired by - // https://gist.github.com/fredrikbergqvist/36704828353ebf5379a5c08c7583fe2d - - // Example reference example for RSS 2.0: - // http://static.userland.com/gems/backend/rssTwoExample2.xml - - // TODO: - // In case the pubDate formatting doesn't work with toUTCString, - // we can port something like this: - // https://github.com/tjconcept/js-rfc822-date - type item = { title: string, href: string, @@ -134,19 +102,11 @@ module RssFeed = { pubDate: Date.t, } - // TODO: This is yet again a dirty approach to prevent UTC to substract too many - // hours of my local timezone so it does end up on another day, so we set the hours - // to 15 o clock. We need to reconsider the way we parse blog article dates, - // since the dates should always be parsed from a single timezone perspective - let dateToUTCString = date => { - date->Date.setHours(15)->ignore - date->Date.toUTCString - } - // Retrieves the most recent [max] blog post feed items let getLatest = (~max=10, ~baseUrl="https://rescript-lang.org", ()): array => { let items = getAllPosts() + ->Array.slice(~start=0, ~end=max) ->Array.map(post => { let fm = post.frontmatter let description = fm.description->Nullable.getOr("") @@ -154,10 +114,9 @@ module RssFeed = { title: fm.title, href: baseUrl ++ "/blog/" ++ blogPathToSlug(post.path), description, - pubDate: DateStr.toDate(fm.date), + pubDate: DateStr.toUTCDate(fm.date), } }) - ->Array.slice(~start=0, ~end=max) items } @@ -165,7 +124,7 @@ module RssFeed = { let latestPubDateElement = items[0] ->Option.map(item => { - let latestPubDateStr = item.pubDate->dateToUTCString + let latestPubDateStr = item.pubDate->Date.toUTCString `${latestPubDateStr}` }) ->Option.getOr("") @@ -181,8 +140,7 @@ module RssFeed = { ` } - // TODO: convert pubdate to string - let dateStr = pubDate->dateToUTCString + let dateStr = pubDate->Date.toUTCString ` <![CDATA[${title}]]> @@ -204,7 +162,7 @@ module RssFeed = { ${latestPubDateElement} ${itemsStr} - ` //rescript-lang.org + ` ret } From 5351fad736c59944ba17fb67cabc344b2d31651d Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 16:25:03 +0200 Subject: [PATCH 2/2] fix: normalize Windows paths in blog feed links Convert backslashes to URL separators before stripping the MDX extension. Cover archived Windows paths in the existing slug regression test. --- apps/docs/scripts/__tests__/BlogFeed_.test.res | 1 + apps/docs/src/data/BlogApi.res | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/scripts/__tests__/BlogFeed_.test.res b/apps/docs/scripts/__tests__/BlogFeed_.test.res index 9be7e5ab1..94a489811 100644 --- a/apps/docs/scripts/__tests__/BlogFeed_.test.res +++ b/apps/docs/scripts/__tests__/BlogFeed_.test.res @@ -64,6 +64,7 @@ test("blog slugs preserve route filenames and existing slugs", async () => { ) expect(BlogApi.blogPathToSlug("archived/2020-01-01-post.mdx"))->toBe("archived/2020-01-01-post") expect(BlogApi.blogPathToSlug("archived/middle"))->toBe("archived/middle") + expect(BlogApi.blogPathToSlug("archived\\middle.mdx"))->toBe("archived/middle") }) test("RSS dates use midnight UTC and XML serialization leaves dates unchanged", async () => { diff --git a/apps/docs/src/data/BlogApi.res b/apps/docs/src/data/BlogApi.res index b52eb148d..8aa5cd2ca 100644 --- a/apps/docs/src/data/BlogApi.res +++ b/apps/docs/src/data/BlogApi.res @@ -8,7 +8,7 @@ type post = { } let blogPathToSlug = path => { - path->String.replaceRegExp(/\.mdx$/, "") + path->String.replaceAll("\\", "/")->String.replaceRegExp(/\.mdx$/, "") } let mdxFiles = dir => {