diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 941da3094..49e32f6d9 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -125,6 +125,17 @@ jobs:
run: yarn workspace @rescript-lang/docs ci:test:scripts
- name: Guide Vitest
run: yarn workspace @rescript-lang/guide ci:test
+ - name: Guide browser history E2E
+ env:
+ # Keep the prerender server and requests on the same localhost address in Docker.
+ NODE_OPTIONS: --dns-result-order=ipv4first
+ run: yarn workspace @rescript-lang/guide test:e2e
+ - name: Upload guide browser test artifacts
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: guide-browser-test-artifacts
+ path: apps/guide/test-results
Homepage_E2E:
runs-on: ubuntu-latest
env:
diff --git a/.gitignore b/.gitignore
index 177e198e7..6806da50c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -114,6 +114,7 @@ apps/guide/.vitest-attachments
# Browser test artifacts
apps/docs/test-results/
+apps/guide/test-results/
# Lighthouse artifacts
.lighthouseci/
diff --git a/apps/docs/__tests__/BlogArticleBehavior_.test.res b/apps/docs/__tests__/BlogArticleBehavior_.test.res
index 6b0f0d317..05857a4dd 100644
--- a/apps/docs/__tests__/BlogArticleBehavior_.test.res
+++ b/apps/docs/__tests__/BlogArticleBehavior_.test.res
@@ -26,11 +26,11 @@ test("blog article marks body content for DocSearch crawling", async () => {
await viewport(1440, 900)
let _screen = await render(
-
+
{React.string("This is the blog post body content for testing.")}
- ,
+ ,
)
switch document->WebAPI.Document.querySelector("article.DocSearch-content") {
diff --git a/apps/docs/__tests__/CopyButtons_.test.res b/apps/docs/__tests__/CopyButtons_.test.res
new file mode 100644
index 000000000..82a2886e2
--- /dev/null
+++ b/apps/docs/__tests__/CopyButtons_.test.res
@@ -0,0 +1,62 @@
+open Vitest
+
+test(
+ "code copy waits for the write, copies exact code, and permits retry after failure",
+ async () => {
+ let copied = ref(None)
+ let complete = ref(_ => ())
+ let writeClipboard = code => {
+ copied := Some(code)
+ Promise.make((resolve, _) => complete := resolve)
+ }
+ let screen = await render( )
+ let button = await screen->getByLabelText("Copy code")
+ await button->click
+ await element(button)->toBeDisabled
+ expect(copied.contents)->toEqual(Some("let answer = 42"))
+ complete.contents(Error(Clipboard.WriteFailed))
+ let retry = await screen->getByLabelText("Copy failed, try again")
+ await element(retry)->notToBeDisabled
+ await retry->click
+ complete.contents(Ok())
+ let feedback = await screen->getByText("Copied to clipboard")
+ await element(feedback)->toBeVisible
+ await element(button)->toBeDisabled
+ await screen->unmount
+ await element(feedback)->notToBeInTheDocument
+ },
+)
+
+test(
+ "share copy writes the full URL and shows success only after the write completes",
+ async () => {
+ let copied = ref(None)
+ let complete = ref(_ => ())
+ let writeClipboard = url => {
+ copied := Some(url)
+ Promise.make((resolve, _) => complete := resolve)
+ }
+ let screen = await render( )
+ let button = await screen->getByText("Copy Share Link")
+ await button->click
+ await element(button)->toBeDisabled
+ expect(copied.contents)->toEqual(Some(window.location.href))
+ complete.contents(Error(Clipboard.WriteFailed))
+ let retry = await screen->getByText("Copy failed, try again")
+ await element(retry)->notToBeDisabled
+ await retry->click
+ complete.contents(Ok())
+ await element(await screen->getByText("Copied to clipboard!"))->toBeVisible
+ },
+)
+
+test("a previous share write cannot change feedback for a new share link", async () => {
+ let complete = ref(_ => ())
+ let writeClipboard = _ => Promise.make((resolve, _) => complete := resolve)
+ let screen = await render( )
+ await (await screen->getByText("Copy Share Link"))->click
+ await screen->rerender( )
+ complete.contents(Ok())
+ await element(await screen->getByText("Copy Share Link"))->notToBeDisabled
+ await element(await screen->getByText("Copied to clipboard!"))->notToBeInTheDocument
+})
diff --git a/apps/docs/__tests__/HighlightLanguages_.test.res b/apps/docs/__tests__/HighlightLanguages_.test.res
index 6b1179bde..634b5212b 100644
--- a/apps/docs/__tests__/HighlightLanguages_.test.res
+++ b/apps/docs/__tests__/HighlightLanguages_.test.res
@@ -1,50 +1,29 @@
open Vitest
-type registeredLanguage
-type highlightOptions = {language: string}
-type highlightResult = {value: string}
-
-@module("highlight.js/lib/core") @scope("default")
-external createHighlighter: unit => HighlightLanguages.highlighter = "newInstance"
-
-@module("highlight.js/lib/languages/javascript")
-external javascript: HighlightLanguages.definition = "default"
-
-@module("highlight.js/lib/languages/plaintext")
-external plaintext: HighlightLanguages.definition = "default"
-
-@send
-external getLanguage: (HighlightLanguages.highlighter, string) => option =
- "getLanguage"
-
-@send
-external highlight: (HighlightLanguages.highlighter, string, highlightOptions) => highlightResult =
- "highlight"
-
test("registering a missing language makes its grammar and aliases available", async () => {
- let highlighter = createHighlighter()
+ let highlighter = HighlightLanguages.make(HighlightLanguages.defaultInstance)
- expect(highlighter->getLanguage("javascript"))->toEqual(None)
- expect(highlighter->getLanguage("js"))->toEqual(None)
- highlighter->HighlightLanguages.ensureRegistered("javascript", javascript)
+ expect(highlighter->HighlightLanguages.getLanguage("javascript"))->toEqual(None)
+ expect(highlighter->HighlightLanguages.getLanguage("js"))->toEqual(None)
+ highlighter->HighlightLanguages.ensureRegistered("javascript", HighlightJsBindings.javascript)
- let result = highlighter->highlight("const answer = 42;", {language: "js"})
+ let result = highlighter->HighlightLanguages.highlight("const answer = 42;", {language: "js"})
expect(result.value)->toBe(
"const answer = 42 ;",
)
})
test("registering an existing name or alias does not replace its grammar", async () => {
- let highlighter = createHighlighter()
- highlighter->HighlightLanguages.ensureRegistered("javascript", javascript)
- let original = highlighter->getLanguage("javascript")
+ let highlighter = HighlightLanguages.make(HighlightLanguages.defaultInstance)
+ highlighter->HighlightLanguages.ensureRegistered("javascript", HighlightJsBindings.javascript)
+ let original = highlighter->HighlightLanguages.getLanguage("javascript")
- highlighter->HighlightLanguages.ensureRegistered("javascript", plaintext)
- highlighter->HighlightLanguages.ensureRegistered("js", plaintext)
+ highlighter->HighlightLanguages.ensureRegistered("javascript", HighlightJsBindings.text)
+ highlighter->HighlightLanguages.ensureRegistered("js", HighlightJsBindings.text)
- expect(highlighter->getLanguage("javascript"))->toBe(original)
- expect(highlighter->getLanguage("js"))->toBe(original)
- let result = highlighter->highlight("const answer = 42;", {language: "js"})
+ expect(highlighter->HighlightLanguages.getLanguage("javascript"))->toBe(original)
+ expect(highlighter->HighlightLanguages.getLanguage("js"))->toBe(original)
+ let result = highlighter->HighlightLanguages.highlight("const answer = 42;", {language: "js"})
expect(result.value)->toBe(
"const answer = 42 ;",
)
diff --git a/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res b/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res
index 8e860e1cc..0e777fb82 100644
--- a/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res
+++ b/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res
@@ -19,12 +19,12 @@ test("mobile drawer can be toggled open", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
breadcrumbs
editLink
- ,
+ ,
)
let sidebar = await screen->getByTestId("sidebar-categories")
diff --git a/apps/docs/__tests__/Search_.test.res b/apps/docs/__tests__/Search_.test.res
index c27590892..503e4de4c 100644
--- a/apps/docs/__tests__/Search_.test.res
+++ b/apps/docs/__tests__/Search_.test.res
@@ -27,6 +27,25 @@ let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearch
_snippetResult: {content: Nullable.null},
}
+let makeCrawlerHit = (): DocSearch.docSearchHit => {
+ objectID: "crawler-hit",
+ content: Nullable.make("map(array, fn) returns a new array."),
+ url: "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map",
+ url_without_anchor: Nullable.undefined,
+ type_: Content,
+ anchor: Nullable.undefined,
+ deprecated: None,
+ hierarchy: {
+ lvl0: Nullable.make("Array"),
+ lvl1: Nullable.make("map"),
+ lvl2: Nullable.null,
+ lvl3: Nullable.null,
+ lvl4: Nullable.null,
+ lvl5: Nullable.null,
+ lvl6: Nullable.null,
+ },
+}
+
let withHierarchy = (
hit: DocSearch.docSearchHit,
~lvl0: string,
@@ -268,28 +287,7 @@ test("hitComponent routes relative hit URLs through React Router", async () => {
test(
"getContentHtml falls back to plain content when crawler hit has no snippet result",
async () => {
- let hit: DocSearch.docSearchHit = Obj.magic(
- Dict.fromArray([
- ("objectID", "crawler-hit"),
- ("content", "map(array, fn) returns a new array."),
- ("url", "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map"),
- ("type", "content"),
- (
- "hierarchy",
- Obj.magic(
- Dict.fromArray([
- ("lvl0", Obj.magic("Array")),
- ("lvl1", Obj.magic("map")),
- ("lvl2", Obj.magic(Nullable.null)),
- ("lvl3", Obj.magic(Nullable.null)),
- ("lvl4", Obj.magic(Nullable.null)),
- ("lvl5", Obj.magic(Nullable.null)),
- ("lvl6", Obj.magic(Nullable.null)),
- ]),
- ),
- ),
- ]),
- )
+ let hit: DocSearch.docSearchHit = makeCrawlerHit()
expect(SearchResults.getContentHtml(hit))->toEqual(Some("map(array, fn) returns a new array."))
},
@@ -480,28 +478,7 @@ test("normalizeHitUrls rewrites absolute site URLs to relative paths", async ()
})
test("normalizeHitUrls tolerates crawler hits without url_without_anchor", async () => {
- let hit: DocSearch.docSearchHit = Obj.magic(
- Dict.fromArray([
- ("objectID", "crawler-hit"),
- ("content", "map(array, fn) returns a new array."),
- ("url", "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map"),
- ("type", "content"),
- (
- "hierarchy",
- Obj.magic(
- Dict.fromArray([
- ("lvl0", Obj.magic("Array")),
- ("lvl1", Obj.magic("map")),
- ("lvl2", Obj.magic(Nullable.null)),
- ("lvl3", Obj.magic(Nullable.null)),
- ("lvl4", Obj.magic(Nullable.null)),
- ("lvl5", Obj.magic(Nullable.null)),
- ("lvl6", Obj.magic(Nullable.null)),
- ]),
- ),
- ),
- ]),
- )
+ let hit: DocSearch.docSearchHit = makeCrawlerHit()
let result = SearchResults.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/")
diff --git a/apps/docs/__tests__/SyntaxLookupBehavior_.test.res b/apps/docs/__tests__/SyntaxLookupBehavior_.test.res
index 94b8fbe10..06b8473ad 100644
--- a/apps/docs/__tests__/SyntaxLookupBehavior_.test.res
+++ b/apps/docs/__tests__/SyntaxLookupBehavior_.test.res
@@ -46,11 +46,11 @@ test("syntax lookup detail marks active content for DocSearch crawling", async (
await viewport(1440, 900)
let _screen = await render(
-
+
Array.getUnsafe(0)}>
{React.string("Detail content for @as decorator.")}
- ,
+ ,
)
switch document->WebAPI.Document.querySelector(".DocSearch-content h1") {
diff --git a/apps/docs/__tests__/visual/BlogArticle_.test.res b/apps/docs/__tests__/visual/BlogArticle_.test.res
index c3f6083a4..3e642fa13 100644
--- a/apps/docs/__tests__/visual/BlogArticle_.test.res
+++ b/apps/docs/__tests__/visual/BlogArticle_.test.res
@@ -24,13 +24,13 @@ test("desktop blog article renders header, author, date, and body", async () =>
await viewport(1440, 900)
let screen = await render(
-
+
{React.string("This is the blog post body content for testing.")}
- ,
+ ,
)
let title = await screen->getByText("Test Blog Post Title")
@@ -50,13 +50,13 @@ test("desktop archived blog article shows warning banner", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{React.string("This is the blog post body content for testing.")}
- ,
+ ,
)
let important = await screen->getByText("Important:")
@@ -70,13 +70,13 @@ test("mobile blog article", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
{React.string("This is the blog post body content for testing.")}
- ,
+ ,
)
let title = await screen->getByText("Test Blog Post Title")
@@ -115,7 +115,7 @@ test("desktop blog article with co-authors shows all authors", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{
{React.string("Collaborative content.")}
- ,
+ ,
)
let mainAuthor = await screen->getByText("Test Author")
@@ -151,7 +151,7 @@ test("desktop blog article with article image shows image", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{
{React.string("Content below the article image.")}
- ,
+ ,
)
let title = await screen->getByText("Blog Post With Article Image")
@@ -185,13 +185,13 @@ test("desktop blog article without description", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{React.string("Content without a description above.")}
- ,
+ ,
)
let title = await screen->getByText("Blog Post Without Description")
diff --git a/apps/docs/__tests__/visual/Blog_.test.res b/apps/docs/__tests__/visual/Blog_.test.res
index 66c62d518..e5de5f052 100644
--- a/apps/docs/__tests__/visual/Blog_.test.res
+++ b/apps/docs/__tests__/visual/Blog_.test.res
@@ -58,11 +58,11 @@ test("desktop blog index shows featured post and cards", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
- ,
+ ,
)
let featured = await screen->getByText("Announcing ReScript 12")
@@ -82,11 +82,11 @@ test("desktop blog shows category selector", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
- ,
+ ,
)
let allTab = await screen->getByText("All")
@@ -103,11 +103,11 @@ test("mobile blog index", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
- ,
+ ,
)
let featured = await screen->getByText("Announcing ReScript 12")
@@ -121,11 +121,11 @@ test("blog shows empty state when no posts", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
- ,
+ ,
)
let emptyMsg = await screen->getByText("Blog not yet available")
@@ -170,11 +170,11 @@ test("desktop blog shows archived posts", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
- ,
+ ,
)
let post1 = await screen->getByText("An Old Archived Post")
@@ -208,11 +208,11 @@ test("desktop blog with single post", async () => {
]
let screen = await render(
-
+
- ,
+ ,
)
let post = await screen->getByText("The Only Post")
diff --git a/apps/docs/__tests__/visual/CodeExample_.test.res b/apps/docs/__tests__/visual/CodeExample_.test.res
index 2497fb025..6ddd0e527 100644
--- a/apps/docs/__tests__/visual/CodeExample_.test.res
+++ b/apps/docs/__tests__/visual/CodeExample_.test.res
@@ -62,11 +62,11 @@ test("renders toggle with multiple tabs", async () => {
]
let screen = await render(
-
+
- ,
+ ,
)
let resTab = await screen->getByText("ReScript")
@@ -98,11 +98,11 @@ test("toggle switches between tabs on click", async () => {
]
let screen = await render(
-
+
- ,
+ ,
)
// Click the JavaScript tab
diff --git a/apps/docs/__tests__/visual/CommunityLayout_.test.res b/apps/docs/__tests__/visual/CommunityLayout_.test.res
index dbbe7cb7f..1a288854b 100644
--- a/apps/docs/__tests__/visual/CommunityLayout_.test.res
+++ b/apps/docs/__tests__/visual/CommunityLayout_.test.res
@@ -21,13 +21,13 @@ test("desktop community layout shows sidebar and content", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{React.string("Community content goes here.")}
- ,
+ ,
)
let resources = await screen->getByText("Resources")
@@ -47,13 +47,13 @@ test("mobile community layout hides sidebar", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
{React.string("Community content goes here.")}
- ,
+ ,
)
let resources = await screen->getByText("Resources")
@@ -87,13 +87,13 @@ test("desktop community layout with multiple categories", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{React.string("Community content with multiple categories.")}
- ,
+ ,
)
let resources = await screen->getByText("Resources")
@@ -113,13 +113,13 @@ test("tablet community layout", async () => {
await viewport(900, 900)
let screen = await render(
-
+
{React.string("Community content goes here.")}
- ,
+ ,
)
let resources = await screen->getByText("Resources")
diff --git a/apps/docs/__tests__/visual/Footer_.test.res b/apps/docs/__tests__/visual/Footer_.test.res
index 9d20af2ea..f50fe9ffe 100644
--- a/apps/docs/__tests__/visual/Footer_.test.res
+++ b/apps/docs/__tests__/visual/Footer_.test.res
@@ -5,11 +5,11 @@ test("desktop footer shows all sections and links", async () => {
await viewport(1440, 500)
let screen = await render(
-
+
- ,
+ ,
)
let community = await screen->getByText("Community")
@@ -35,11 +35,11 @@ test("mobile footer stacks sections vertically", async () => {
await viewport(600, 800)
let screen = await render(
-
+
- ,
+ ,
)
let community = await screen->getByText("Community")
diff --git a/apps/docs/__tests__/visual/MainLayout_.test.res b/apps/docs/__tests__/visual/MainLayout_.test.res
index 670d180c0..2b8154cff 100644
--- a/apps/docs/__tests__/visual/MainLayout_.test.res
+++ b/apps/docs/__tests__/visual/MainLayout_.test.res
@@ -5,13 +5,13 @@ test("desktop main layout renders children and footer", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{React.string("Main page content goes here.")}
- ,
+ ,
)
let content = await screen->getByTestId("main-content")
@@ -28,13 +28,13 @@ test("mobile main layout", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
{React.string("Main page content goes here.")}
- ,
+ ,
)
let content = await screen->getByTestId("main-content")
diff --git a/apps/docs/__tests__/visual/MarkdownComponents_.test.res b/apps/docs/__tests__/visual/MarkdownComponents_.test.res
index 7976d557f..eee8e75d4 100644
--- a/apps/docs/__tests__/visual/MarkdownComponents_.test.res
+++ b/apps/docs/__tests__/visual/MarkdownComponents_.test.res
@@ -171,7 +171,7 @@ test("renders links", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
@@ -184,7 +184,7 @@ test("renders links", async () => {
- ,
+ ,
)
let externalLink = await screen->getByText("External link")
diff --git a/apps/docs/__tests__/visual/NavbarPrimary_.test.res b/apps/docs/__tests__/visual/NavbarPrimary_.test.res
index 23c7b50e3..5d8f2025f 100644
--- a/apps/docs/__tests__/visual/NavbarPrimary_.test.res
+++ b/apps/docs/__tests__/visual/NavbarPrimary_.test.res
@@ -5,9 +5,9 @@ test("desktop has everything visible", async () => {
await viewport(1440, 500)
let screen = await render(
-
+
- ,
+ ,
)
let leftContent = await screen->getByTestId("navbar-primary-left-content")
@@ -33,9 +33,9 @@ test("tablet has everything visible", async () => {
await viewport(900, 500)
let screen = await render(
-
+
- ,
+ ,
)
let leftContent = await screen->getByTestId("navbar-primary-left-content")
@@ -61,9 +61,9 @@ test("phone has some things hidden and a mobile nav that can be toggled", async
await viewport(600, 1200)
let screen = await render(
-
+
- ,
+ ,
)
let leftContent = await screen->getByTestId("navbar-primary-left-content")
diff --git a/apps/docs/__tests__/visual/NavbarTertiary_.test.res b/apps/docs/__tests__/visual/NavbarTertiary_.test.res
index 015c6d11e..ba8daf6df 100644
--- a/apps/docs/__tests__/visual/NavbarTertiary_.test.res
+++ b/apps/docs/__tests__/visual/NavbarTertiary_.test.res
@@ -19,12 +19,12 @@ test("desktop shows breadcrumbs and edit link", async () => {
await viewport(1440, 500)
let screen = await render(
-
+
breadcrumbs
editLink
- ,
+ ,
)
let navbar = await screen->getByTestId("navbar-tertiary")
@@ -44,12 +44,12 @@ test("mobile shows breadcrumbs and drawer button", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
breadcrumbs
editLink
- ,
+ ,
)
let navbar = await screen->getByTestId("navbar-tertiary")
diff --git a/apps/docs/__tests__/visual/SidebarNav_.test.res b/apps/docs/__tests__/visual/SidebarNav_.test.res
index 94303a657..70c5906f0 100644
--- a/apps/docs/__tests__/visual/SidebarNav_.test.res
+++ b/apps/docs/__tests__/visual/SidebarNav_.test.res
@@ -14,11 +14,11 @@ test("sidebar category renders title and nav items", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
()} />
- ,
+ ,
)
let introduction = await screen->getByText("Introduction")
@@ -38,7 +38,7 @@ test("sidebar category highlights active item", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
{
onClick={_ => ()}
/>
- ,
+ ,
)
let introduction = await screen->getByText("Introduction")
@@ -117,11 +117,11 @@ test("sidebar category with many items", async () => {
}
let screen = await render(
-
+
()} />
- ,
+ ,
)
let stringItem = await screen->getByText("String")
diff --git a/apps/docs/__tests__/visual/SyntaxLookup_.test.res b/apps/docs/__tests__/visual/SyntaxLookup_.test.res
index 38b3bb864..85a838a32 100644
--- a/apps/docs/__tests__/visual/SyntaxLookup_.test.res
+++ b/apps/docs/__tests__/visual/SyntaxLookup_.test.res
@@ -44,11 +44,11 @@ test("desktop syntax lookup renders categories and tags", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
- ,
+ ,
)
let decoratorsHeading = await screen->getByText("Decorators")
@@ -71,13 +71,13 @@ test("desktop syntax lookup with active item shows detail box", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
Array.getUnsafe(0)}>
{React.string("Detail content for @as decorator.")}
- ,
+ ,
)
let detail = await screen->getByText("Detail content for @as decorator.")
@@ -91,11 +91,11 @@ test("mobile syntax lookup", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
- ,
+ ,
)
let decoratorsHeading = await screen->getByText("Decorators")
@@ -118,11 +118,11 @@ test("deprecated items show with line-through styling", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
- ,
+ ,
)
let deprecatedTag = await screen->getByText("|>")
@@ -136,13 +136,13 @@ test("syntax lookup detail box shows summary", async () => {
await viewport(1440, 900)
let screen = await render(
-
+
Array.getUnsafe(2)}>
{React.string("Detailed documentation about the pipe operator.")}
- ,
+ ,
)
let detail = await screen->getByText("Detailed documentation about the pipe operator.")
@@ -156,13 +156,13 @@ test("mobile syntax lookup with active item", async () => {
await viewport(600, 1200)
let screen = await render(
-
+
Array.getUnsafe(0)}>
{React.string("Detail content for @as decorator.")}
- ,
+ ,
)
let detail = await screen->getByText("Detail content for @as decorator.")
diff --git a/apps/docs/app/routes/ApiDocs.res b/apps/docs/app/routes/ApiDocs.res
index 3d5df459b..6d423b5a0 100644
--- a/apps/docs/app/routes/ApiDocs.res
+++ b/apps/docs/app/routes/ApiDocs.res
@@ -221,7 +221,7 @@ module MarkdownStylize = {
let make = (~content, ~rehypePlugins) => {
let components = {
...MarkdownComponents.default,
- h2: MarkdownComponents.default.h3->Obj.magic,
+ h2: props => props.children ,
}
content
}
@@ -367,7 +367,7 @@ module Data = {
}
let processStaticProps = (~slug: array) => {
- let moduleName = slug->Belt.Array.getExn(0)
+ let moduleName = slug->Array.at(0)->Option.getOrThrow
let modulePath = slug->Array.join("/")
let content =
@@ -456,7 +456,7 @@ let processStaticProps = (~slug: array) => {
items: sortedItems,
}
- Ok({module_, toctree: Obj.magic({name: "root", path: [], children: []})})
+ Ok({module_, toctree: {name: "root", path: [], children: []}})
| None => Error(`Failed to get API Data for module ${moduleName}`)
}
diff --git a/apps/docs/app/routes/Packages.res b/apps/docs/app/routes/Packages.res
index c8d2e416c..319501055 100644
--- a/apps/docs/app/routes/Packages.res
+++ b/apps/docs/app/routes/Packages.res
@@ -112,7 +112,7 @@ module Resource = {
let filteredNpm = applyNpmSearch(allNpms, pattern)->Array.map(m => Npm(m["item"]))
let filteredOutdated = applyNpmSearch(allOutDated, pattern)->Array.map(m => Outdated(m["item"]))
- Belt.Array.concatMany([filteredNpm, filteredOutdated])
+ Array.concat(filteredNpm, filteredOutdated)
}
}
@@ -326,7 +326,7 @@ let make = (props: props) => {
let allResources = {
let npms = props.packages->Array.map(pkg => Resource.Npm(pkg))
let outdated = props.unmaintained->Array.map(pkg => Resource.Outdated(pkg))
- Belt.Array.concatMany([npms, outdated])
+ Array.concat(npms, outdated)
}
let resources = switch state {
diff --git a/apps/docs/e2e/Breadcrumbs.cy.res b/apps/docs/e2e/Breadcrumbs.cy.res
index 25d6ad419..ba30797bc 100644
--- a/apps/docs/e2e/Breadcrumbs.cy.res
+++ b/apps/docs/e2e/Breadcrumbs.cy.res
@@ -1,15 +1,15 @@
-open Cy
+open Cypress
let waitForPage = () => {
- getByTestId("navbar-primary")->shouldBeVisible->ignore
- getByTestId("navbar-tertiary")->shouldBeVisible->ignore
- cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore
+ getByTestId("navbar-primary")->should("be.visible")->ignore
+ getByTestId("navbar-tertiary")->should("be.visible")->ignore
+ cyWindow()->propertyString("document.readyState")->shouldEqual("complete")->ignore
}
let expectBreadcrumbsText = text => {
getByTestId("breadcrumbs")
->should("be.visible")
- ->shouldWithValue("have.text", text)
+ ->shouldText(text)
->ignore
}
diff --git a/apps/docs/e2e/Navigation.cy.res b/apps/docs/e2e/Navigation.cy.res
index e046d26a3..c123961f9 100644
--- a/apps/docs/e2e/Navigation.cy.res
+++ b/apps/docs/e2e/Navigation.cy.res
@@ -1,12 +1,12 @@
-open Cy
+open Cypress
// Wait for the app to fully hydrate before interacting with links.
// The production build is pre-rendered so React must attach event
// handlers before client-side navigation works.
let waitForHydration = () => {
- getByTestId("navbar-primary")->shouldBeVisible->ignore
- cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore
- wait(2000)
+ getByTestId("navbar-primary")->should("be.visible")->ignore
+ cyWindow()->propertyString("document.readyState")->shouldEqual("complete")->ignore
+ pause(2000)
}
// Use short, re-queryable selectors to avoid detached DOM issues.
@@ -17,7 +17,7 @@ let waitForHydration = () => {
let clickNavLink = (~testId, ~text) => {
getByTestId(testId)
->should("be.visible")
- ->containsSelectorChainable("a", text)
+ ->containsChildIn("a", text)
->scrollIntoView
->should("be.visible")
->click
@@ -27,7 +27,7 @@ let clickNavLink = (~testId, ~text) => {
let clickMobileNavLink = text => {
getByTestId("mobile-nav")
->should("be.visible")
- ->containsSelectorChainable("a", text)
+ ->containsChildIn("a", text)
->scrollIntoView
->should("be.visible")
->click
@@ -53,8 +53,8 @@ describe("Desktop Navigation", () => {
"should navigate to Docs via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
- url()->shouldInclude("/docs/manual/introduction")->ignore
- get("h1")->shouldBeVisible->ignore
+ currentUrl()->shouldInclude("/docs/manual/introduction")->ignore
+ get("h1")->should("be.visible")->ignore
},
)
@@ -62,7 +62,7 @@ describe("Desktop Navigation", () => {
"should navigate to Playground via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Playground")
- url()->shouldInclude("/try")->ignore
+ currentUrl()->shouldInclude("/try")->ignore
},
)
@@ -70,7 +70,7 @@ describe("Desktop Navigation", () => {
"should navigate to Blog via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Blog")
- url()->shouldInclude("/blog")->ignore
+ currentUrl()->shouldInclude("/blog")->ignore
},
)
@@ -78,8 +78,8 @@ describe("Desktop Navigation", () => {
"should navigate to Community via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Community")
- url()->shouldInclude("/community")->ignore
- get("h1")->shouldBeVisible->ignore
+ currentUrl()->shouldInclude("/community")->ignore
+ get("h1")->should("be.visible")->ignore
},
)
@@ -87,10 +87,10 @@ describe("Desktop Navigation", () => {
"should navigate home via logo after clicking away",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Blog")
- url()->shouldInclude("/blog")->ignore
+ currentUrl()->shouldInclude("/blog")->ignore
get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore
- cyLocation("pathname")->shouldWithValue("eq", "/")->ignore
+ cyLocation("pathname")->shouldEqual("/")->ignore
},
)
})
@@ -101,28 +101,28 @@ describe("Desktop Navigation", () => {
() => {
// Click Docs in primary nav to reveal the secondary nav
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
- url()->shouldInclude("/docs/manual/introduction")->ignore
+ currentUrl()->shouldInclude("/docs/manual/introduction")->ignore
// Language Manual
clickNavLink(~testId="navbar-secondary", ~text="Language Manual")
- url()->shouldInclude("/docs/manual/introduction")->ignore
+ currentUrl()->shouldInclude("/docs/manual/introduction")->ignore
// API
clickNavLink(~testId="navbar-secondary", ~text="API")
- url()->shouldInclude("/docs/manual/api")->ignore
+ currentUrl()->shouldInclude("/docs/manual/api")->ignore
// Guides
clickNavLink(~testId="navbar-secondary", ~text="Guides")
- url()->shouldInclude("/docs/guides/overview")->ignore
+ currentUrl()->shouldInclude("/docs/guides/overview")->ignore
get("h1")->shouldContainText("Guides")->ignore
// Syntax Lookup
clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup")
- url()->shouldInclude("/syntax-lookup")->ignore
+ currentUrl()->shouldInclude("/syntax-lookup")->ignore
// React
clickNavLink(~testId="navbar-secondary", ~text="React")
- url()->shouldInclude("/docs/react/introduction")->ignore
+ currentUrl()->shouldInclude("/docs/react/introduction")->ignore
},
)
})
@@ -142,8 +142,8 @@ describe("Mobile Navigation", () => {
"should navigate to Docs via navbar link",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
- url()->shouldInclude("/docs/manual/introduction")->ignore
- get("h1")->shouldBeVisible->ignore
+ currentUrl()->shouldInclude("/docs/manual/introduction")->ignore
+ get("h1")->should("be.visible")->ignore
},
)
@@ -151,10 +151,10 @@ describe("Mobile Navigation", () => {
"should navigate home via logo after clicking away",
() => {
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
- url()->shouldInclude("/docs/manual/introduction")->ignore
+ currentUrl()->shouldInclude("/docs/manual/introduction")->ignore
get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore
- cyLocation("pathname")->shouldWithValue("eq", "/")->ignore
+ cyLocation("pathname")->shouldEqual("/")->ignore
},
)
})
@@ -165,7 +165,7 @@ describe("Mobile Navigation", () => {
() => {
openMobileMenu()
clickMobileNavLink("Playground")
- url()->shouldInclude("/try")->ignore
+ currentUrl()->shouldInclude("/try")->ignore
},
)
@@ -174,7 +174,7 @@ describe("Mobile Navigation", () => {
() => {
openMobileMenu()
clickMobileNavLink("Blog")
- url()->shouldInclude("/blog")->ignore
+ currentUrl()->shouldInclude("/blog")->ignore
},
)
@@ -183,8 +183,8 @@ describe("Mobile Navigation", () => {
() => {
openMobileMenu()
clickMobileNavLink("Community")
- url()->shouldInclude("/community")->ignore
- get("h1")->shouldBeVisible->ignore
+ currentUrl()->shouldInclude("/community")->ignore
+ get("h1")->should("be.visible")->ignore
},
)
})
@@ -195,35 +195,35 @@ describe("Mobile Navigation", () => {
() => {
// Click Docs in primary nav to reveal the secondary nav
clickNavLink(~testId="navbar-primary-left-content", ~text="Docs")
- url()->shouldInclude("/docs/manual/introduction")->ignore
+ currentUrl()->shouldInclude("/docs/manual/introduction")->ignore
// Scroll to top so the secondary nav is visible
cyScrollTo("top")
// Language Manual
clickNavLink(~testId="navbar-secondary", ~text="Language Manual")
- url()->shouldInclude("/docs/manual/introduction")->ignore
+ currentUrl()->shouldInclude("/docs/manual/introduction")->ignore
// API
cyScrollTo("top")
clickNavLink(~testId="navbar-secondary", ~text="API")
- url()->shouldInclude("/docs/manual/api")->ignore
+ currentUrl()->shouldInclude("/docs/manual/api")->ignore
// Guides
cyScrollTo("top")
clickNavLink(~testId="navbar-secondary", ~text="Guides")
- url()->shouldInclude("/docs/guides/overview")->ignore
+ currentUrl()->shouldInclude("/docs/guides/overview")->ignore
get("h1")->shouldContainText("Guides")->ignore
// Syntax Lookup
cyScrollTo("top")
clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup")
- url()->shouldInclude("/syntax-lookup")->ignore
+ currentUrl()->shouldInclude("/syntax-lookup")->ignore
// React
cyScrollTo("top")
clickNavLink(~testId="navbar-secondary", ~text="React")
- url()->shouldInclude("/docs/react/introduction")->ignore
+ currentUrl()->shouldInclude("/docs/react/introduction")->ignore
},
)
})
diff --git a/apps/docs/e2e/OgImage.cy.res b/apps/docs/e2e/OgImage.cy.res
new file mode 100644
index 000000000..927315bf6
--- /dev/null
+++ b/apps/docs/e2e/OgImage.cy.res
@@ -0,0 +1,19 @@
+open Cypress
+
+describe("Open Graph images", () => {
+ it("renders a PNG for a same-origin documentation page", () => {
+ let pageUrl = WebAPI.URL.make(~url="/docs/manual/introduction", ~base=baseUrl())
+ let url = `/ogimage/index.png?url=${encodeURIComponent(pageUrl.href)}`
+
+ requestWithOptions({url, encoding: "binary"})
+ ->then(
+ response => {
+ expect(response.status)->equal(200)
+ expect(response.headers->Dict.get("content-type")->Option.getOr(""))->include_("image/png")
+ // The PNG signature verifies that the response is an image rather than an HTML error page.
+ expect(response.body->String.slice(~start=0, ~end=8))->equal("\x89PNG\r\n\x1a\n")
+ },
+ )
+ ->ignore
+ })
+})
diff --git a/apps/docs/e2e/Playground.cy.res b/apps/docs/e2e/Playground.cy.res
index 8e8bf97c6..165510c1e 100644
--- a/apps/docs/e2e/Playground.cy.res
+++ b/apps/docs/e2e/Playground.cy.res
@@ -1,20 +1,20 @@
-open Cy
+open Cypress
let waitForHydration = () => {
- getByTestId("navbar-primary")->shouldBeVisible->ignore
- cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore
- wait(2000)
+ getByTestId("navbar-primary")->should("be.visible")->ignore
+ cyWindow()->propertyString("document.readyState")->shouldEqual("complete")->ignore
+ pause(2000)
}
let waitForPlayground = () => {
get(".cm-editor")->should("be.visible")->ignore
getByTestId("control-panel")->should("be.visible")->ignore
- wait(2000)
+ pause(2000)
}
let clickNavLink = (~testId, ~text) => {
get(`[data-testid="${testId}"] a:visible`)
- ->containsChainable(text)
+ ->containsChild(text)
->click
->ignore
}
@@ -29,7 +29,7 @@ let compileAndRunImportedCode = () => {
)
->ignore
- wait(3000)
+ pause(3000)
contains("JavaScript")->click->ignore
get("pre.whitespace-pre-wrap")
@@ -39,7 +39,7 @@ let compileAndRunImportedCode = () => {
getByTestId("control-panel")
->find("button")
- ->containsChainable("Run")
+ ->containsChild("Run")
->click
->ignore
@@ -66,9 +66,9 @@ describe("Playground", () => {
it("should highlight JavaScript after a direct playground load", () => {
visit("/try")
- get(".cm-editor")->shouldBeVisible->ignore
- get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore
- get(".cm-editor")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore
+ get(".cm-editor")->should("be.visible")->ignore
+ get("main")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore
+ get(".cm-editor")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore
get("pre.whitespace-pre-wrap")->shouldContainText("react/jsx-runtime")->ignore
get("pre code.lang-js span[class^='hljs-']")->should("exist")->ignore
})
@@ -76,7 +76,7 @@ describe("Playground", () => {
it("should compile and run imported code in the playground", () => {
// Navigate to the playground from the homepage
clickNavLink(~testId="navbar-primary-left-content", ~text="Playground")
- url()->shouldInclude("/try")->ignore
+ currentUrl()->shouldInclude("/try")->ignore
compileAndRunImportedCode()
})
@@ -91,7 +91,7 @@ describe("Playground", () => {
contains("Settings")->click->ignore
get(`select[name="compilerVersions"]`)
->select(version)
- ->shouldWithValue("have.value", version)
+ ->shouldValue(version)
->ignore
compileAndRunImportedCode()
@@ -116,7 +116,7 @@ Console.log(isRetriable(HttpError({status: 500})))`
get("pre.whitespace-pre-wrap")->shouldContainText("Primitive_exceptions.mjs")->ignore
getByTestId("control-panel")
->find("button")
- ->containsChainable("Run")
+ ->containsChild("Run")
->click
->ignore
@@ -124,13 +124,13 @@ Console.log(isRetriable(HttpError({status: 500})))`
})
it("should open the landing page example in the playground with code and compiled output", () => {
- containsSelector("a", "Edit this example in Playground")
+ containsIn("a", "Edit this example in Playground")
->scrollIntoView
->should("be.visible")
->click
->ignore
- url()->shouldInclude("/try?code=")->ignore
+ currentUrl()->shouldInclude("/try?code=")->ignore
waitForPlayground()
get(".cm-content")
@@ -148,45 +148,43 @@ Console.log(isRetriable(HttpError({status: 500})))`
it("should switch to light mode from toast and back to dark mode in settings", () => {
// Navigate to playground and wait for initial render
clickNavLink(~testId="navbar-primary-left-content", ~text="Playground")
- url()->shouldInclude("/try")->ignore
+ currentUrl()->shouldInclude("/try")->ignore
waitForPlayground()
// Switch to light mode through the onboarding toast
getByTestId("playground-lightmode-toast")
->should("be.visible")
->find("button")
- ->containsChainable("Try it now")
+ ->containsChild("Try it now")
->click
->ignore
// Verify playground shell is in light mode
- get("main")->shouldWithValue("have.class", "playground-theme-light")->ignore
- get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(250, 251, 252)")->ignore
+ get("main")->shouldClass("playground-theme-light")->ignore
+ get("main")->shouldCss("background-color", "rgb(250, 251, 252)")->ignore
get(".cm-editor")
- ->shouldWithKeyValue("have.css", "background-color", "rgb(255, 255, 255)")
+ ->shouldCss("background-color", "rgb(255, 255, 255)")
->ignore
cyWindow()
- ->its("localStorage")
- ->invokeWithArg("getItem", "playgroundTheme")
- ->shouldWithValue("eq", "light")
+ ->thenMap(window => window.localStorage->WebAPI.Storage.getItem("playgroundTheme"))
+ ->shouldEqual(Null.make("light"))
->ignore
// Switch back to dark mode from Settings
contains("Settings")->click->ignore
get("main")
->find("button")
- ->containsChainable("Dark")
+ ->containsChild("Dark")
->click
->ignore
// Verify playground shell is back to dark mode
- get("main")->shouldWithValue("have.class", "playground-theme-dark")->ignore
- get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore
- get(".cm-editor")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore
+ get("main")->shouldClass("playground-theme-dark")->ignore
+ get("main")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore
+ get(".cm-editor")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore
cyWindow()
- ->its("localStorage")
- ->invokeWithArg("getItem", "playgroundTheme")
- ->shouldWithValue("eq", "dark")
+ ->thenMap(window => window.localStorage->WebAPI.Storage.getItem("playgroundTheme"))
+ ->shouldEqual(Null.make("dark"))
->ignore
})
})
diff --git a/apps/docs/e2e/WarningFlags.cy.res b/apps/docs/e2e/WarningFlags.cy.res
new file mode 100644
index 000000000..62f789c1f
--- /dev/null
+++ b/apps/docs/e2e/WarningFlags.cy.res
@@ -0,0 +1,40 @@
+open Cypress
+
+let expectSelectedSuggestionInView = (~scrolled, elements) => {
+ let selected = elements->item(0)->Option.getOrThrow->HtmlRef.fromDom
+ let parent = selected.parentElement->Null.getOrThrow
+ let selectedRect = selected->WebAPI.HTMLElement.getBoundingClientRect
+ let parentRect = parent->WebAPI.HTMLElement.getBoundingClientRect
+ expect(selectedRect.top)->atLeast(parentRect.top -. 1.0)
+ expect(parentRect.bottom +. 1.0)->atLeast(selectedRect.bottom)
+ if scrolled {
+ expect(parent.scrollTop)->greaterThanFloat(0.0)
+ } else {
+ expect(parent.scrollTop)->closeTo(0.0, 1.0)
+ }
+}
+
+describe("Warning flag suggestions", () => {
+ it("scrolls the selected suggestion into view in both keyboard directions", () => {
+ viewport(1280, 720)
+ visit("/try")
+ get(".cm-editor")->should("be.visible")->ignore
+ get("pre.whitespace-pre-wrap")->shouldContainText("react/jsx-runtime")->ignore
+ containsIn("button", "Settings")->click->ignore
+ let input = "input[placeholder='Flags']"
+ get(input)->typeText("+")->ignore
+ for _ in 1 to 15 {
+ get(input)->typeText("{downarrow}")->ignore
+ }
+ get(".playground-suggestion-active")
+ ->shouldSatisfy(elements => expectSelectedSuggestionInView(~scrolled=true, elements))
+ ->ignore
+
+ for _ in 1 to 15 {
+ get(input)->typeText("{uparrow}")->ignore
+ }
+ get(".playground-suggestion-active")
+ ->shouldSatisfy(elements => expectSelectedSuggestionInView(~scrolled=false, elements))
+ ->ignore
+ })
+})
diff --git a/apps/docs/e2e/bindings/Cy.res b/apps/docs/e2e/bindings/Cy.res
deleted file mode 100644
index d34ef610d..000000000
--- a/apps/docs/e2e/bindings/Cy.res
+++ /dev/null
@@ -1,141 +0,0 @@
-// -- Chainable type -----------------------------------------------------------
-
-type t
-
-// -- Cypress global commands --------------------------------------------------
-
-@val @scope("cy")
-external visit: string => unit = "visit"
-
-@val @scope("cy")
-external visitWithOptions: (string, {..}) => unit = "visit"
-
-@val @scope("cy")
-external get: string => t = "get"
-
-@val @scope("cy")
-external contains: string => t = "contains"
-
-@val @scope("cy")
-external containsSelector: (string, string) => t = "contains"
-
-@val @scope("cy")
-external title: unit => t = "title"
-
-@val @scope("cy")
-external url: unit => t = "url"
-
-@val @scope("cy")
-external cyLocation: string => t = "location"
-
-@val @scope("cy")
-external wait: int => unit = "wait"
-
-@val @scope("cy")
-external reload: unit => unit = "reload"
-
-@val @scope("cy")
-external go: string => unit = "go"
-
-@val @scope("cy")
-external viewport: (int, int) => unit = "viewport"
-
-@val @scope("cy")
-external cyScrollTo: string => unit = "scrollTo"
-
-@val @scope("cy")
-external log: string => unit = "log"
-
-// -- Chainable commands -------------------------------------------------------
-
-// Contains (chainable versions)
-@send external containsChainable: (t, string) => t = "contains"
-@send external containsSelectorChainable: (t, string, string) => t = "contains"
-
-// Queries
-@send external find: (t, string) => t = "find"
-@send external first: t => t = "first"
-@send external last: t => t = "last"
-@send external eq: (t, int) => t = "eq"
-@send external children: t => t = "children"
-@send external parent: t => t = "parent"
-@send external parents: (t, string) => t = "parents"
-@send external closest: (t, string) => t = "closest"
-@send external next: t => t = "next"
-@send external prev: t => t = "prev"
-@send external siblings: t => t = "siblings"
-@send external filter: (t, string) => t = "filter"
-@send external not_: (t, string) => t = "not"
-
-// Actions
-@send external click: t => t = "click"
-@send external clickWithOptions: (t, {..}) => t = "click"
-@send external dblclick: t => t = "dblclick"
-@send external type_: (t, string) => t = "type"
-@send external typeWithOptions: (t, string, {..}) => t = "type"
-@send external clear: t => t = "clear"
-@send external check: t => t = "check"
-@send external uncheck: t => t = "uncheck"
-@send external select: (t, string) => t = "select"
-@send external trigger: (t, string) => t = "trigger"
-@send external scrollIntoView: t => t = "scrollIntoView"
-@send external focus: t => t = "focus"
-@send external blur: t => t = "blur"
-
-// Assertions
-@send external should: (t, string) => t = "should"
-@send external shouldWithValue: (t, string, string) => t = "should"
-@send external shouldWithKeyValue: (t, string, string, string) => t = "should"
-@send external and_: (t, string) => t = "and"
-@send external andWithValue: (t, string, string) => t = "and"
-
-// Traversal
-@send external each: (t, t => unit) => t = "each"
-@send external then: (t, t => unit) => t = "then"
-@send external its: (t, string) => t = "its"
-
-// Attributes
-@send external invoke: (t, string) => t = "invoke"
-@send external invokeWithArg: (t, string, string) => t = "invoke"
-@send external attr: (t, string) => t = "attr"
-
-// Visibility & state
-@send external asChainable: (t, string) => t = "as"
-
-// Yielding
-@send external within: (t, unit => unit) => t = "within"
-
-// -- Describe / It (Mocha globals) --------------------------------------------
-
-@val external describe: (string, unit => unit) => unit = "describe"
-@val external it: (string, unit => unit) => unit = "it"
-@val external before: (unit => unit) => unit = "before"
-@val external beforeEach: (unit => unit) => unit = "beforeEach"
-@val external after: (unit => unit) => unit = "after"
-@val external afterEach: (unit => unit) => unit = "afterEach"
-@val external context: (string, unit => unit) => unit = "context"
-
-// -- Window -------------------------------------------------------------------
-
-@val @scope("cy")
-external cyWindow: unit => t = "window"
-
-// -- Convenience helpers ------------------------------------------------------
-
-let getByTestId = testId => get(`[data-testid="${testId}"]`)
-
-let getByRole = role => get(`[role="${role}"]`)
-
-let getByHref = href => get(`a[href="${href}"]`)
-
-let shouldBeVisible = chain => chain->should("be.visible")
-
-let shouldBeVisibleAndClick = chain => chain->should("be.visible")->click
-
-let shouldExist = chain => chain->should("exist")
-
-let shouldContainText = (chain, text) => chain->shouldWithValue("contain.text", text)
-
-let shouldHaveAttr = (chain, attr, value) => chain->shouldWithKeyValue("have.attr", attr, value)
-
-let shouldInclude = (chain, value) => chain->shouldWithValue("include", value)
diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res
index be8c3c607..e9c164ff8 100644
--- a/apps/docs/e2e/bindings/Cypress.res
+++ b/apps/docs/e2e/bindings/Cypress.res
@@ -12,6 +12,7 @@ type rec window = {
console: console,
navigator: {clipboard: clipboard},
scrollY: float,
+ localStorage: WebAPI.WebStorageAPI.storage,
}
and clipboard
type response = {status: int, body: string, headers: Dict.t}
@@ -214,3 +215,26 @@ external addEventListenerOnce: (Dom.document, string, unit => unit, listenerOpti
@get external fontWeight: cssStyle => string = "fontWeight"
@get external fontStyle: cssStyle => string = "fontStyle"
@send external propertyValue: (cssStyle, string) => string = "getPropertyValue"
+
+@val external describe: (string, unit => unit) => unit = "describe"
+@val @scope("cy") external currentUrl: unit => chain = "url"
+@val @scope("cy") external pause: int => unit = "wait"
+@val @scope("cy") external cyScrollTo: string => unit = "scrollTo"
+@send external find: (chain, string) => chain = "find"
+@send external containsChild: (chain, string) => chain = "contains"
+@send external containsChildIn: (chain, string, string) => chain = "contains"
+@send external select: (chain, string) => chain = "select"
+@send
+external typeWithOptions: (
+ chain,
+ string,
+ {"force": bool, "delay": int},
+) => chain = "type"
+@send
+external shouldContainText: (chain, @as("contain.text") _, string) => chain =
+ "should"
+@send external shouldInclude: (chain, @as("include") _, string) => chain = "should"
+@send
+external shouldClass: (chain, @as("have.class") _, string) => chain = "should"
+
+let getByTestId = testId => get(`[data-testid="${testId}"]`)
diff --git a/apps/docs/e2e/homepage/HomepageInteractions.cy.res b/apps/docs/e2e/homepage/HomepageInteractions.cy.res
index a5e242d8e..5243097eb 100644
--- a/apps/docs/e2e/homepage/HomepageInteractions.cy.res
+++ b/apps/docs/e2e/homepage/HomepageInteractions.cy.res
@@ -1,6 +1,16 @@
open Cypress
open HomepageHelpers
+type clipboardDescriptor = {value: Nullable.t, configurable: bool}
+
+@val @scope("Object")
+external hideClipboardAPI: ('navigator, @as("clipboard") _, clipboardDescriptor) => unit =
+ "defineProperty"
+
+@val @scope("cy")
+external stubCopyCommand: (Dom.document, @as("execCommand") _) => spy = "stub"
+@send external returns: (spy, bool) => spy = "returns"
+
it("community gallery supports keyboard selection and wraps to the first photo", () => {
visit("/")
containsIn("h1", headline)->realClick->ignore
@@ -18,15 +28,24 @@ it("community gallery supports keyboard selection and wraps to the first photo",
get(`img[alt="ReScript community photo 1"]`)->should("be.visible")->ignore
})
-it("clipboard denial can recover and both install commands can be copied repeatedly", () => {
+it("clipboard failure can recover and both install commands can be copied repeatedly", () => {
denyClipboardPermissions()
- visit("/")
+ visitWithOptions(
+ "/",
+ {
+ onBeforeLoad: window => {
+ // Denying the native API still allows the legacy copy command, so fail it too.
+ window.document->stubCopyCommand->returns(false)->ignore
+ },
+ },
+ )
let first = `button[aria-label="Copy npm install rescript command"]`
get(`[role="status"]`)->shouldInt("have.length", 2)->ignore
get(`button [role="status"]`)->should("not.exist")->ignore
get(first)->realClick->ignore
containsIn(`[role="status"]`, "Could not copy. Try again.")->should("be.visible")->ignore
get(first)->should("be.enabled")->ignore
+ get("textarea")->should("not.exist")->ignore
grantClipboardPermissions()
["npm install rescript", "npx create-rescript-app"]->Array.forEach(command => {
@@ -42,3 +61,23 @@ it("clipboard denial can recover and both install commands can be copied repeate
get(button)->should("be.enabled")->ignore
})
})
+
+it("copying falls back when the Clipboard API is unavailable", () => {
+ grantClipboardPermissions()
+ let nativeClipboard = ref(None)
+ visitWithOptions(
+ "/",
+ {
+ onBeforeLoad: window => {
+ nativeClipboard := Some(window.navigator.clipboard)
+ hideClipboardAPI(window.navigator, {value: Nullable.undefined, configurable: true})
+ },
+ },
+ )
+ get(`button[aria-label="Copy npm install rescript command"]`)->realClick->ignore
+ containsIn(`[role="status"]`, "Copied!")->should("be.visible")->ignore
+ runPromise(() => nativeClipboard.contents->Option.getOrThrow->readText)
+ ->shouldEqual("npm install rescript")
+ ->ignore
+ get("textarea")->should("not.exist")->ignore
+})
diff --git a/apps/docs/functions/ogimage/[[path]]/index.png.res b/apps/docs/functions/ogimage/[[path]]/index.png.res
index e3526a3f3..7b5d6f480 100644
--- a/apps/docs/functions/ogimage/[[path]]/index.png.res
+++ b/apps/docs/functions/ogimage/[[path]]/index.png.res
@@ -1,7 +1,5 @@
open WebAPI
-%%raw("import React from 'react'")
-
let loadGoogleFont = async (family: string) => {
let url = `https://fonts.googleapis.com/css2?family=${family}`
let css = await (await fetch(url))->Response.text
diff --git a/apps/docs/functions/tsconfig.json b/apps/docs/functions/tsconfig.json
new file mode 100644
index 000000000..a224293f4
--- /dev/null
+++ b/apps/docs/functions/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "compilerOptions": {
+ "jsx": "react-jsx"
+ }
+}
diff --git a/apps/docs/package.json b/apps/docs/package.json
index e7350a978..4345a9bc8 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -63,7 +63,6 @@
"mdast-util-toc": "^7.1.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
- "react-markdown": "^10.1.0",
"react-router": "^8.3.1",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
diff --git a/apps/docs/scripts/__tests__/Clipboard_.test.res b/apps/docs/scripts/__tests__/Clipboard_.test.res
new file mode 100644
index 000000000..b1e12a4b1
--- /dev/null
+++ b/apps/docs/scripts/__tests__/Clipboard_.test.res
@@ -0,0 +1,88 @@
+open ScriptTest
+
+type dom = {window: WebAPI.DOMAPI.window}
+type clipboard = {writeText: string => promise}
+type navigator = {clipboard?: clipboard}
+@module("jsdom") @new external makeDom: string => dom = "JSDOM"
+@module("vitest") @scope("vi") external stubGlobal: (string, 'a) => unit = "stubGlobal"
+@module("vitest") @scope("vi") external unstubGlobals: unit => unit = "unstubAllGlobals"
+@send external close: WebAPI.DOMAPI.window => unit = "close"
+@set external setCopyCommand: (WebAPI.DOMAPI.document, string => bool) => unit = "execCommand"
+@get external textareaValue: WebAPI.DOMAPI.element => string = "value"
+
+let setup = (~writeNative=?) => {
+ let dom = makeDom("Selected text
Original focus ")
+ stubGlobal("document", dom.window.document)
+ stubGlobal(
+ "navigator",
+ {clipboard: ?(writeNative->Option.map(writeText => {writeText: writeText}))},
+ )
+ onTestFinished(async () => {
+ unstubGlobals()
+ close(dom.window)
+ })
+ dom.window.document
+}
+
+let textareaCount = document => (document->WebAPI.Document.querySelectorAll("textarea")).length
+
+test("clipboard prefers the native API without creating a fallback textarea", async () => {
+ let copied = ref("")
+ let document = setup(~writeNative=async text => copied := text)
+ setCopyCommand(document, _ => {
+ throw(Failure("Fallback should not run"))
+ })
+ expect(await Clipboard.writeText("let answer = 42"))->toStrictEqual(Ok())
+ expect(copied.contents)->toBe("let answer = 42")
+ expect(textareaCount(document))->toBe(0)
+})
+
+test("missing Clipboard API falls back and restores focus and selected text", async () => {
+ let document = setup()
+ let button = document->WebAPI.Document.querySelector("button")->Null.getOrThrow
+ Clipboard.focusElement(button)
+ let paragraph = document->WebAPI.Document.querySelector("p")->Null.getOrThrow
+ let selection = document->WebAPI.Document.getSelection
+ let range = document->WebAPI.Document.createRange
+ range->WebAPI.Range.selectNodeContents(paragraph->WebAPI.Element.asNode)
+ selection->WebAPI.Selection.removeAllRanges
+ selection->WebAPI.Selection.addRange(range)
+ expect(String.make(selection))->toBe("Selected text")
+
+ let copied = ref("")
+ setCopyCommand(document, command => {
+ expect(command)->toBe("copy")
+ let textarea = document->WebAPI.Document.querySelector("textarea")->Null.getOrThrow
+ copied := textarea->textareaValue
+ true
+ })
+
+ expect(await Clipboard.writeText("let answer = 42"))->toStrictEqual(Ok())
+ expect(copied.contents)->toBe("let answer = 42")
+ expect(document.activeElement)->toBe(Null.make(button))
+ expect(String.make(selection))->toBe("Selected text")
+ expect(textareaCount(document))->toBe(0)
+})
+
+test("a rejected native clipboard write tries the selection fallback", async () => {
+ let document = setup(~writeNative=async _ => throw(Failure("Permission denied")))
+ let commands = ref([])
+ setCopyCommand(document, command => {
+ commands.contents->Array.push(command)
+ true
+ })
+ expect(await Clipboard.writeText("share URL"))->toStrictEqual(Ok())
+ expect(commands.contents)->toStrictEqual(["copy"])
+ expect(textareaCount(document))->toBe(0)
+})
+
+test("failed fallback commands report failure and remove their temporary textarea", async () => {
+ let document = setup()
+ setCopyCommand(document, _ => false)
+ expect(await Clipboard.writeText("code"))->toStrictEqual(Error(Clipboard.WriteFailed))
+ expect(textareaCount(document))->toBe(0)
+
+ setCopyCommand(document, _ => throw(Failure("Copy unavailable")))
+ expect(await Clipboard.writeText("code"))->toStrictEqual(Error(Clipboard.WriteFailed))
+ expect(textareaCount(document))->toBe(0)
+})
diff --git a/apps/docs/scripts/__tests__/Interop_.test.res b/apps/docs/scripts/__tests__/Interop_.test.res
new file mode 100644
index 000000000..eed60dbb3
--- /dev/null
+++ b/apps/docs/scripts/__tests__/Interop_.test.res
@@ -0,0 +1,68 @@
+open ScriptTest
+
+@module("react-dom/server") external renderHtml: React.element => string = "renderToStaticMarkup"
+
+test("MDX list items preserve paragraph and nested list structure", async () => {
+ let html = renderHtml(
+
+ {React.string("Parent")}
+
+ {React.string("Child")}
+
+ ,
+ )
+ expect(html)->toContain("Parent
String.includes("
toBe(false)
+ expect(renderHtml( {React.null} ))->toContain("")
+})
+
+test(
+ "MDX code tabs read code, language, labels and line metadata from React children",
+ async () => {
+ HighlightLanguages.defaultInstance->ContentHighlighting.register
+ let html = renderHtml(
+
+
+
+
+ {React.string("let a = 1\nlet b = 2")}
+
+
+
+
+ {React.string("const a = 1;")}
+
+
+
+ ,
+ )
+ expect(html)->toContain("Source")
+ expect(html)->toContain("Output")
+ expect(html)->toContain("lang-rescript")
+ expect(html)->toContain("opacity-50")
+ expect(html)->toContain("let")
+ expect(html->String.replaceAllRegExp(/<[^>]*>/g, ""))->toContain("let b = 2")
+ },
+)
+
+test("URL boxes preserve their first paragraph, following content and destination", async () => {
+ let html = renderHtml(
+
+
+ {React.string("First paragraph")}
+ {React.string("Second paragraph")}
+
+ ,
+ )
+ expect(html)->toContain("First paragraph")
+ expect(html)->toContain("Second paragraph")
+ expect(html)->toContain("Read more")
+ expect(html)->toContain("href=\"/docs/manual/introduction\"")
+ expect(html->String.includes("
toBe(false)
+})
+
+test("typed string helpers preserve empty strings and repeated hyphen behavior", async () => {
+ expect(Util.String.camelCase("hello--world-42-next"))->toBe("hello-World-42Next")
+ expect(Util.String.capitalize(""))->toBe("")
+ expect(Util.String.capitalize("hello"))->toBe("Hello")
+})
diff --git a/apps/docs/src/bindings/HighlightJsBindings.res b/apps/docs/src/bindings/HighlightJsBindings.res
index f793d247f..3943cb613 100644
--- a/apps/docs/src/bindings/HighlightJsBindings.res
+++ b/apps/docs/src/bindings/HighlightJsBindings.res
@@ -1,9 +1,3 @@
-type highlightOptions = {language: string}
-type highlightResult = {value: string}
-
-@module("highlight.js/lib/core") @scope("default")
-external make: unit => HighlightLanguages.highlighter = "newInstance"
-
@module("highlight.js/lib/languages/bash")
external bash: HighlightLanguages.definition = "default"
@@ -36,14 +30,3 @@ external toml: HighlightLanguages.definition = "default"
@module("highlightjs-rescript")
external rescript: HighlightLanguages.definition = "default"
-
-@send
-external registerLanguage: (
- HighlightLanguages.highlighter,
- string,
- HighlightLanguages.definition,
-) => unit = "registerLanguage"
-
-@send
-external highlight: (HighlightLanguages.highlighter, string, highlightOptions) => highlightResult =
- "highlight"
diff --git a/apps/docs/src/common/AlgoliaConfig.res b/apps/docs/src/common/AlgoliaConfig.res
index e486ee425..f557262e4 100644
--- a/apps/docs/src/common/AlgoliaConfig.res
+++ b/apps/docs/src/common/AlgoliaConfig.res
@@ -4,24 +4,12 @@ type publicConfig = {
searchApiKey: string,
}
-let isPresent = value =>
- switch value {
- | Some(v) => v !== ""
- | None => false
- }
-
-let missingPublicVars = (~appId, ~indexName, ~searchApiKey): array => {
- let missing = []
- if !isPresent(appId) {
- missing->Array.push("VITE_ALGOLIA_APP_ID")
- }
- if !isPresent(indexName) {
- missing->Array.push("VITE_ALGOLIA_INDEX_NAME")
- }
- if !isPresent(searchApiKey) {
- missing->Array.push("VITE_ALGOLIA_SEARCH_API_KEY")
- }
- missing
+let missingPublicVars = (~appId, ~indexName, ~searchApiKey) => {
+ let env = Dict.make()
+ appId->Option.forEach(value => env->Dict.set("VITE_ALGOLIA_APP_ID", value))
+ indexName->Option.forEach(value => env->Dict.set("VITE_ALGOLIA_INDEX_NAME", value))
+ searchApiKey->Option.forEach(value => env->Dict.set("VITE_ALGOLIA_SEARCH_API_KEY", value))
+ AlgoliaEnvStatus.getMissingPublicAlgoliaVars(~env)
}
let publicConfigFrom = (~appId, ~indexName, ~searchApiKey): option =>
diff --git a/apps/docs/src/common/Clipboard.res b/apps/docs/src/common/Clipboard.res
deleted file mode 100644
index ea9b53b51..000000000
--- a/apps/docs/src/common/Clipboard.res
+++ /dev/null
@@ -1,10 +0,0 @@
-type error = WriteFailed
-
-let writeText = async text => {
- try {
- await navigator.clipboard->WebAPI.Clipboard.writeText(text)
- Ok()
- } catch {
- | _ => Error(WriteFailed)
- }
-}
diff --git a/apps/docs/src/common/Hooks.res b/apps/docs/src/common/Hooks.res
index 682355c21..99b79381f 100644
--- a/apps/docs/src/common/Hooks.res
+++ b/apps/docs/src/common/Hooks.res
@@ -1,19 +1,3 @@
-/* Contains some generic hooks */
-let useOutsideClick: (ReactDOM.Ref.t, unit => unit) => unit = %raw(`(outerRef, trigger) => {
- function handleClickOutside(event) {
- if (outerRef.current && !outerRef.current.contains(event.target)) {
- trigger();
- }
- }
-
- React.useEffect(() => {
- document.addEventListener("mousedown", handleClickOutside);
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- };
- });
-}`)
-
/** scrollDir is not memo-friendly.
It must be used with pattern matching.
Do not pass it directly to child components. */
diff --git a/apps/docs/src/common/Util.res b/apps/docs/src/common/Util.res
index 27e8d354e..aabbf2221 100644
--- a/apps/docs/src/common/Util.res
+++ b/apps/docs/src/common/Util.res
@@ -1,21 +1,14 @@
-module Unsafe = {
- external elementAsString: React.element => string = "%identity"
-}
-
module String = {
- let camelCase: string => string = %raw("str => {
- return str.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
- }")
-
- let kebabCase = string =>
- string
- ->String.replaceAllRegExp(/([a-z])([A-Z])/g, "$1-$2")
- ->String.toLowerCase
- ->String.replaceAll(" ", "-")
-
- let capitalize: string => string = %raw("str => {
- return str && str.charAt(0).toUpperCase() + str.substring(1);
- }")
+ let camelCase = str =>
+ str->String.replaceRegExpBy1Unsafe(/-([a-z])/g, (
+ ~match as _,
+ ~group1,
+ ~offset as _,
+ ~input as _,
+ ) => group1->String.toUpperCase)
+
+ let capitalize = str =>
+ str->String.slice(~start=0, ~end=1)->String.toUpperCase ++ str->String.slice(~start=1)
let capitalizeSentence = str =>
str
diff --git a/apps/docs/src/common/Util.resi b/apps/docs/src/common/Util.resi
index fb0da2991..c395ff634 100644
--- a/apps/docs/src/common/Util.resi
+++ b/apps/docs/src/common/Util.resi
@@ -6,13 +6,8 @@ module Url: {
let makeOpenGraphImageUrl: string => string
}
-module Unsafe: {
- let elementAsString: React.element => string
-}
-
module String: {
let camelCase: string => string
- let kebabCase: string => string
let capitalize: string => string
let capitalizeSentence: string => string
let leadingSlash: string => string
diff --git a/apps/docs/src/components/CodeCopyButton.res b/apps/docs/src/components/CodeCopyButton.res
new file mode 100644
index 000000000..ed56f0187
--- /dev/null
+++ b/apps/docs/src/components/CodeCopyButton.res
@@ -0,0 +1,78 @@
+type state =
+ | Init
+ | Pending
+ | Copied
+ | Failed
+
+@react.component
+let make = (~code, ~writeClipboard=Clipboard.writeText) => {
+ let (state, setState) = React.useState(_ => Init)
+
+ let buttonRef = React.useRef(Nullable.null)
+
+ let copy = async () => {
+ setState(_ => Pending)
+ let result = await writeClipboard(code)
+ setState(_ =>
+ switch result {
+ | Ok() => Copied
+ | Error(_) => Failed
+ }
+ )
+ }
+ let onClick = evt => {
+ ReactEvent.Mouse.preventDefault(evt)
+ copy()->ignore
+ }
+
+ React.useEffect(() => {
+ switch state {
+ | Copied =>
+ let buttonEl = Nullable.toOption(buttonRef.current)->Option.getOrThrow
+
+ // Note on this imperative DOM nonsense:
+ // For Tailwind transitions to behave correctly, we need to first paint the DOM element in the tree,
+ // and in the next tick, add the opacity-100 class, so the transition animation actually takes place.
+ // If we don't do that, the banner will essentially pop up without any animation
+ let bannerEl = WebAPI.Document.createElement(document, "div")
+ bannerEl.className = "opacity-0 absolute -top-6 right-0 -mt-5 -mr-4 px-4 py-2 w-40 rounded-lg captions text-white bg-gray-100 text-gray-80-tr transition-all duration-1000 ease-in-out "
+
+ let textNode = WebAPI.Document.createTextNode(document, "Copied to clipboard")
+
+ WebAPI.Element.appendChild(bannerEl, textNode)->ignore
+ WebAPI.Element.appendChild(buttonEl, bannerEl)->ignore
+
+ let nextFrameId = WebAPI.Window.requestAnimationFrame(window, _ => {
+ WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-0")->ignore
+ WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-100")->ignore
+ })
+
+ let timeoutId = setTimeout(~handler=() => {
+ buttonEl->WebAPI.Element.removeChild(bannerEl)->ignore
+ setState(_ => Init)
+ }, ~timeout=3000)
+
+ Some(
+ () => {
+ cancelAnimationFrame(nextFrameId)
+ clearTimeout(timeoutId)
+ bannerEl->WebAPI.Element.remove
+ },
+ )
+ | _ => None
+ }
+ }, [state])
+ //Copy-Button
+
+
+
+}
diff --git a/apps/docs/src/components/CodeExample.res b/apps/docs/src/components/CodeExample.res
index 3736d8da0..5346b4e49 100644
--- a/apps/docs/src/components/CodeExample.res
+++ b/apps/docs/src/components/CodeExample.res
@@ -9,102 +9,6 @@ let langShortname = (lang: string) =>
| rest => rest
}
-module CopyButton = {
- let copyToClipboard: string => bool = %raw(`
- function(str) {
- try {
- const el = document.createElement('textarea');
- el.value = str;
- el.setAttribute('readonly', '');
- el.style.position = 'absolute';
- el.style.left = '-9999px';
- document.body.appendChild(el);
- const selected =
- document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false;
- el.select();
- document.execCommand('copy');
- document.body.removeChild(el);
- if (selected) {
- document.getSelection().removeAllRanges();
- document.getSelection().addRange(selected);
- }
- return true;
- } catch(e) {
- return false;
- }
- }
- `)
-
- type state =
- | Init
- | Copied
- | Failed
-
- @react.component
- let make = (~code) => {
- let (state, setState) = React.useState(_ => Init)
-
- let buttonRef = React.useRef(Nullable.null)
-
- let onClick = evt => {
- ReactEvent.Mouse.preventDefault(evt)
- if copyToClipboard(code) {
- setState(_ => Copied)
- } else {
- setState(_ => Failed)
- }
- }
-
- React.useEffect(() => {
- switch state {
- | Copied =>
- let buttonEl = Nullable.toOption(buttonRef.current)->Option.getOrThrow
-
- // Note on this imperative DOM nonsense:
- // For Tailwind transitions to behave correctly, we need to first paint the DOM element in the tree,
- // and in the next tick, add the opacity-100 class, so the transition animation actually takes place.
- // If we don't do that, the banner will essentially pop up without any animation
- let bannerEl = WebAPI.Document.createElement(document, "div")
- bannerEl.className = "opacity-0 absolute -top-6 right-0 -mt-5 -mr-4 px-4 py-2 w-40 rounded-lg captions text-white bg-gray-100 text-gray-80-tr transition-all duration-1000 ease-in-out "
-
- let textNode = WebAPI.Document.createTextNode(document, "Copied to clipboard")
-
- WebAPI.Element.appendChild(bannerEl, textNode)->ignore
- WebAPI.Element.appendChild(buttonEl, bannerEl)->ignore
-
- let nextFrameId = WebAPI.Window.requestAnimationFrame(window, _ => {
- WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-0")->ignore
- WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-100")->ignore
- })
-
- let timeoutId = setTimeout(~handler=() => {
- buttonEl->WebAPI.Element.removeChild(bannerEl)->ignore
- setState(_ => Init)
- }, ~timeout=3000)
-
- Some(
- () => {
- cancelAnimationFrame(nextFrameId)
- clearTimeout(timeoutId)
- },
- )
- | _ => None
- }
- }, [state])
- //Copy-Button
- >))}
- disabled={state === Copied}
- className="relative"
- onClick
- >
-
-
- }
-}
-
@react.component
let make = (
~highlightedLines=[],
@@ -139,7 +43,7 @@ let make = (
let copyButton = if showCopyButton {
-
+
} else {
React.null
@@ -259,7 +163,7 @@ module Toggle = {
: React.null
- let copyButton =
+ let copyButton =
playgroundLinkButton
diff --git a/apps/docs/src/components/Markdown.res b/apps/docs/src/components/Markdown.res
index 8ad1a1436..9840ee22d 100644
--- a/apps/docs/src/components/Markdown.res
+++ b/apps/docs/src/components/Markdown.res
@@ -40,48 +40,29 @@ module Warn = {
}
module UrlBox = {
- open! MdxLegacy.MdxChildren
-
let imgEl =
@react.component
- let make = (~text: string, ~href: string, ~children: MdxLegacy.MdxChildren.t) => {
- let content = switch classify(children) {
- | String(str) =>
-
- imgEl
- {React.string(str)}
-
- | Element(el) =>
- let subChildren = el->getMdxChildren
+ let make = (~text: string, ~href: string, ~children: React.element) => {
+ let elements = React.Children.toArray(children)
+ let content = if typeof(children) == #string || React.isValidElement(children) {
imgEl
- {subChildren->toReactElement}
+ {MdxLegacy.getChildren(children)}
- | Array(arr) =>
- // Scenario: Take the first element, rewrap its children with the hyperlink img
- let length = Array.length(arr)
- if length >= 1 {
- let head = Belt.Array.getExn(arr, 0)
- let headChildren = head->getMdxChildren
-
+ } else {
+ switch elements[0] {
+ | None => React.null
+ | Some(head) =>
+ let rest = elements->Array.slice(~start=1)
<>
imgEl
- {headChildren->toReactElement}
+ {MdxLegacy.getChildren(head)}
- {if length > 1 {
- arr->Array.slice(~start=1, ~end=length)->MdxLegacy.arrToReactElement
- } else {
- React.null
- }}
+ {React.array(rest)}
>
- } else {
- React.null
}
- | Unknown(el) =>
- Console.log2("Received unknown", el)
- React.null
}
let link = if Util.Url.isAbsolute(href) {
@@ -232,10 +213,7 @@ module Code = {
@module("../ffi/parse-numeric-range.js")
external parseNumericRange: string => array
= "parsePart"
- // TODO: Might be refactorable with the new @unboxed feature
- type unknown = MdxLegacy.Components.unknown
-
- external unknownAsString: unknown => string = "%identity"
+ type unknown = React.element
let parseNumericRangeMeta = (metastring: string) =>
String.split(metastring, " ")
@@ -279,7 +257,7 @@ module Code = {
}
}
- let code = children->unknownAsString
+ let code = String.make(children)
let isMultiline = code->String.includes("\n")
switch lang {
@@ -290,35 +268,25 @@ module Code = {
}
module CodeTab = {
- let getMdxMetastring: MdxLegacy.mdxComponent => option = %raw("element => {
- if(element == null || element.props == null) {
- return;
- }
- return element.props.metastring;
- }")
@react.component
- let make = (~children: MdxLegacy.MdxChildren.t, ~labels: array=[]) => {
- let mdxElements = switch MdxLegacy.MdxChildren.classify(children) {
- | Array(mdxElements) => mdxElements
- | Element(el) => [el]
- | _ => []
- }
+ let make = (~children: React.element, ~labels: array=[]) => {
+ let mdxElements = React.Children.toArray(children)
let tabs = Array.reduceWithIndex(mdxElements, [], (acc, mdxElement, i) => {
- let child = mdxElement->MdxLegacy.MdxChildren.getMdxChildren->MdxLegacy.MdxChildren.classify
+ let children = mdxElement->MdxLegacy.getChildren->React.Children.toArray
- switch child {
- | Element(codeEl) =>
- let className = MdxLegacy.getMdxClassName(codeEl)->Option.getOr("")
+ switch children {
+ | [codeEl] if React.isValidElement(codeEl) =>
+ let className = MdxLegacy.getClassName(codeEl)->Option.getOr("")
- let metastring = getMdxMetastring(codeEl)->Option.getOr("")
+ let metastring = MdxLegacy.getMetastring(codeEl)->Option.getOr("")
let lang = switch String.split(className, "-") {
| ["language", lang] => Some(lang)
| _ => None
}
- let code = String.make(MdxLegacy.MdxChildren.getMdxChildren(codeEl))
+ let code = String.make(MdxLegacy.getChildren(codeEl))
let label = labels[i]
let tab = {
CodeExample.Toggle.lang,
@@ -408,73 +376,43 @@ module Ol = {
}
module Li = {
- let typeOf: 'a => string = %raw("thing => { return typeof thing; }")
- let isArray: 'a => bool = %raw("thing => { return thing instanceof Array; }")
- external asArray: 'a => array = "%identity"
+ let getType = element =>
+ if MdxLegacy.isComponent(element, P.make) {
+ "p"
+ } else if MdxLegacy.isComponent(element, Pre.make) {
+ "pre"
+ } else if MdxLegacy.isComponent(element, Ul.make) {
+ "ul"
+ } else if MdxLegacy.isComponent(element, Ol.make) {
+ "ol"
+ } else {
+ MdxLegacy.getType(element)
+ }
@react.component
let make = (~children) => {
- /*
- There are 3 value scenarios for `children`
-
- 1) string (if bullet point is standalone text)
- 2) array(,
|) (if nested list)
- 3) array(,,...,) (if text with nested content)
- 4) array(, , string,...) (if nested content without wrapping )
-
- We are iterating on these here with quite some bailout JS
- */
-
- let elements: React.element = if isArray(children) {
- let arr = children->asArray
- let last: React.element = {
- arr->Belt.Array.getExn(arr->Array.length - 1)
- }
-
- let head = Array.slice(arr, ~start=0, ~end=arr->Array.length - 1)
-
- let first = Belt.Array.getExn(head, 0)
-
- switch {
- open MdxLegacy
- last->fromReactElement->getMdxType
- } {
- | "ul"
- | "li"
- | "pre" =>
- switch {
- open MdxLegacy
- first->fromReactElement->getMdxType
- } {
- | "p" =>
- <>
- {React.array(head)}
- last
- >
- | _ =>
- <>
-
{React.array(head)}
- last
- >
- }
- | _ => children
- /* Scenario 3 */
+ let elements = React.Children.toArray(children)
+ let content = switch elements {
+ | [] => React.null
+ | [child] =>
+ switch getType(child) {
+ | "pre" | "p" => child
+ | _ => child
}
- } else if typeOf(children) === "string" {
- {children->Util.Unsafe.elementAsString->React.string}
- } else {
- switch {
- /* Unknown Scenario */
- open MdxLegacy
- children->fromReactElement->getMdxType
- } {
- | "pre" => children
- | "p" => children
+ | _ =>
+ let first = elements[0]->Option.getOrThrow
+ let last = elements[elements->Array.length - 1]->Option.getOrThrow
+ let head = elements->Array.slice(~start=0, ~end=elements->Array.length - 1)
+ switch getType(last) {
+ | "ul" | "ol" | "li" | "pre" =>
+ <>
+ {getType(first) == "p" ? React.array(head) : {React.array(head)}
}
+ last
+ >
| _ => children
}
}
-
- elements
+ content
}
}
@@ -543,17 +481,3 @@ module Video = {
}
}
-
-// Useful for debugging injected values in props
-// let mdxTestComponent: React.component<{.}> = %raw(`
-// function(children) {
-// console.log(children);
-// return React.createElement("div");
-// }
-// `)
-
-// Used for the MdxJS Provider
-
-/* Sets our preferred branded styles
- We most likely will never need a different ~components
- option on our website. */
diff --git a/apps/docs/src/components/Markdown.resi b/apps/docs/src/components/Markdown.resi
index 4c42ad8ff..51ac8b922 100644
--- a/apps/docs/src/components/Markdown.resi
+++ b/apps/docs/src/components/Markdown.resi
@@ -27,7 +27,7 @@ module Warn: {
module UrlBox: {
@react.component
- let make: (~text: string, ~href: string, ~children: MdxLegacy.MdxChildren.t) => React.element
+ let make: (~text: string, ~href: string, ~children: React.element) => React.element
}
// Used for creating invisible, hoverable anchors for url linking
@@ -93,13 +93,13 @@ module Code: {
let make: (
~className: string=?,
~metastring: option,
- ~children: MdxLegacy.Components.unknown,
+ ~children: React.element,
) => React.element
}
module CodeTab: {
@react.component
- let make: (~children: MdxLegacy.MdxChildren.t, ~labels: array=?) => React.element
+ let make: (~children: React.element, ~labels: array=?) => React.element
}
module Blockquote: {
diff --git a/apps/docs/src/components/MarkdownComponents.res b/apps/docs/src/components/MarkdownComponents.res
index 145d118c7..ebd829c92 100644
--- a/apps/docs/src/components/MarkdownComponents.res
+++ b/apps/docs/src/components/MarkdownComponents.res
@@ -18,12 +18,9 @@ type t = {
@as("Video")
video?: React.componentLike, React.element>,
@as("UrlBox")
- urlBox?: React.componentLike<
- UrlBox.props,
- React.element,
- >,
+ urlBox?: React.componentLike, React.element>,
@as("CodeTab")
- codeTab?: CodeTab.props> => React.element,
+ codeTab?: CodeTab.props> => React.element,
/* Common markdown elements */
p?: P.props => React.element,
li?: Li.props => React.element,
@@ -41,10 +38,7 @@ type t = {
blockquote?: Blockquote.props => React.element,
strong?: Strong.props => React.element,
hr?: Hr.props => React.element,
- code?: React.componentLike<
- Code.props, MdxLegacy.Components.unknown>,
- React.element,
- >,
+ code?: React.componentLike, React.element>, React.element>,
pre?: Pre.props => React.element,
a?: A.props => React.element,
}
diff --git a/apps/docs/src/components/SearchBox.res b/apps/docs/src/components/SearchBox.res
index f99e31c3b..a83ef4f02 100644
--- a/apps/docs/src/components/SearchBox.res
+++ b/apps/docs/src/components/SearchBox.res
@@ -23,8 +23,7 @@ let make = (
onClear()
}
- let focusInput = () =>
- textInput.current->Nullable.forEach(el => el->WebAPI.HTMLInputElement.focus)
+ let focusInput = () => textInput.current->Nullable.forEach(el => el->WebAPI.HTMLElement.focus)
let onAreaFocus = evt => {
let el = ReactEvent.Focus.target(evt)
@@ -52,15 +51,11 @@ let make = (
switch full {
| "Escape" => onClear()
| "Tab" =>
- if Array.length(completionValues) === 1 {
- let targetValue = Belt.Array.getExn(completionValues, 0)
-
- if targetValue !== value {
- ReactEvent.Keyboard.preventDefault(evt)
- onValueChange(targetValue)
- } else {
- ()
- }
+ switch completionValues {
+ | [targetValue] if targetValue !== value =>
+ ReactEvent.Keyboard.preventDefault(evt)
+ onValueChange(targetValue)
+ | _ => ()
}
| _ => ()
}
@@ -84,7 +79,7 @@ let make = (
/>
>))}
+ ref={HtmlRef.domRef(textInput)}
onFocus
onKeyDown
onChange={onChange}
diff --git a/apps/docs/src/data/LandingPagePlaygroundLoader.res b/apps/docs/src/data/LandingPagePlaygroundLoader.res
index c8f946591..60d33b6e1 100644
--- a/apps/docs/src/data/LandingPagePlaygroundLoader.res
+++ b/apps/docs/src/data/LandingPagePlaygroundLoader.res
@@ -41,16 +41,16 @@ export {
let build = (): LandingPagePlayground.playgroundData => {
// Keep prerendering independent of global language registration and other routes.
- let highlighter = HighlightJsBindings.make()
- highlighter->HighlightJsBindings.registerLanguage("rescript", HighlightJsBindings.rescript)
- highlighter->HighlightJsBindings.registerLanguage("javascript", HighlightJsBindings.javascript)
+ let highlighter = HighlightLanguages.make(HighlightLanguages.defaultInstance)
+ highlighter->HighlightLanguages.registerLanguage("rescript", HighlightJsBindings.rescript)
+ highlighter->HighlightLanguages.registerLanguage("javascript", HighlightJsBindings.javascript)
{
rescriptHtml: (
- highlighter->HighlightJsBindings.highlight(example.res, {language: "rescript"})
+ highlighter->HighlightLanguages.highlight(example.res, {language: "rescript"})
).value,
javascriptHtml: (
- highlighter->HighlightJsBindings.highlight(example.js, {language: "javascript"})
+ highlighter->HighlightLanguages.highlight(example.js, {language: "javascript"})
).value,
playgroundHref: `/try?code=${LzString.lzString.compressToEncodedURIComponent(example.res)}`,
}
diff --git a/apps/docs/src/data/MetaTagsApi.res b/apps/docs/src/data/MetaTagsApi.res
deleted file mode 100644
index 7f3655d02..000000000
--- a/apps/docs/src/data/MetaTagsApi.res
+++ /dev/null
@@ -1,98 +0,0 @@
-type t = {
- title: option,
- description: option,
- image: option,
-}
-
-/**
- This function uses JSDOM to fetch a webpage and extract the meta tags from it.
- JSDOM is required since this runs on Node.
- */
-let extractMetaTags = async (url: string) => {
- try {
- let response = await fetch(url)
-
- let html = await response->WebAPI.Response.text
- let dom = Jsdom.make(html)
- let document = dom.window.document
-
- let nodeList = document->WebAPI.Document.querySelectorAll("meta")
-
- let elements = []
-
- for i in 0 to nodeList.length {
- let node = WebAPI.NodeList.item(nodeList, i)
- // cast Node elements to Element
- elements->Array.push((Obj.magic(node): WebAPI.DOMAPI.element))
- }
-
- let metaTags = elements->Array.reduce(Dict.fromArray([]), (tags, meta) => {
- let name = meta->WebAPI.Element.getAttribute("name")
- let property = meta->WebAPI.Element.getAttribute("property")
- let itemprop = meta->WebAPI.Element.getAttribute("itemprop")
-
- let name = switch (name, property, itemprop) {
- | (Value(name), _, _) => Some(name)
- | (_, Value(property), _) => Some(property)
- | (_, _, Value(itemprop)) => Some(itemprop)
- | _ => None
- }
-
- let content = meta->WebAPI.Element.getAttribute("content")
-
- switch (name, content) {
- | (Some(name), Value(content)) => tags->Dict.set(name, content)
- | _ => ()
- }
-
- tags
- })
-
- let title = metaTags->Dict.get("og:title")
- let description = metaTags->Dict.get("og:description")
- let image = metaTags->Dict.get("og:image")
-
- Some({
- title,
- description,
- image,
- })
- } catch {
- | _ => {
- Console.error(`Error fetching Open Graph details for ${url}`)
- None
- }
- }
-}
-
-type tags = {
- ...t,
- url: string,
-}
-
-/*
- Pass an array of URLs and get back an array of meta tags for each URL.
- */
-let getMetaTags = async (urls: array) => {
- let metaTags: array = []
- for i in 0 to Array.length(urls) - 1 {
- let url = urls[i]
- switch url {
- | Some(url) => {
- let tags = await extractMetaTags(url)
- switch tags {
- | Some(tags) =>
- metaTags->Array.push({
- title: tags.title,
- description: tags.description,
- image: tags.image,
- url,
- })
- | None => ()
- }
- }
- | None => ()
- }
- }
- metaTags
-}
diff --git a/apps/docs/src/markdown/MdxLegacy.res b/apps/docs/src/markdown/MdxLegacy.res
index 922232b7e..5ee546bd3 100644
--- a/apps/docs/src/markdown/MdxLegacy.res
+++ b/apps/docs/src/markdown/MdxLegacy.res
@@ -1,72 +1,35 @@
-/*
- Abstract type for representing mdx
- components mostly passed as children to
- the component context API
- */
-/**
- * TODO: POST RR7 The code below is from Next's markdown, and I am not sure it is needed anymore.
- */
-type mdxComponent
-
-external fromReactElement: React.element => mdxComponent = "%identity"
-
-external arrToReactElement: array => React.element = "%identity"
-
-let getMdxClassName: mdxComponent => option = %raw("element => {
- if(element == null || element.props == null) {
- return;
- }
- return element.props.className;
- }")
-
-let getMdxType: mdxComponent => string = %raw("element => {
- if(element == null || element.props == null) {
- return 'unknown';
- }
- return element.props.mdxType;
- }")
+type props = {
+ children?: React.element,
+ className?: string,
+ metastring?: string,
+ mdxType?: string,
+}
-module MdxChildren = {
- type unknown
+@get external props: React.element => props = "props"
- type t
+let getChildren = element =>
+ React.isValidElement(element) ? props(element).children->Option.getOr(React.null) : element
- type case =
- | String(string)
- | Element(mdxComponent)
- | Array(array)
- | Unknown(unknown)
+let getClassName = element => React.isValidElement(element) ? props(element).className : None
- let classify = (v: t): case =>
- if %raw(`function (a) { return a instanceof Array}`)(v) {
- Array((Obj.magic(v): array))
- } else if typeof(v) == #string {
- String((Obj.magic(v): string))
- } else if typeof(v) == #object {
- Element((Obj.magic(v): mdxComponent))
- } else {
- Unknown((Obj.magic(v): unknown))
- }
+let getMetastring = element => React.isValidElement(element) ? props(element).metastring : None
- external toReactElement: t => React.element = "%identity"
+type component
+@get external elementType: React.element => component = "type"
- // Sometimes an mdxComponent element can be a string
- // which means it doesn't have any children.
- // We will return the element as its own child then
- let getMdxChildren: mdxComponent => t = %raw("element => {
- if(typeof element === 'string') {
- return element;
- }
- if(element == null || element.props == null || element.props.children == null) {
- return;
+let getType = element => {
+ if React.isValidElement(element) {
+ switch props(element).mdxType {
+ | Some(tag) => tag
+ | None => {
+ let tag = elementType(element)
+ typeof(tag) == #string ? String.make(tag) : "unknown"
}
- return element.props.children;
- }")
+ }
+ } else {
+ "unknown"
+ }
}
-module Components = {
- // Used for reflection based logic in
- // components such as `code` or `ul`
- // with runtime reflection
- type unknown
-}
+@get external component: React.element => React.componentLike<'props, React.element> = "type"
+let isComponent = (element, make) => React.isValidElement(element) && component(element) === make
diff --git a/apps/guide/__tests__/GuideHome_.test.res b/apps/guide/__tests__/GuideHome_.test.res
index 5833174f4..220dc7bf3 100644
--- a/apps/guide/__tests__/GuideHome_.test.res
+++ b/apps/guide/__tests__/GuideHome_.test.res
@@ -5,7 +5,7 @@ let secondLesson = GuideTestFixtures.secondLesson
let guideLessonsWithFinal = GuideTestFixtures.guideLessonsWithFinal
let renderGuideHome = GuideTestFixtures.renderGuideHome
let renderGuideHomeWithDocsIntroNavigation = GuideTestFixtures.renderGuideHomeWithDocsIntroNavigation
-let renderGuideHomeInBrowser = GuideTestFixtures.renderGuideHomeInBrowser
+let renderGuideHomeWithHistory = GuideTestFixtures.renderGuideHomeWithHistory
test("loads saved guide editor code into the editor", async () => {
await viewport(1440, 900)
@@ -117,11 +117,6 @@ test("navigates to the function argument page", async () => {
GuideLayout.clearExerciseCode(secondLesson.exercise.id)
})
-let guideTestUrl = hash => window.location.pathname ++ window.location.search ++ hash
-
-let resetGuideTestUrl = () =>
- WebAPI.History.replaceState(window.history, ~data=JSON.Null, ~unused="", ~url=guideTestUrl(""))
-
test("shows Back before lesson forward actions and returns to the previous lesson", async () => {
await viewport(1440, 900)
GuideLayout.clearCompletedExercises()
@@ -202,37 +197,23 @@ test("Done on a completed final lesson opens the ReScript docs intro", async ()
GuideLayout.clearExerciseCode(secondLesson.exercise.id)
})
-test("browser back returns to the previous guide lesson", async () => {
+test("history back returns to the previous guide lesson", async () => {
await viewport(1440, 900)
GuideLayout.clearCompletedExercises()
GuideLayout.clearExerciseCode(firstLesson.exercise.id)
GuideLayout.clearExerciseCode(secondLesson.exercise.id)
- WebAPI.History.replaceState(
- window.history,
- ~data=JSON.Null,
- ~unused="",
- ~url=guideTestUrl("#guide-test-start"),
- )
- WebAPI.History.pushState(
- window.history,
- ~data=JSON.Null,
- ~unused="",
- ~url=guideTestUrl("#first-contact"),
- )
-
- let screen = await renderGuideHomeInBrowser()
+ let screen = await renderGuideHomeWithHistory()
await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible
await (await screen->getByText("Next"))->click
await (await screen->getByText("Call A Function"))->element->toBeVisible
- WebAPI.History.back(window.history)
+ await (await screen->getByText("History back"))->click
await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible
GuideLayout.clearExerciseCode(firstLesson.exercise.id)
GuideLayout.clearExerciseCode(secondLesson.exercise.id)
- resetGuideTestUrl()
})
test("stretches the output surface to the full output panel", async () => {
diff --git a/apps/guide/__tests__/GuideTestFixtures.res b/apps/guide/__tests__/GuideTestFixtures.res
index b3d199fb9..e92e5f7b4 100644
--- a/apps/guide/__tests__/GuideTestFixtures.res
+++ b/apps/guide/__tests__/GuideTestFixtures.res
@@ -71,9 +71,20 @@ let renderGuideHomeWithDocsIntroNavigation = (goToDocsIntro, ~initialEntries=["/
,
)
-let renderGuideHomeInBrowser = () =>
+module HistoryBack = {
+ @module("react-router") external useNavigate: unit => int => unit = "useNavigate"
+
+ @react.component
+ let make = () => {
+ let navigate = useNavigate()
+ navigate(-1)}> {React.string("History back")}
+ }
+}
+
+let renderGuideHomeWithHistory = () =>
render(
-
+
+
- ,
+ ,
)
diff --git a/apps/guide/app/GuideHome.res b/apps/guide/app/GuideHome.res
index 250e6dbaf..1462ecde6 100644
--- a/apps/guide/app/GuideHome.res
+++ b/apps/guide/app/GuideHome.res
@@ -39,7 +39,7 @@ let make = (
{React.string(lesson.title)}
- lesson.content
+ lesson.content
React.element = "default"
diff --git a/apps/guide/e2e/navigation.test.mjs b/apps/guide/e2e/navigation.test.mjs
new file mode 100644
index 000000000..10a335aaf
--- /dev/null
+++ b/apps/guide/e2e/navigation.test.mjs
@@ -0,0 +1,42 @@
+import { expect, test } from "playwright/test";
+
+test("browser Back and Forward restore the guide lesson and saved code", async ({
+ page,
+}) => {
+ const savedCode = 'let greeting = "saved greeting"';
+ await page.addInitScript((code) => {
+ localStorage.setItem(
+ "rescript-guide:v1:progress",
+ JSON.stringify(["first-contact/greeting"]),
+ );
+ localStorage.setItem(
+ "rescript-guide:v1:exercise:first-contact/greeting",
+ code,
+ );
+ }, savedCode);
+
+ await page.goto("/#first-contact");
+ await expect(
+ page.getByRole("heading", { name: "Learn ReScript Guide", exact: true }),
+ ).toBeVisible();
+ await expect(page.getByTestId("guide-code-editor")).toContainText(savedCode);
+
+ await page.getByRole("button", { name: "Next", exact: true }).click();
+ await expect(page).toHaveURL(/#functions$/);
+ await expect(
+ page.getByRole("heading", { name: "Call A Function", exact: true }),
+ ).toBeVisible();
+
+ await page.goBack();
+ await expect(page).toHaveURL(/#first-contact$/);
+ await expect(
+ page.getByRole("heading", { name: "Learn ReScript Guide", exact: true }),
+ ).toBeVisible();
+ await expect(page.getByTestId("guide-code-editor")).toContainText(savedCode);
+
+ await page.goForward();
+ await expect(page).toHaveURL(/#functions$/);
+ await expect(
+ page.getByRole("heading", { name: "Call A Function", exact: true }),
+ ).toBeVisible();
+});
diff --git a/apps/guide/package.json b/apps/guide/package.json
index eda0193b4..8cd420036 100644
--- a/apps/guide/package.json
+++ b/apps/guide/package.json
@@ -10,7 +10,8 @@
"build": "yarn build:res && yarn build:vite",
"ci:test": "yarn build:res && yarn vitest --run --browser.headless",
"dev:vite": "yarn build:res && vite --host",
- "vitest": "vitest"
+ "vitest": "vitest",
+ "test:e2e": "yarn build && playwright test"
},
"dependencies": {
"@babel/generator": "^7.29.1",
@@ -24,7 +25,6 @@
"isbot": "^5",
"react": "^19.3.0",
"react-dom": "^19.3.0",
- "react-markdown": "^10.1.0",
"react-router": "^8.3.1",
"remark-comment": "^1.0.0",
"remark-frontmatter": "^5.0.0",
diff --git a/apps/guide/playwright.config.mjs b/apps/guide/playwright.config.mjs
new file mode 100644
index 000000000..7740eee02
--- /dev/null
+++ b/apps/guide/playwright.config.mjs
@@ -0,0 +1,15 @@
+import { defineConfig } from "playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ use: {
+ baseURL: "http://localhost:4174",
+ viewport: { width: 1440, height: 900 },
+ trace: "retain-on-failure",
+ },
+ webServer: {
+ command:
+ "yarn vite preview --outDir build/client --host localhost --port 4174 --strictPort",
+ url: "http://localhost:4174",
+ },
+});
diff --git a/packages/playground/src/EvalIFrame.res b/packages/playground/src/EvalIFrame.res
index 3531edcf1..aaee8db3d 100644
--- a/packages/playground/src/EvalIFrame.res
+++ b/packages/playground/src/EvalIFrame.res
@@ -69,12 +69,14 @@ let srcDoc = `