Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/docs/__tests__/Meta_.test.res
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
open Vitest

test("advertises the blog RSS feed in page metadata", async () => {
let _screen = await render(
<ReactRouter.MemoryRouter initialEntries=["/blog"]>
<Meta />
</ReactRouter.MemoryRouter>,
)
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) =>
Expand Down
90 changes: 90 additions & 0 deletions apps/docs/scripts/__tests__/BlogFeed_.test.res
Original file line number Diff line number Diff line change
@@ -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("<lastBuildDate>Tue, 28 Apr 2026 00:00:00 GMT</lastBuildDate>")
expect(xml)->toContain("<pubDate>Thu, 28 Nov 2019 00:00:00 GMT</pubDate>")
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)
})
})
3 changes: 3 additions & 0 deletions apps/docs/src/common/DateStr.res
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions apps/docs/src/common/DateStr.resi
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ external fromString: string => t = "%identity"

let fromDate: Date.t => t
let toDate: t => Date.t
let toUTCDate: t => Date.t
62 changes: 10 additions & 52 deletions apps/docs/src/data/BlogApi.res
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 => {
Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -116,56 +95,36 @@ 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,
description: string,
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<item> => {
let items =
getAllPosts()
->Array.slice(~start=0, ~end=max)
->Array.map(post => {
let fm = post.frontmatter
let description = fm.description->Nullable.getOr("")
{
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
}

let toXmlString = (~siteTitle="ReScript Blog", ~siteDescription="", items: array<item>) => {
let latestPubDateElement =
items[0]
->Option.map(item => {
let latestPubDateStr = item.pubDate->dateToUTCString
let latestPubDateStr = item.pubDate->Date.toUTCString
`<lastBuildDate>${latestPubDateStr}</lastBuildDate>`
})
->Option.getOr("")
Expand All @@ -181,8 +140,7 @@ module RssFeed = {
</description>`
}

// TODO: convert pubdate to string
let dateStr = pubDate->dateToUTCString
let dateStr = pubDate->Date.toUTCString
`
<item>
<title> <![CDATA[${title}]]></title>
Expand All @@ -204,7 +162,7 @@ module RssFeed = {
${latestPubDateElement}
${itemsStr}
</channel>
</rss>` //rescript-lang.org</link>
</rss>`

ret
}
Expand Down
Loading