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..94a489811
--- /dev/null
+++ b/apps/docs/scripts/__tests__/BlogFeed_.test.res
@@ -0,0 +1,90 @@
+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")
+ expect(BlogApi.blogPathToSlug("archived\\middle.mdx"))->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..8aa5cd2ca 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.replaceAll("\\", "/")->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
`
-
@@ -204,7 +162,7 @@ module RssFeed = {
${latestPubDateElement}
${itemsStr}
- ` //rescript-lang.org
+ `
ret
}