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 ` <![CDATA[${title}]]> @@ -204,7 +162,7 @@ module RssFeed = { ${latestPubDateElement} ${itemsStr} - ` //rescript-lang.org + ` ret }