From 94ca39f25df000b5e06961e105990845b2ce586d Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 11:29:32 +0200 Subject: [PATCH 1/7] refactor: consolidate interop helpers and follow ReScript rules Share clipboard writes, Algolia validation, highlighting, Markdown and Cypress bindings. Replace raw JavaScript and unsafe casts with typed interop, migrate Belt calls to Stdlib, and isolate router tests with MemoryRouter. Add clipboard and MDX regressions, including exact meta element iteration and share feedback reset coverage. --- .../__tests__/BlogArticleBehavior_.test.res | 4 +- apps/docs/__tests__/CopyButtons_.test.res | 62 ++++++ .../__tests__/HighlightLanguages_.test.res | 47 ++--- .../NavbarTertiaryBehavior_.test.res | 4 +- apps/docs/__tests__/Search_.test.res | 65 +++---- .../__tests__/SyntaxLookupBehavior_.test.res | 4 +- .../__tests__/visual/BlogArticle_.test.res | 24 +-- apps/docs/__tests__/visual/Blog_.test.res | 24 +-- .../__tests__/visual/CodeExample_.test.res | 8 +- .../visual/CommunityLayout_.test.res | 16 +- apps/docs/__tests__/visual/Footer_.test.res | 8 +- .../__tests__/visual/MainLayout_.test.res | 8 +- .../visual/MarkdownComponents_.test.res | 4 +- .../__tests__/visual/NavbarPrimary_.test.res | 12 +- .../__tests__/visual/NavbarTertiary_.test.res | 8 +- .../__tests__/visual/SidebarNav_.test.res | 12 +- .../__tests__/visual/SyntaxLookup_.test.res | 24 +-- apps/docs/app/routes/ApiDocs.res | 6 +- apps/docs/app/routes/Packages.res | 4 +- apps/docs/e2e/Breadcrumbs.cy.res | 10 +- apps/docs/e2e/Navigation.cy.res | 68 +++---- apps/docs/e2e/Playground.cy.res | 60 +++--- apps/docs/e2e/bindings/Cy.res | 141 -------------- apps/docs/e2e/bindings/Cypress.res | 24 +++ .../functions/ogimage/[[path]]/index.png.res | 2 - apps/docs/package.json | 1 - apps/docs/scripts/__tests__/Interop_.test.res | 82 ++++++++ .../docs/src/bindings/HighlightJsBindings.res | 17 -- apps/docs/src/common/AlgoliaConfig.res | 24 +-- apps/docs/src/common/Hooks.res | 16 -- apps/docs/src/common/Util.res | 19 +- apps/docs/src/common/Util.resi | 4 - apps/docs/src/components/CodeCopyButton.res | 78 ++++++++ apps/docs/src/components/CodeExample.res | 100 +--------- apps/docs/src/components/Markdown.res | 178 +++++------------- apps/docs/src/components/Markdown.resi | 6 +- .../src/components/MarkdownComponents.res | 12 +- apps/docs/src/components/SearchBox.res | 19 +- .../src/data/LandingPagePlaygroundLoader.res | 10 +- apps/docs/src/data/MetaTagsApi.res | 10 +- apps/docs/src/markdown/MdxLegacy.res | 89 +++------ apps/guide/__tests__/GuideHome_.test.res | 27 +-- apps/guide/__tests__/GuideTestFixtures.res | 17 +- apps/guide/app/GuideHome.res | 2 +- apps/guide/app/GuideMarkdown.res | 2 - apps/guide/package.json | 1 - packages/playground/src/EvalIFrame.res | 8 +- packages/playground/src/Playground.res | 102 ++-------- .../playground/src/PlaygroundShareButton.res | 42 +++++ packages/shared/package.json | 1 + packages/shared/src/Ansi.res | 9 +- .../shared/src}/Clipboard.res | 0 packages/shared/src/HighlightJs.res | 15 +- packages/shared/src/HighlightLanguages.res | 8 + packages/shared/src/HighlightLanguages.resi | 9 + packages/shared/src/HtmlRef.res | 15 ++ .../shared/src}/ReactMarkdown.res | 4 +- packages/shared/src/ReactRouter.res | 5 - .../shared/src/WarningFlagDescription.res | 12 +- yarn.lock | 3 +- 60 files changed, 679 insertions(+), 917 deletions(-) create mode 100644 apps/docs/__tests__/CopyButtons_.test.res delete mode 100644 apps/docs/e2e/bindings/Cy.res create mode 100644 apps/docs/scripts/__tests__/Interop_.test.res create mode 100644 apps/docs/src/components/CodeCopyButton.res delete mode 100644 apps/guide/app/GuideMarkdown.res create mode 100644 packages/playground/src/PlaygroundShareButton.res rename {apps/docs/src/common => packages/shared/src}/Clipboard.res (100%) create mode 100644 packages/shared/src/HtmlRef.res rename {apps/docs/src/bindings => packages/shared/src}/ReactMarkdown.res (56%) 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..ba5675155 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..2192b9fad 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..b12265d5f 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[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/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/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/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/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__/Interop_.test.res b/apps/docs/scripts/__tests__/Interop_.test.res new file mode 100644 index 000000000..269b21954 --- /dev/null +++ b/apps/docs/scripts/__tests__/Interop_.test.res @@ -0,0 +1,82 @@ +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("meta tag extraction visits exactly the available HTML elements", async () => { + let html = `` + let result = await MetaTagsApi.extractMetaTags(`data:text/html,${encodeURIComponent(html)}`) + expect(result)->toStrictEqual( + Some({ + MetaTagsApi.title: Some("Title"), + description: Some("Description"), + image: Some("/image.png"), + }), + ) + let empty = await MetaTagsApi.extractMetaTags("data:text/html,No meta") + expect(empty)->toStrictEqual(Some({MetaTagsApi.title: None, description: None, image: None})) +}) + +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/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..2922d8aa8 100644 --- a/apps/docs/src/common/Util.res +++ b/apps/docs/src/common/Util.res @@ -1,11 +1,11 @@ -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 camelCase = str => + str->String.replaceRegExpBy1Unsafe(/-([a-z])/g, ( + ~match as _, + ~group1, + ~offset as _, + ~input as _, + ) => group1->String.toUpperCase) let kebabCase = string => string @@ -13,9 +13,8 @@ module String = { ->String.toLowerCase ->String.replaceAll(" ", "-") - let capitalize: string => string = %raw("str => { - return str && str.charAt(0).toUpperCase() + str.substring(1); - }") + 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..6ed3f78f6 100644 --- a/apps/docs/src/common/Util.resi +++ b/apps/docs/src/common/Util.resi @@ -6,10 +6,6 @@ module Url: { let makeOpenGraphImageUrl: string => string } -module Unsafe: { - let elementAsString: React.element => string -} - module String: { let camelCase: string => string let kebabCase: 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..2c7eca370 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 - - } -} - @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

      } } - -
    1. elements
    2. +
    3. content
    4. } } @@ -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 index 7f3655d02..93a26d825 100644 --- a/apps/docs/src/data/MetaTagsApi.res +++ b/apps/docs/src/data/MetaTagsApi.res @@ -16,14 +16,10 @@ let extractMetaTags = async (url: string) => { let dom = Jsdom.make(html) let document = dom.window.document - let nodeList = document->WebAPI.Document.querySelectorAll("meta") - + let collection = document->WebAPI.Document.getElementsByTagName("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)) + for i in 0 to collection.length - 1 { + elements->Array.push(collection->WebAPI.HTMLCollection.item(i)) } let metaTags = elements->Array.reduce(Dict.fromArray([]), (tags, meta) => { 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() + + } +} + +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/package.json b/apps/guide/package.json index eda0193b4..c607908ac 100644 --- a/apps/guide/package.json +++ b/apps/guide/package.json @@ -24,7 +24,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/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 = ` ` +external asIFrame: WebAPI.DOMAPI.element => WebAPI.DOMAPI.htmliFrameElement = "%identity" + let sendOutput = (code, imports) => { let frame = document->WebAPI.Document.querySelector("#iframe-eval") switch frame { - | Value(element) => - let element: WebAPI.DOMAPI.htmliFrameElement = element->Obj.magic + | Value(element) if element.tagName == "IFRAME" => + let element = element->asIFrame switch element.contentWindow { | Value({window}) => let message = JSON.Object( @@ -86,7 +88,7 @@ let sendOutput = (code, imports) => { window->WebAPI.Window.postMessage(~message, ~targetOrigin="*") | Null => Console.error("contentWindow not found") } - | Null => Console.error("iframe not found") + | Value(_) | Null => Console.error("iframe not found") } } diff --git a/packages/playground/src/Playground.res b/packages/playground/src/Playground.res index 70038bc9e..88859241f 100644 --- a/packages/playground/src/Playground.res +++ b/packages/playground/src/Playground.res @@ -611,11 +611,9 @@ module WarningFlagsWidget = { // Used for the text input let inputRef = React.useRef(Nullable.null) - let focusInput = () => - inputRef.current->Nullable.forEach(el => WebAPI.HTMLInputElement.focus(el)) + let focusInput = () => inputRef.current->Nullable.forEach(el => WebAPI.HTMLElement.focus(el)) - let blurInput = () => - inputRef.current->Nullable.forEach(el => WebAPI.HTMLInputElement.focus(el)) + let blurInput = () => inputRef.current->Nullable.forEach(el => WebAPI.HTMLElement.focus(el)) let chips = Array.mapWithIndex(flags, (token, i) => { let {WarningFlagDescription.Parser.flag: flag, enabled} = token @@ -784,7 +782,8 @@ module WarningFlagsWidget = { switch (parent, el) { | (Some(parent), Some(el)) => - Some(() => scrollToElement(~parent, (Obj.magic(el): WebAPI.DOMAPI.htmlElement))) + scrollToElement(~parent, HtmlRef.fromDom(el)) + None | _ => None } })->Some @@ -833,7 +832,7 @@ module WarningFlagsWidget = { let suggestionBox = Option.map(suggestions, elements =>
>))} + ref={HtmlRef.domRef(listboxRef)} className="playground-overlay p-2 absolute overflow-auto playground-scrollbar z-50 border-b rounded border-l border-r block w-full max-h-60" > elements @@ -906,7 +905,7 @@ module WarningFlagsWidget = { chips
>))} + ref={HtmlRef.domRef(inputRef)} className="playground-input inline-block p-1 max-w-20 outline-hidden" placeholder="Flags" type_="text" @@ -1197,69 +1196,6 @@ module ControlPanel = { } - module ShareButton = { - 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 - | CopySuccess - - @react.component - let make = (~actionIndicatorKey: string) => { - let (state, setState) = React.useState(() => Init) - - React.useEffect(() => { - setState(_ => Init) - None - }, [actionIndicatorKey]) - - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) - let ret = copyToClipboard(window.location.href) - if ret { - setState(_ => CopySuccess) - } - } - - let (text, className) = switch state { - | Init => ("Copy Share Link", " bg-sky body-xs active:bg-sky-70 border-sky-70") - | CopySuccess => ("Copied to clipboard!", "bg-turtle-dark border-turtle-dark") - } - - <> - - - } - } - let commandWithKeyboardShortcut = (commandName, ~key) => { let userAgent = window.navigator.userAgent if userAgent->String.includes("iPhone") || userAgent->String.includes("Android") { @@ -1352,7 +1288,7 @@ module ControlPanel = { - +
| _ => React.null } @@ -1524,7 +1460,7 @@ module InitialContent = { let times = switch count { | 1 => "once" | 2 => "twice" - | n => n->Belt.Int.toString ++ " times" + | n => n->Int.toString ++ " times" } let text = \`Click me $\{times\}\` @@ -1539,7 +1475,7 @@ module InitialContent = { let times = switch count { | 1 => "once" | 2 => "twice" - | n => Belt.Int.toString(n) ++ " times" + | n => Int.toString(n) ++ " times" } let name = switch username { @@ -1803,7 +1739,7 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { | _ => "rescript" } let config: CodeMirror.editorConfig = { - parent, + parent: parent->WebAPI.HTMLElement.asElement, initialValue: initialContent, mode, readOnly: false, @@ -2195,23 +2131,20 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { />
>))} + ref={HtmlRef.elementRef(panelRef)} > // Left Panel
>))} + ref={HtmlRef.elementRef(leftPanelRef)} className={`overflow-scroll playground-scrollbar ${layout == Column ? "h-2/4" : "h-full!"} ${layout == Column ? "w-full" : "w-[50%]"}`} > -
>))} - /> +
// Separator
>))} + ref={HtmlRef.domRef(separatorRef)} // TODO: touch-none not applied className={"playground-divider flex items-center justify-center touch-none select-none rounded-lg " ++ ( layout == Column ? "cursor-row-resize" : "cursor-col-resize" @@ -2228,7 +2161,7 @@ let make = (~bundleBaseUrl: string, ~versions: array) => {
// Right Panel
>))} + ref={HtmlRef.elementRef(rightPanelRef)} className={`${layout == Column ? "h-6/15" : "h-inherit!"} ${layout == Column ? "w-full" : "w-[50%]"}`} @@ -2236,10 +2169,7 @@ let make = (~bundleBaseUrl: string, ~versions: array) => {
{React.array(headers)}
-
>))} - className="overflow-auto playground-scrollbar" - > +
{ + let (state, setState) = React.useState(() => Init) + + let copy = async () => { + setState(_ => Pending) + let result = await writeClipboard(window.location.href) + setState(_ => + switch result { + | Ok() => CopySuccess + | Error(_) => CopyFailed + } + ) + } + let onClick = evt => { + ReactEvent.Mouse.preventDefault(evt) + copy()->ignore + } + + let (text, className) = switch state { + | Init | Pending => ("Copy Share Link", " bg-sky body-xs active:bg-sky-70 border-sky-70") + | CopySuccess => ("Copied to clipboard!", "bg-turtle-dark border-turtle-dark") + | CopyFailed => ("Copy failed, try again", " bg-sky body-xs active:bg-sky-70 border-sky-70") + } + + <> + + +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 616c7626c..02291215e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,6 +23,7 @@ "highlight.js": "^11.11.1", "react": "^19.3.0", "react-dom": "^19.3.0", + "react-markdown": "^10.1.0", "remark-comment": "^1.0.0", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", diff --git a/packages/shared/src/Ansi.res b/packages/shared/src/Ansi.res index 5eb6bd65e..9c78aebee 100644 --- a/packages/shared/src/Ansi.res +++ b/packages/shared/src/Ansi.res @@ -263,7 +263,7 @@ module SgrString = { let length = Array.length(tokens) for i in 0 to length - 1 { - let token = Belt.Array.getExn(tokens, i) + let token = tokens[i]->Option.getOrThrow let isLast = i === length - 1 @@ -298,12 +298,7 @@ module SgrString = { content := "" } - params := - Belt.Array.concatMany([ - Option.mapOr(fg, [], v => [v]), - Option.mapOr(bg, [], v => [v]), - rest, - ]) + params := Array.flat([Option.mapOr(fg, [], v => [v]), Option.mapOr(bg, [], v => [v]), rest]) | ClearSgr(_) => if content.contents !== "" { let element = {content: content.contents, params: params.contents} diff --git a/apps/docs/src/common/Clipboard.res b/packages/shared/src/Clipboard.res similarity index 100% rename from apps/docs/src/common/Clipboard.res rename to packages/shared/src/Clipboard.res diff --git a/packages/shared/src/HighlightJs.res b/packages/shared/src/HighlightJs.res index f22311a55..99aa89692 100644 --- a/packages/shared/src/HighlightJs.res +++ b/packages/shared/src/HighlightJs.res @@ -1,15 +1,10 @@ -type options = {language: string} - -@deriving(abstract) -type highlightResult = {value: string} - -@module("highlight.js/lib/core") @scope("default") -external highlight: (~code: string, ~options: options) => highlightResult = "highlight" - let renderHLJS = (~highlightedLines=[], ~darkmode=false, ~code: string, ~lang: string, ()) => { // If the language couldn't be parsed, we will fall back to text - let options = {language: lang} - let (lang, highlighted) = try (lang, highlight(~code, ~options)->valueGet) catch { + let options = {HighlightLanguages.language: lang} + let (lang, highlighted) = try ( + lang, + (HighlightLanguages.defaultInstance->HighlightLanguages.highlight(code, options)).value, + ) catch { | JsExn(_) => ("text", code) } diff --git a/packages/shared/src/HighlightLanguages.res b/packages/shared/src/HighlightLanguages.res index 60250770b..3d77771a9 100644 --- a/packages/shared/src/HighlightLanguages.res +++ b/packages/shared/src/HighlightLanguages.res @@ -1,10 +1,18 @@ type highlighter type definition +type highlightOptions = {language: string} +type highlightResult = {value: string} type registeredLanguage @module("highlight.js/lib/core") external defaultInstance: highlighter = "default" +@send +external make: highlighter => highlighter = "newInstance" + +@send +external highlight: (highlighter, string, highlightOptions) => highlightResult = "highlight" + @send external getLanguage: (highlighter, string) => option = "getLanguage" diff --git a/packages/shared/src/HighlightLanguages.resi b/packages/shared/src/HighlightLanguages.resi index 13ebcccba..1069beae7 100644 --- a/packages/shared/src/HighlightLanguages.resi +++ b/packages/shared/src/HighlightLanguages.resi @@ -1,7 +1,16 @@ type highlighter type definition +type registeredLanguage +type highlightOptions = {language: string} +type highlightResult = {value: string} let defaultInstance: highlighter /** Preserve an existing language or alias when route modules initialize more than once. */ let ensureRegistered: (highlighter, string, definition) => unit + +let make: highlighter => highlighter +let registerLanguage: (highlighter, string, definition) => unit +let highlight: (highlighter, string, highlightOptions) => highlightResult + +let getLanguage: (highlighter, string) => option diff --git a/packages/shared/src/HtmlRef.res b/packages/shared/src/HtmlRef.res new file mode 100644 index 000000000..d2d2bfbcc --- /dev/null +++ b/packages/shared/src/HtmlRef.res @@ -0,0 +1,15 @@ +/** For refs attached to HTML tags; bridge React's DOM type to WebAPI's HTML type. */ +external fromDom: Dom.element => WebAPI.DOMAPI.htmlElement = "%identity" + +let domRef = ref => + ReactDOM.Ref.callbackDomRef(element => { + ref.React.current = element->Nullable.map(fromDom) + None + }) + +let elementRef = ref => + ReactDOM.Ref.callbackDomRef(element => { + ref.React.current = + element->Nullable.map(element => fromDom(element)->WebAPI.HTMLElement.asElement) + None + }) diff --git a/apps/docs/src/bindings/ReactMarkdown.res b/packages/shared/src/ReactMarkdown.res similarity index 56% rename from apps/docs/src/bindings/ReactMarkdown.res rename to packages/shared/src/ReactMarkdown.res index d3a6a9bc9..e3d179d0d 100644 --- a/apps/docs/src/bindings/ReactMarkdown.res +++ b/packages/shared/src/ReactMarkdown.res @@ -1,6 +1,6 @@ @module("react-markdown") @react.component external make: ( ~children: string, - ~components: MarkdownComponents.t=?, - ~rehypePlugins: array=?, + ~components: 'components=?, + ~rehypePlugins: array<'plugin>=?, ) => React.element = "default" diff --git a/packages/shared/src/ReactRouter.res b/packages/shared/src/ReactRouter.res index a6d4ade12..7b87d9726 100644 --- a/packages/shared/src/ReactRouter.res +++ b/packages/shared/src/ReactRouter.res @@ -114,11 +114,6 @@ module Routes = { external layout: (string, array) => t = "layout" } -module BrowserRouter = { - @react.component @module("react-router") - external make: (~children: React.element) => React.element = "BrowserRouter" -} - module MemoryRouter = { @react.component @module("react-router") external make: (~children: React.element, ~initialEntries: array=?) => React.element = diff --git a/packages/shared/src/WarningFlagDescription.res b/packages/shared/src/WarningFlagDescription.res index d99914d34..ba63ff26c 100644 --- a/packages/shared/src/WarningFlagDescription.res +++ b/packages/shared/src/WarningFlagDescription.res @@ -212,11 +212,11 @@ module Parser = { | _ => () } - ret->Belt.SortArray.stableSortBy((v1, v2) => { + ret->Array.toSorted((v1, v2) => { let a = v1.flag->Int.fromString let b = v2.flag->Int.fromString - compare(a, b) + compare(a, b)->Int.toFloat }) } @@ -231,16 +231,16 @@ module Parser = { Array.forEach(other, token => dict->Dict.set(token.flag, token)) - Dict.valuesToArray(dict)->Belt.SortArray.stableSortBy((t1, t2) => { + Dict.valuesToArray(dict)->Array.toSorted((t1, t2) => { open Float let f1 = t1.flag let f2 = t2.flag switch (f1->fromString->Option.isNone, f2->fromString->Option.isNone) { | (false, false) | (true, true) => - String.localeCompare(f1, f2)->Float.toInt - | (true, false) => -1 - | (false, true) => 1 + String.localeCompare(f1, f2) + | (true, false) => -1.0 + | (false, true) => 1.0 } }) } diff --git a/yarn.lock b/yarn.lock index fbac4a176..eb3408c2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2003,7 +2003,6 @@ __metadata: playwright: "npm:^1.63.0" react: "npm:^19.3.0" react-dom: "npm:^19.3.0" - react-markdown: "npm:^10.1.0" react-router: "npm:^8.3.1" rehype-slug: "npm:^6.0.0" rehype-stringify: "npm:^10.0.1" @@ -2053,7 +2052,6 @@ __metadata: playwright: "npm:^1.63.0" react: "npm:^19.3.0" react-dom: "npm:^19.3.0" - react-markdown: "npm:^10.1.0" react-router: "npm:^8.3.1" remark-comment: "npm:^1.0.0" remark-frontmatter: "npm:^5.0.0" @@ -2109,6 +2107,7 @@ __metadata: highlight.js: "npm:^11.11.1" react: "npm:^19.3.0" react-dom: "npm:^19.3.0" + react-markdown: "npm:^10.1.0" remark-comment: "npm:^1.0.0" remark-frontmatter: "npm:^5.0.0" remark-gfm: "npm:^4.0.1" From f716ba48dab8273dbeba8d5e08f49211684ec44c Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 11:34:50 +0200 Subject: [PATCH 2/7] refactor: align cleanup with pending documentation fixes Remove the unused metadata helper and kebab-case export already covered by the final dead-code cleanup. Match the pending API array access and visual-test router paths to avoid overlapping edits. --- apps/docs/__tests__/visual/Blog_.test.res | 12 +-- .../__tests__/visual/SyntaxLookup_.test.res | 12 +-- apps/docs/app/routes/ApiDocs.res | 2 +- apps/docs/scripts/__tests__/Interop_.test.res | 14 --- apps/docs/src/common/Util.res | 6 -- apps/docs/src/common/Util.resi | 1 - apps/docs/src/components/CodeExample.res | 2 +- apps/docs/src/data/MetaTagsApi.res | 94 ------------------- 8 files changed, 14 insertions(+), 129 deletions(-) delete mode 100644 apps/docs/src/data/MetaTagsApi.res diff --git a/apps/docs/__tests__/visual/Blog_.test.res b/apps/docs/__tests__/visual/Blog_.test.res index ba5675155..e5de5f052 100644 --- a/apps/docs/__tests__/visual/Blog_.test.res +++ b/apps/docs/__tests__/visual/Blog_.test.res @@ -58,7 +58,7 @@ test("desktop blog index shows featured post and cards", async () => { await viewport(1440, 900) let screen = await render( - +
@@ -82,7 +82,7 @@ test("desktop blog shows category selector", async () => { await viewport(1440, 900) let screen = await render( - +
@@ -103,7 +103,7 @@ test("mobile blog index", async () => { await viewport(600, 1200) let screen = await render( - +
@@ -121,7 +121,7 @@ test("blog shows empty state when no posts", async () => { await viewport(1440, 900) let screen = await render( - +
@@ -170,7 +170,7 @@ test("desktop blog shows archived posts", async () => { await viewport(1440, 900) let screen = await render( - +
@@ -208,7 +208,7 @@ test("desktop blog with single post", async () => { ] let screen = await render( - +
diff --git a/apps/docs/__tests__/visual/SyntaxLookup_.test.res b/apps/docs/__tests__/visual/SyntaxLookup_.test.res index 2192b9fad..85a838a32 100644 --- a/apps/docs/__tests__/visual/SyntaxLookup_.test.res +++ b/apps/docs/__tests__/visual/SyntaxLookup_.test.res @@ -44,7 +44,7 @@ test("desktop syntax lookup renders categories and tags", async () => { await viewport(1440, 900) let screen = await render( - +
@@ -71,7 +71,7 @@ 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.")}
@@ -91,7 +91,7 @@ test("mobile syntax lookup", async () => { await viewport(600, 1200) let screen = await render( - +
@@ -118,7 +118,7 @@ test("deprecated items show with line-through styling", async () => { await viewport(1440, 900) let screen = await render( - +
@@ -136,7 +136,7 @@ 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.")}
@@ -156,7 +156,7 @@ 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.")}
diff --git a/apps/docs/app/routes/ApiDocs.res b/apps/docs/app/routes/ApiDocs.res index b12265d5f..6d423b5a0 100644 --- a/apps/docs/app/routes/ApiDocs.res +++ b/apps/docs/app/routes/ApiDocs.res @@ -367,7 +367,7 @@ module Data = { } let processStaticProps = (~slug: array) => { - let moduleName = slug[0]->Option.getOrThrow + let moduleName = slug->Array.at(0)->Option.getOrThrow let modulePath = slug->Array.join("/") let content = diff --git a/apps/docs/scripts/__tests__/Interop_.test.res b/apps/docs/scripts/__tests__/Interop_.test.res index 269b21954..eed60dbb3 100644 --- a/apps/docs/scripts/__tests__/Interop_.test.res +++ b/apps/docs/scripts/__tests__/Interop_.test.res @@ -61,20 +61,6 @@ test("URL boxes preserve their first paragraph, following content and destinatio expect(html->String.includes("

toBe(false) }) -test("meta tag extraction visits exactly the available HTML elements", async () => { - let html = `` - let result = await MetaTagsApi.extractMetaTags(`data:text/html,${encodeURIComponent(html)}`) - expect(result)->toStrictEqual( - Some({ - MetaTagsApi.title: Some("Title"), - description: Some("Description"), - image: Some("/image.png"), - }), - ) - let empty = await MetaTagsApi.extractMetaTags("data:text/html,No meta") - expect(empty)->toStrictEqual(Some({MetaTagsApi.title: None, description: None, image: None})) -}) - 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("") diff --git a/apps/docs/src/common/Util.res b/apps/docs/src/common/Util.res index 2922d8aa8..aabbf2221 100644 --- a/apps/docs/src/common/Util.res +++ b/apps/docs/src/common/Util.res @@ -7,12 +7,6 @@ module String = { ~input as _, ) => group1->String.toUpperCase) - let kebabCase = string => - string - ->String.replaceAllRegExp(/([a-z])([A-Z])/g, "$1-$2") - ->String.toLowerCase - ->String.replaceAll(" ", "-") - let capitalize = str => str->String.slice(~start=0, ~end=1)->String.toUpperCase ++ str->String.slice(~start=1) diff --git a/apps/docs/src/common/Util.resi b/apps/docs/src/common/Util.resi index 6ed3f78f6..c395ff634 100644 --- a/apps/docs/src/common/Util.resi +++ b/apps/docs/src/common/Util.resi @@ -8,7 +8,6 @@ module Url: { 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/CodeExample.res b/apps/docs/src/components/CodeExample.res index 2c7eca370..5346b4e49 100644 --- a/apps/docs/src/components/CodeExample.res +++ b/apps/docs/src/components/CodeExample.res @@ -163,7 +163,7 @@ module Toggle = { : React.null - let copyButton = + let copyButton =

playgroundLinkButton diff --git a/apps/docs/src/data/MetaTagsApi.res b/apps/docs/src/data/MetaTagsApi.res deleted file mode 100644 index 93a26d825..000000000 --- a/apps/docs/src/data/MetaTagsApi.res +++ /dev/null @@ -1,94 +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 collection = document->WebAPI.Document.getElementsByTagName("meta") - let elements = [] - for i in 0 to collection.length - 1 { - elements->Array.push(collection->WebAPI.HTMLCollection.item(i)) - } - - 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 -} From 803099da1d301fbedc5d50b3013460f77c8cdb1a Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 12:16:53 +0200 Subject: [PATCH 3/7] fix: use automatic JSX runtime for Pages functions Configure the Functions JSX transform to import React runtime helpers automatically. Add a deployed OG image regression that checks the PNG signature, catching missing runtime imports that bundling alone does not detect. --- apps/docs/e2e/OgImage.cy.res | 19 +++++++++++++++++++ apps/docs/functions/tsconfig.json | 5 +++++ 2 files changed, 24 insertions(+) create mode 100644 apps/docs/e2e/OgImage.cy.res create mode 100644 apps/docs/functions/tsconfig.json 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/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" + } +} From 4caabf449a1a1def398669e6dcbb44efa323c3a1 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 14:10:22 +0200 Subject: [PATCH 4/7] fix: preserve clipboard and navigation behavior during interop cleanup Restore a shared typed clipboard fallback with focus and selection cleanup. Scroll warning suggestions through their DOM parent, and cover keyboard scrolling and fallback copying in Cypress. Add guide browser Back/Forward coverage against the production build in CI. --- .github/workflows/pull-request.yml | 8 ++ .gitignore | 1 + apps/docs/e2e/WarningFlags.cy.res | 37 ++++++++ .../e2e/homepage/HomepageInteractions.cy.res | 26 ++++++ .../scripts/__tests__/Clipboard_.test.res | 88 +++++++++++++++++++ apps/guide/e2e/navigation.test.mjs | 42 +++++++++ apps/guide/package.json | 3 +- apps/guide/playwright.config.mjs | 15 ++++ packages/playground/src/Playground.res | 21 ++--- packages/shared/src/Clipboard.res | 54 +++++++++++- 10 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 apps/docs/e2e/WarningFlags.cy.res create mode 100644 apps/docs/scripts/__tests__/Clipboard_.test.res create mode 100644 apps/guide/e2e/navigation.test.mjs create mode 100644 apps/guide/playwright.config.mjs diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 941da3094..b8abc035d 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -125,6 +125,14 @@ 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 + 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/e2e/WarningFlags.cy.res b/apps/docs/e2e/WarningFlags.cy.res new file mode 100644 index 000000000..86d97217e --- /dev/null +++ b/apps/docs/e2e/WarningFlags.cy.res @@ -0,0 +1,37 @@ +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", () => { + visit("/try") + 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/homepage/HomepageInteractions.cy.res b/apps/docs/e2e/homepage/HomepageInteractions.cy.res index a5e242d8e..ad950a614 100644 --- a/apps/docs/e2e/homepage/HomepageInteractions.cy.res +++ b/apps/docs/e2e/homepage/HomepageInteractions.cy.res @@ -1,6 +1,12 @@ open Cypress open HomepageHelpers +type clipboardDescriptor = {value: Nullable.t, configurable: bool} + +@val @scope("Object") +external hideClipboardAPI: ('navigator, @as("clipboard") _, clipboardDescriptor) => unit = + "defineProperty" + it("community gallery supports keyboard selection and wraps to the first photo", () => { visit("/") containsIn("h1", headline)->realClick->ignore @@ -42,3 +48,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/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

") + 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/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 c607908ac..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", 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/Playground.res b/packages/playground/src/Playground.res index 88859241f..08a2cab58 100644 --- a/packages/playground/src/Playground.res +++ b/packages/playground/src/Playground.res @@ -605,9 +605,6 @@ module WarningFlagsWidget = { ) => { let (state, setState) = React.useState(_ => HideSuggestion({input: ""})) - // Used for the suggestion box list - let listboxRef = React.useRef(Nullable.null) - // Used for the text input let inputRef = React.useRef(Nullable.null) @@ -777,15 +774,14 @@ module WarningFlagsWidget = { let ref = if selected === i { ReactDOM.Ref.callbackDomRef(dom => { - let el = Nullable.toOption(dom) - let parent = listboxRef.current->Nullable.toOption - - switch (parent, el) { - | (Some(parent), Some(el)) => - scrollToElement(~parent, HtmlRef.fromDom(el)) - None - | _ => None - } + dom->Nullable.forEach( + dom => { + let element = HtmlRef.fromDom(dom) + // Ancestor refs may not be attached yet, but the DOM parent already exists. + element.parentElement->Null.forEach(parent => scrollToElement(~parent, element)) + }, + ) + None })->Some } else { None @@ -832,7 +828,6 @@ module WarningFlagsWidget = { let suggestionBox = Option.map(suggestions, elements =>
elements diff --git a/packages/shared/src/Clipboard.res b/packages/shared/src/Clipboard.res index ea9b53b51..af4355463 100644 --- a/packages/shared/src/Clipboard.res +++ b/packages/shared/src/Clipboard.res @@ -1,10 +1,62 @@ type error = WriteFailed +@send +external createTextArea: ( + WebAPI.DOMAPI.document, + @as("textarea") _, +) => WebAPI.DOMAPI.htmlTextAreaElement = "createElement" + +@send external copySelection: (WebAPI.DOMAPI.document, @as("copy") _) => bool = "execCommand" +@send external focusElement: WebAPI.DOMAPI.element => unit = "focus" +@send +external getSelection: WebAPI.DOMAPI.document => Null.t = "getSelection" + +let writeWithSelection = text => { + try { + let textarea = createTextArea(document) + let activeElement = document.activeElement + let selection = getSelection(document)->Null.toOption + let ranges = + selection->Option.mapOr([], selection => + Array.fromInitializer(~length=selection.rangeCount, index => + selection->WebAPI.Selection.getRangeAt(index)->WebAPI.Range.cloneRange + ) + ) + + let copied = try { + textarea.value = text + textarea.readOnly = true + textarea->WebAPI.HTMLTextAreaElement.setAttribute( + ~qualifiedName="style", + ~value="position: fixed; left: -9999px; top: 0", + ) + document.body + ->WebAPI.HTMLElement.appendChild(textarea->WebAPI.HTMLTextAreaElement.asNode) + ->ignore + textarea->WebAPI.HTMLTextAreaElement.select + copySelection(document) + } catch { + | _ => false + } + + textarea->WebAPI.HTMLTextAreaElement.remove + activeElement->Null.forEach(focusElement) + selection->Option.forEach(selection => { + selection->WebAPI.Selection.removeAllRanges + ranges->Array.forEach(range => selection->WebAPI.Selection.addRange(range)) + }) + + copied ? Ok() : Error(WriteFailed) + } catch { + | _ => Error(WriteFailed) + } +} + let writeText = async text => { try { await navigator.clipboard->WebAPI.Clipboard.writeText(text) Ok() } catch { - | _ => Error(WriteFailed) + | _ => writeWithSelection(text) } } From ef6529d80d278048ce7e058b86c27264f8a2ff9a Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 14:13:17 +0200 Subject: [PATCH 5/7] fix(ci): align localhost resolution for guide browser tests Use IPv4-first DNS ordering for the guide browser test step so React Router prerender requests reach the preview server in the Playwright container. Keep localhost URLs unchanged. --- .github/workflows/pull-request.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b8abc035d..49e32f6d9 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -126,6 +126,9 @@ jobs: - 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() From 9a44adcd7c0351cc0d3a07d273019bbba2464d26 Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 14:17:14 +0200 Subject: [PATCH 6/7] test: cover failure of both clipboard copy methods Deny the native Clipboard API and stub the legacy copy command to return false when checking error feedback. Confirm cleanup after failure and retain native permission recovery and repeated copying coverage. --- .../e2e/homepage/HomepageInteractions.cy.res | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/docs/e2e/homepage/HomepageInteractions.cy.res b/apps/docs/e2e/homepage/HomepageInteractions.cy.res index ad950a614..5243097eb 100644 --- a/apps/docs/e2e/homepage/HomepageInteractions.cy.res +++ b/apps/docs/e2e/homepage/HomepageInteractions.cy.res @@ -7,6 +7,10 @@ type clipboardDescriptor = {value: Nullable.t, configurable: 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 @@ -24,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 => { From 04c2c3d233a967a2c7d18e97fbedc3103ebbed0f Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Mon, 12 Oct 2026 14:30:32 +0200 Subject: [PATCH 7/7] test: wait for playground hydration before opening settings Wait for CodeMirror and the initial compiler output before clicking Settings in the warning suggestion regression. This avoids clicking server-rendered controls before React attaches their handlers. --- apps/docs/e2e/WarningFlags.cy.res | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/docs/e2e/WarningFlags.cy.res b/apps/docs/e2e/WarningFlags.cy.res index 86d97217e..62f789c1f 100644 --- a/apps/docs/e2e/WarningFlags.cy.res +++ b/apps/docs/e2e/WarningFlags.cy.res @@ -16,7 +16,10 @@ let expectSelectedSuggestionInView = (~scrolled, elements) => { 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