diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 941da3094..49e32f6d9 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -125,6 +125,17 @@ jobs: run: yarn workspace @rescript-lang/docs ci:test:scripts - name: Guide Vitest run: yarn workspace @rescript-lang/guide ci:test + - name: Guide browser history E2E + env: + # Keep the prerender server and requests on the same localhost address in Docker. + NODE_OPTIONS: --dns-result-order=ipv4first + run: yarn workspace @rescript-lang/guide test:e2e + - name: Upload guide browser test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: guide-browser-test-artifacts + path: apps/guide/test-results Homepage_E2E: runs-on: ubuntu-latest env: diff --git a/.gitignore b/.gitignore index 177e198e7..6806da50c 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,7 @@ apps/guide/.vitest-attachments # Browser test artifacts apps/docs/test-results/ +apps/guide/test-results/ # Lighthouse artifacts .lighthouseci/ diff --git a/apps/docs/__tests__/BlogArticleBehavior_.test.res b/apps/docs/__tests__/BlogArticleBehavior_.test.res index 6b0f0d317..05857a4dd 100644 --- a/apps/docs/__tests__/BlogArticleBehavior_.test.res +++ b/apps/docs/__tests__/BlogArticleBehavior_.test.res @@ -26,11 +26,11 @@ test("blog article marks body content for DocSearch crawling", async () => { await viewport(1440, 900) let _screen = await render( - +

{React.string("This is the blog post body content for testing.")}

-
, + , ) switch document->WebAPI.Document.querySelector("article.DocSearch-content") { diff --git a/apps/docs/__tests__/CopyButtons_.test.res b/apps/docs/__tests__/CopyButtons_.test.res new file mode 100644 index 000000000..82a2886e2 --- /dev/null +++ b/apps/docs/__tests__/CopyButtons_.test.res @@ -0,0 +1,62 @@ +open Vitest + +test( + "code copy waits for the write, copies exact code, and permits retry after failure", + async () => { + let copied = ref(None) + let complete = ref(_ => ()) + let writeClipboard = code => { + copied := Some(code) + Promise.make((resolve, _) => complete := resolve) + } + let screen = await render() + let button = await screen->getByLabelText("Copy code") + await button->click + await element(button)->toBeDisabled + expect(copied.contents)->toEqual(Some("let answer = 42")) + complete.contents(Error(Clipboard.WriteFailed)) + let retry = await screen->getByLabelText("Copy failed, try again") + await element(retry)->notToBeDisabled + await retry->click + complete.contents(Ok()) + let feedback = await screen->getByText("Copied to clipboard") + await element(feedback)->toBeVisible + await element(button)->toBeDisabled + await screen->unmount + await element(feedback)->notToBeInTheDocument + }, +) + +test( + "share copy writes the full URL and shows success only after the write completes", + async () => { + let copied = ref(None) + let complete = ref(_ => ()) + let writeClipboard = url => { + copied := Some(url) + Promise.make((resolve, _) => complete := resolve) + } + let screen = await render() + let button = await screen->getByText("Copy Share Link") + await button->click + await element(button)->toBeDisabled + expect(copied.contents)->toEqual(Some(window.location.href)) + complete.contents(Error(Clipboard.WriteFailed)) + let retry = await screen->getByText("Copy failed, try again") + await element(retry)->notToBeDisabled + await retry->click + complete.contents(Ok()) + await element(await screen->getByText("Copied to clipboard!"))->toBeVisible + }, +) + +test("a previous share write cannot change feedback for a new share link", async () => { + let complete = ref(_ => ()) + let writeClipboard = _ => Promise.make((resolve, _) => complete := resolve) + let screen = await render() + await (await screen->getByText("Copy Share Link"))->click + await screen->rerender() + complete.contents(Ok()) + await element(await screen->getByText("Copy Share Link"))->notToBeDisabled + await element(await screen->getByText("Copied to clipboard!"))->notToBeInTheDocument +}) diff --git a/apps/docs/__tests__/HighlightLanguages_.test.res b/apps/docs/__tests__/HighlightLanguages_.test.res index 6b1179bde..634b5212b 100644 --- a/apps/docs/__tests__/HighlightLanguages_.test.res +++ b/apps/docs/__tests__/HighlightLanguages_.test.res @@ -1,50 +1,29 @@ open Vitest -type registeredLanguage -type highlightOptions = {language: string} -type highlightResult = {value: string} - -@module("highlight.js/lib/core") @scope("default") -external createHighlighter: unit => HighlightLanguages.highlighter = "newInstance" - -@module("highlight.js/lib/languages/javascript") -external javascript: HighlightLanguages.definition = "default" - -@module("highlight.js/lib/languages/plaintext") -external plaintext: HighlightLanguages.definition = "default" - -@send -external getLanguage: (HighlightLanguages.highlighter, string) => option = - "getLanguage" - -@send -external highlight: (HighlightLanguages.highlighter, string, highlightOptions) => highlightResult = - "highlight" - test("registering a missing language makes its grammar and aliases available", async () => { - let highlighter = createHighlighter() + let highlighter = HighlightLanguages.make(HighlightLanguages.defaultInstance) - expect(highlighter->getLanguage("javascript"))->toEqual(None) - expect(highlighter->getLanguage("js"))->toEqual(None) - highlighter->HighlightLanguages.ensureRegistered("javascript", javascript) + expect(highlighter->HighlightLanguages.getLanguage("javascript"))->toEqual(None) + expect(highlighter->HighlightLanguages.getLanguage("js"))->toEqual(None) + highlighter->HighlightLanguages.ensureRegistered("javascript", HighlightJsBindings.javascript) - let result = highlighter->highlight("const answer = 42;", {language: "js"}) + let result = highlighter->HighlightLanguages.highlight("const answer = 42;", {language: "js"}) expect(result.value)->toBe( "const answer = 42;", ) }) test("registering an existing name or alias does not replace its grammar", async () => { - let highlighter = createHighlighter() - highlighter->HighlightLanguages.ensureRegistered("javascript", javascript) - let original = highlighter->getLanguage("javascript") + let highlighter = HighlightLanguages.make(HighlightLanguages.defaultInstance) + highlighter->HighlightLanguages.ensureRegistered("javascript", HighlightJsBindings.javascript) + let original = highlighter->HighlightLanguages.getLanguage("javascript") - highlighter->HighlightLanguages.ensureRegistered("javascript", plaintext) - highlighter->HighlightLanguages.ensureRegistered("js", plaintext) + highlighter->HighlightLanguages.ensureRegistered("javascript", HighlightJsBindings.text) + highlighter->HighlightLanguages.ensureRegistered("js", HighlightJsBindings.text) - expect(highlighter->getLanguage("javascript"))->toBe(original) - expect(highlighter->getLanguage("js"))->toBe(original) - let result = highlighter->highlight("const answer = 42;", {language: "js"}) + expect(highlighter->HighlightLanguages.getLanguage("javascript"))->toBe(original) + expect(highlighter->HighlightLanguages.getLanguage("js"))->toBe(original) + let result = highlighter->HighlightLanguages.highlight("const answer = 42;", {language: "js"}) expect(result.value)->toBe( "const answer = 42;", ) diff --git a/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res b/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res index 8e860e1cc..0e777fb82 100644 --- a/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res +++ b/apps/docs/__tests__/NavbarTertiaryBehavior_.test.res @@ -19,12 +19,12 @@ test("mobile drawer can be toggled open", async () => { await viewport(600, 1200) let screen = await render( - + breadcrumbs editLink - , + , ) let sidebar = await screen->getByTestId("sidebar-categories") diff --git a/apps/docs/__tests__/Search_.test.res b/apps/docs/__tests__/Search_.test.res index c27590892..503e4de4c 100644 --- a/apps/docs/__tests__/Search_.test.res +++ b/apps/docs/__tests__/Search_.test.res @@ -27,6 +27,25 @@ let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearch _snippetResult: {content: Nullable.null}, } +let makeCrawlerHit = (): DocSearch.docSearchHit => { + objectID: "crawler-hit", + content: Nullable.make("map(array, fn) returns a new array."), + url: "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map", + url_without_anchor: Nullable.undefined, + type_: Content, + anchor: Nullable.undefined, + deprecated: None, + hierarchy: { + lvl0: Nullable.make("Array"), + lvl1: Nullable.make("map"), + lvl2: Nullable.null, + lvl3: Nullable.null, + lvl4: Nullable.null, + lvl5: Nullable.null, + lvl6: Nullable.null, + }, +} + let withHierarchy = ( hit: DocSearch.docSearchHit, ~lvl0: string, @@ -268,28 +287,7 @@ test("hitComponent routes relative hit URLs through React Router", async () => { test( "getContentHtml falls back to plain content when crawler hit has no snippet result", async () => { - let hit: DocSearch.docSearchHit = Obj.magic( - Dict.fromArray([ - ("objectID", "crawler-hit"), - ("content", "map(array, fn) returns a new array."), - ("url", "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map"), - ("type", "content"), - ( - "hierarchy", - Obj.magic( - Dict.fromArray([ - ("lvl0", Obj.magic("Array")), - ("lvl1", Obj.magic("map")), - ("lvl2", Obj.magic(Nullable.null)), - ("lvl3", Obj.magic(Nullable.null)), - ("lvl4", Obj.magic(Nullable.null)), - ("lvl5", Obj.magic(Nullable.null)), - ("lvl6", Obj.magic(Nullable.null)), - ]), - ), - ), - ]), - ) + let hit: DocSearch.docSearchHit = makeCrawlerHit() expect(SearchResults.getContentHtml(hit))->toEqual(Some("map(array, fn) returns a new array.")) }, @@ -480,28 +478,7 @@ test("normalizeHitUrls rewrites absolute site URLs to relative paths", async () }) test("normalizeHitUrls tolerates crawler hits without url_without_anchor", async () => { - let hit: DocSearch.docSearchHit = Obj.magic( - Dict.fromArray([ - ("objectID", "crawler-hit"), - ("content", "map(array, fn) returns a new array."), - ("url", "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map"), - ("type", "content"), - ( - "hierarchy", - Obj.magic( - Dict.fromArray([ - ("lvl0", Obj.magic("Array")), - ("lvl1", Obj.magic("map")), - ("lvl2", Obj.magic(Nullable.null)), - ("lvl3", Obj.magic(Nullable.null)), - ("lvl4", Obj.magic(Nullable.null)), - ("lvl5", Obj.magic(Nullable.null)), - ("lvl6", Obj.magic(Nullable.null)), - ]), - ), - ), - ]), - ) + let hit: DocSearch.docSearchHit = makeCrawlerHit() let result = SearchResults.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/") diff --git a/apps/docs/__tests__/SyntaxLookupBehavior_.test.res b/apps/docs/__tests__/SyntaxLookupBehavior_.test.res index 94b8fbe10..06b8473ad 100644 --- a/apps/docs/__tests__/SyntaxLookupBehavior_.test.res +++ b/apps/docs/__tests__/SyntaxLookupBehavior_.test.res @@ -46,11 +46,11 @@ test("syntax lookup detail marks active content for DocSearch crawling", async ( await viewport(1440, 900) let _screen = await render( - + Array.getUnsafe(0)}>

{React.string("Detail content for @as decorator.")}

-
, + , ) switch document->WebAPI.Document.querySelector(".DocSearch-content h1") { diff --git a/apps/docs/__tests__/visual/BlogArticle_.test.res b/apps/docs/__tests__/visual/BlogArticle_.test.res index c3f6083a4..3e642fa13 100644 --- a/apps/docs/__tests__/visual/BlogArticle_.test.res +++ b/apps/docs/__tests__/visual/BlogArticle_.test.res @@ -24,13 +24,13 @@ test("desktop blog article renders header, author, date, and body", async () => await viewport(1440, 900) let screen = await render( - +
{React.string("This is the blog post body content for testing.")}
-
, + , ) let title = await screen->getByText("Test Blog Post Title") @@ -50,13 +50,13 @@ test("desktop archived blog article shows warning banner", async () => { await viewport(1440, 900) let screen = await render( - +
{React.string("This is the blog post body content for testing.")}
-
, + , ) let important = await screen->getByText("Important:") @@ -70,13 +70,13 @@ test("mobile blog article", async () => { await viewport(600, 1200) let screen = await render( - +
{React.string("This is the blog post body content for testing.")}
-
, + , ) let title = await screen->getByText("Test Blog Post Title") @@ -115,7 +115,7 @@ test("desktop blog article with co-authors shows all authors", async () => { await viewport(1440, 900) let screen = await render( - +
{
{React.string("Collaborative content.")}
-
, + , ) let mainAuthor = await screen->getByText("Test Author") @@ -151,7 +151,7 @@ test("desktop blog article with article image shows image", async () => { await viewport(1440, 900) let screen = await render( - +
{
{React.string("Content below the article image.")}
-
, + , ) let title = await screen->getByText("Blog Post With Article Image") @@ -185,13 +185,13 @@ test("desktop blog article without description", async () => { await viewport(1440, 900) let screen = await render( - +
{React.string("Content without a description above.")}
-
, + , ) let title = await screen->getByText("Blog Post Without Description") diff --git a/apps/docs/__tests__/visual/Blog_.test.res b/apps/docs/__tests__/visual/Blog_.test.res index 66c62d518..e5de5f052 100644 --- a/apps/docs/__tests__/visual/Blog_.test.res +++ b/apps/docs/__tests__/visual/Blog_.test.res @@ -58,11 +58,11 @@ test("desktop blog index shows featured post and cards", async () => { await viewport(1440, 900) let screen = await render( - +
-
, + , ) let featured = await screen->getByText("Announcing ReScript 12") @@ -82,11 +82,11 @@ test("desktop blog shows category selector", async () => { await viewport(1440, 900) let screen = await render( - +
-
, + , ) let allTab = await screen->getByText("All") @@ -103,11 +103,11 @@ test("mobile blog index", async () => { await viewport(600, 1200) let screen = await render( - +
-
, + , ) let featured = await screen->getByText("Announcing ReScript 12") @@ -121,11 +121,11 @@ test("blog shows empty state when no posts", async () => { await viewport(1440, 900) let screen = await render( - +
-
, + , ) let emptyMsg = await screen->getByText("Blog not yet available") @@ -170,11 +170,11 @@ test("desktop blog shows archived posts", async () => { await viewport(1440, 900) let screen = await render( - +
-
, + , ) let post1 = await screen->getByText("An Old Archived Post") @@ -208,11 +208,11 @@ test("desktop blog with single post", async () => { ] let screen = await render( - +
-
, + , ) let post = await screen->getByText("The Only Post") diff --git a/apps/docs/__tests__/visual/CodeExample_.test.res b/apps/docs/__tests__/visual/CodeExample_.test.res index 2497fb025..6ddd0e527 100644 --- a/apps/docs/__tests__/visual/CodeExample_.test.res +++ b/apps/docs/__tests__/visual/CodeExample_.test.res @@ -62,11 +62,11 @@ test("renders toggle with multiple tabs", async () => { ] let screen = await render( - +
-
, + , ) let resTab = await screen->getByText("ReScript") @@ -98,11 +98,11 @@ test("toggle switches between tabs on click", async () => { ] let screen = await render( - +
-
, + , ) // Click the JavaScript tab diff --git a/apps/docs/__tests__/visual/CommunityLayout_.test.res b/apps/docs/__tests__/visual/CommunityLayout_.test.res index dbbe7cb7f..1a288854b 100644 --- a/apps/docs/__tests__/visual/CommunityLayout_.test.res +++ b/apps/docs/__tests__/visual/CommunityLayout_.test.res @@ -21,13 +21,13 @@ test("desktop community layout shows sidebar and content", async () => { await viewport(1440, 900) let screen = await render( - +
{React.string("Community content goes here.")}
-
, + , ) let resources = await screen->getByText("Resources") @@ -47,13 +47,13 @@ test("mobile community layout hides sidebar", async () => { await viewport(600, 1200) let screen = await render( - +
{React.string("Community content goes here.")}
-
, + , ) let resources = await screen->getByText("Resources") @@ -87,13 +87,13 @@ test("desktop community layout with multiple categories", async () => { await viewport(1440, 900) let screen = await render( - +
{React.string("Community content with multiple categories.")}
-
, + , ) let resources = await screen->getByText("Resources") @@ -113,13 +113,13 @@ test("tablet community layout", async () => { await viewport(900, 900) let screen = await render( - +
{React.string("Community content goes here.")}
-
, + , ) let resources = await screen->getByText("Resources") diff --git a/apps/docs/__tests__/visual/Footer_.test.res b/apps/docs/__tests__/visual/Footer_.test.res index 9d20af2ea..f50fe9ffe 100644 --- a/apps/docs/__tests__/visual/Footer_.test.res +++ b/apps/docs/__tests__/visual/Footer_.test.res @@ -5,11 +5,11 @@ test("desktop footer shows all sections and links", async () => { await viewport(1440, 500) let screen = await render( - +
-
, + , ) let community = await screen->getByText("Community") @@ -35,11 +35,11 @@ test("mobile footer stacks sections vertically", async () => { await viewport(600, 800) let screen = await render( - +
-
, + , ) let community = await screen->getByText("Community") diff --git a/apps/docs/__tests__/visual/MainLayout_.test.res b/apps/docs/__tests__/visual/MainLayout_.test.res index 670d180c0..2b8154cff 100644 --- a/apps/docs/__tests__/visual/MainLayout_.test.res +++ b/apps/docs/__tests__/visual/MainLayout_.test.res @@ -5,13 +5,13 @@ test("desktop main layout renders children and footer", async () => { await viewport(1440, 900) let screen = await render( - +
{React.string("Main page content goes here.")}
-
, + , ) let content = await screen->getByTestId("main-content") @@ -28,13 +28,13 @@ test("mobile main layout", async () => { await viewport(600, 1200) let screen = await render( - +
{React.string("Main page content goes here.")}
-
, + , ) let content = await screen->getByTestId("main-content") diff --git a/apps/docs/__tests__/visual/MarkdownComponents_.test.res b/apps/docs/__tests__/visual/MarkdownComponents_.test.res index 7976d557f..eee8e75d4 100644 --- a/apps/docs/__tests__/visual/MarkdownComponents_.test.res +++ b/apps/docs/__tests__/visual/MarkdownComponents_.test.res @@ -171,7 +171,7 @@ test("renders links", async () => { await viewport(1440, 900) let screen = await render( - +
@@ -184,7 +184,7 @@ test("renders links", async () => {
-
, + , ) let externalLink = await screen->getByText("External link") diff --git a/apps/docs/__tests__/visual/NavbarPrimary_.test.res b/apps/docs/__tests__/visual/NavbarPrimary_.test.res index 23c7b50e3..5d8f2025f 100644 --- a/apps/docs/__tests__/visual/NavbarPrimary_.test.res +++ b/apps/docs/__tests__/visual/NavbarPrimary_.test.res @@ -5,9 +5,9 @@ test("desktop has everything visible", async () => { await viewport(1440, 500) let screen = await render( - + - , + , ) let leftContent = await screen->getByTestId("navbar-primary-left-content") @@ -33,9 +33,9 @@ test("tablet has everything visible", async () => { await viewport(900, 500) let screen = await render( - + - , + , ) let leftContent = await screen->getByTestId("navbar-primary-left-content") @@ -61,9 +61,9 @@ test("phone has some things hidden and a mobile nav that can be toggled", async await viewport(600, 1200) let screen = await render( - + - , + , ) let leftContent = await screen->getByTestId("navbar-primary-left-content") diff --git a/apps/docs/__tests__/visual/NavbarTertiary_.test.res b/apps/docs/__tests__/visual/NavbarTertiary_.test.res index 015c6d11e..ba8daf6df 100644 --- a/apps/docs/__tests__/visual/NavbarTertiary_.test.res +++ b/apps/docs/__tests__/visual/NavbarTertiary_.test.res @@ -19,12 +19,12 @@ test("desktop shows breadcrumbs and edit link", async () => { await viewport(1440, 500) let screen = await render( - + breadcrumbs editLink - , + , ) let navbar = await screen->getByTestId("navbar-tertiary") @@ -44,12 +44,12 @@ test("mobile shows breadcrumbs and drawer button", async () => { await viewport(600, 1200) let screen = await render( - + breadcrumbs editLink - , + , ) let navbar = await screen->getByTestId("navbar-tertiary") diff --git a/apps/docs/__tests__/visual/SidebarNav_.test.res b/apps/docs/__tests__/visual/SidebarNav_.test.res index 94303a657..70c5906f0 100644 --- a/apps/docs/__tests__/visual/SidebarNav_.test.res +++ b/apps/docs/__tests__/visual/SidebarNav_.test.res @@ -14,11 +14,11 @@ test("sidebar category renders title and nav items", async () => { await viewport(1440, 900) let screen = await render( - +
()} />
-
, + , ) let introduction = await screen->getByText("Introduction") @@ -38,7 +38,7 @@ test("sidebar category highlights active item", async () => { await viewport(1440, 900) let screen = await render( - +
{ onClick={_ => ()} />
-
, + , ) let introduction = await screen->getByText("Introduction") @@ -117,11 +117,11 @@ test("sidebar category with many items", async () => { } let screen = await render( - +
()} />
-
, + , ) let stringItem = await screen->getByText("String") diff --git a/apps/docs/__tests__/visual/SyntaxLookup_.test.res b/apps/docs/__tests__/visual/SyntaxLookup_.test.res index 38b3bb864..85a838a32 100644 --- a/apps/docs/__tests__/visual/SyntaxLookup_.test.res +++ b/apps/docs/__tests__/visual/SyntaxLookup_.test.res @@ -44,11 +44,11 @@ test("desktop syntax lookup renders categories and tags", async () => { await viewport(1440, 900) let screen = await render( - +
-
, + , ) let decoratorsHeading = await screen->getByText("Decorators") @@ -71,13 +71,13 @@ test("desktop syntax lookup with active item shows detail box", async () => { await viewport(1440, 900) let screen = await render( - +
Array.getUnsafe(0)}>
{React.string("Detail content for @as decorator.")}
-
, + , ) let detail = await screen->getByText("Detail content for @as decorator.") @@ -91,11 +91,11 @@ test("mobile syntax lookup", async () => { await viewport(600, 1200) let screen = await render( - +
-
, + , ) let decoratorsHeading = await screen->getByText("Decorators") @@ -118,11 +118,11 @@ test("deprecated items show with line-through styling", async () => { await viewport(1440, 900) let screen = await render( - +
-
, + , ) let deprecatedTag = await screen->getByText("|>") @@ -136,13 +136,13 @@ test("syntax lookup detail box shows summary", async () => { await viewport(1440, 900) let screen = await render( - +
Array.getUnsafe(2)}>
{React.string("Detailed documentation about the pipe operator.")}
-
, + , ) let detail = await screen->getByText("Detailed documentation about the pipe operator.") @@ -156,13 +156,13 @@ test("mobile syntax lookup with active item", async () => { await viewport(600, 1200) let screen = await render( - +
Array.getUnsafe(0)}>
{React.string("Detail content for @as decorator.")}
-
, + , ) let detail = await screen->getByText("Detail content for @as decorator.") diff --git a/apps/docs/app/routes/ApiDocs.res b/apps/docs/app/routes/ApiDocs.res index 3d5df459b..6d423b5a0 100644 --- a/apps/docs/app/routes/ApiDocs.res +++ b/apps/docs/app/routes/ApiDocs.res @@ -221,7 +221,7 @@ module MarkdownStylize = { let make = (~content, ~rehypePlugins) => { let components = { ...MarkdownComponents.default, - h2: MarkdownComponents.default.h3->Obj.magic, + h2: props => props.children , } content } @@ -367,7 +367,7 @@ module Data = { } let processStaticProps = (~slug: array) => { - let moduleName = slug->Belt.Array.getExn(0) + let moduleName = slug->Array.at(0)->Option.getOrThrow let modulePath = slug->Array.join("/") let content = @@ -456,7 +456,7 @@ let processStaticProps = (~slug: array) => { items: sortedItems, } - Ok({module_, toctree: Obj.magic({name: "root", path: [], children: []})}) + Ok({module_, toctree: {name: "root", path: [], children: []}}) | None => Error(`Failed to get API Data for module ${moduleName}`) } diff --git a/apps/docs/app/routes/Packages.res b/apps/docs/app/routes/Packages.res index c8d2e416c..319501055 100644 --- a/apps/docs/app/routes/Packages.res +++ b/apps/docs/app/routes/Packages.res @@ -112,7 +112,7 @@ module Resource = { let filteredNpm = applyNpmSearch(allNpms, pattern)->Array.map(m => Npm(m["item"])) let filteredOutdated = applyNpmSearch(allOutDated, pattern)->Array.map(m => Outdated(m["item"])) - Belt.Array.concatMany([filteredNpm, filteredOutdated]) + Array.concat(filteredNpm, filteredOutdated) } } @@ -326,7 +326,7 @@ let make = (props: props) => { let allResources = { let npms = props.packages->Array.map(pkg => Resource.Npm(pkg)) let outdated = props.unmaintained->Array.map(pkg => Resource.Outdated(pkg)) - Belt.Array.concatMany([npms, outdated]) + Array.concat(npms, outdated) } let resources = switch state { diff --git a/apps/docs/e2e/Breadcrumbs.cy.res b/apps/docs/e2e/Breadcrumbs.cy.res index 25d6ad419..ba30797bc 100644 --- a/apps/docs/e2e/Breadcrumbs.cy.res +++ b/apps/docs/e2e/Breadcrumbs.cy.res @@ -1,15 +1,15 @@ -open Cy +open Cypress let waitForPage = () => { - getByTestId("navbar-primary")->shouldBeVisible->ignore - getByTestId("navbar-tertiary")->shouldBeVisible->ignore - cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore + getByTestId("navbar-primary")->should("be.visible")->ignore + getByTestId("navbar-tertiary")->should("be.visible")->ignore + cyWindow()->propertyString("document.readyState")->shouldEqual("complete")->ignore } let expectBreadcrumbsText = text => { getByTestId("breadcrumbs") ->should("be.visible") - ->shouldWithValue("have.text", text) + ->shouldText(text) ->ignore } diff --git a/apps/docs/e2e/Navigation.cy.res b/apps/docs/e2e/Navigation.cy.res index e046d26a3..c123961f9 100644 --- a/apps/docs/e2e/Navigation.cy.res +++ b/apps/docs/e2e/Navigation.cy.res @@ -1,12 +1,12 @@ -open Cy +open Cypress // Wait for the app to fully hydrate before interacting with links. // The production build is pre-rendered so React must attach event // handlers before client-side navigation works. let waitForHydration = () => { - getByTestId("navbar-primary")->shouldBeVisible->ignore - cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore - wait(2000) + getByTestId("navbar-primary")->should("be.visible")->ignore + cyWindow()->propertyString("document.readyState")->shouldEqual("complete")->ignore + pause(2000) } // Use short, re-queryable selectors to avoid detached DOM issues. @@ -17,7 +17,7 @@ let waitForHydration = () => { let clickNavLink = (~testId, ~text) => { getByTestId(testId) ->should("be.visible") - ->containsSelectorChainable("a", text) + ->containsChildIn("a", text) ->scrollIntoView ->should("be.visible") ->click @@ -27,7 +27,7 @@ let clickNavLink = (~testId, ~text) => { let clickMobileNavLink = text => { getByTestId("mobile-nav") ->should("be.visible") - ->containsSelectorChainable("a", text) + ->containsChildIn("a", text) ->scrollIntoView ->should("be.visible") ->click @@ -53,8 +53,8 @@ describe("Desktop Navigation", () => { "should navigate to Docs via navbar link", () => { clickNavLink(~testId="navbar-primary-left-content", ~text="Docs") - url()->shouldInclude("/docs/manual/introduction")->ignore - get("h1")->shouldBeVisible->ignore + currentUrl()->shouldInclude("/docs/manual/introduction")->ignore + get("h1")->should("be.visible")->ignore }, ) @@ -62,7 +62,7 @@ describe("Desktop Navigation", () => { "should navigate to Playground via navbar link", () => { clickNavLink(~testId="navbar-primary-left-content", ~text="Playground") - url()->shouldInclude("/try")->ignore + currentUrl()->shouldInclude("/try")->ignore }, ) @@ -70,7 +70,7 @@ describe("Desktop Navigation", () => { "should navigate to Blog via navbar link", () => { clickNavLink(~testId="navbar-primary-left-content", ~text="Blog") - url()->shouldInclude("/blog")->ignore + currentUrl()->shouldInclude("/blog")->ignore }, ) @@ -78,8 +78,8 @@ describe("Desktop Navigation", () => { "should navigate to Community via navbar link", () => { clickNavLink(~testId="navbar-primary-left-content", ~text="Community") - url()->shouldInclude("/community")->ignore - get("h1")->shouldBeVisible->ignore + currentUrl()->shouldInclude("/community")->ignore + get("h1")->should("be.visible")->ignore }, ) @@ -87,10 +87,10 @@ describe("Desktop Navigation", () => { "should navigate home via logo after clicking away", () => { clickNavLink(~testId="navbar-primary-left-content", ~text="Blog") - url()->shouldInclude("/blog")->ignore + currentUrl()->shouldInclude("/blog")->ignore get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore - cyLocation("pathname")->shouldWithValue("eq", "/")->ignore + cyLocation("pathname")->shouldEqual("/")->ignore }, ) }) @@ -101,28 +101,28 @@ describe("Desktop Navigation", () => { () => { // Click Docs in primary nav to reveal the secondary nav clickNavLink(~testId="navbar-primary-left-content", ~text="Docs") - url()->shouldInclude("/docs/manual/introduction")->ignore + currentUrl()->shouldInclude("/docs/manual/introduction")->ignore // Language Manual clickNavLink(~testId="navbar-secondary", ~text="Language Manual") - url()->shouldInclude("/docs/manual/introduction")->ignore + currentUrl()->shouldInclude("/docs/manual/introduction")->ignore // API clickNavLink(~testId="navbar-secondary", ~text="API") - url()->shouldInclude("/docs/manual/api")->ignore + currentUrl()->shouldInclude("/docs/manual/api")->ignore // Guides clickNavLink(~testId="navbar-secondary", ~text="Guides") - url()->shouldInclude("/docs/guides/overview")->ignore + currentUrl()->shouldInclude("/docs/guides/overview")->ignore get("h1")->shouldContainText("Guides")->ignore // Syntax Lookup clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup") - url()->shouldInclude("/syntax-lookup")->ignore + currentUrl()->shouldInclude("/syntax-lookup")->ignore // React clickNavLink(~testId="navbar-secondary", ~text="React") - url()->shouldInclude("/docs/react/introduction")->ignore + currentUrl()->shouldInclude("/docs/react/introduction")->ignore }, ) }) @@ -142,8 +142,8 @@ describe("Mobile Navigation", () => { "should navigate to Docs via navbar link", () => { clickNavLink(~testId="navbar-primary-left-content", ~text="Docs") - url()->shouldInclude("/docs/manual/introduction")->ignore - get("h1")->shouldBeVisible->ignore + currentUrl()->shouldInclude("/docs/manual/introduction")->ignore + get("h1")->should("be.visible")->ignore }, ) @@ -151,10 +151,10 @@ describe("Mobile Navigation", () => { "should navigate home via logo after clicking away", () => { clickNavLink(~testId="navbar-primary-left-content", ~text="Docs") - url()->shouldInclude("/docs/manual/introduction")->ignore + currentUrl()->shouldInclude("/docs/manual/introduction")->ignore get("a[aria-label='homepage']")->should("be.visible")->first->click->ignore - cyLocation("pathname")->shouldWithValue("eq", "/")->ignore + cyLocation("pathname")->shouldEqual("/")->ignore }, ) }) @@ -165,7 +165,7 @@ describe("Mobile Navigation", () => { () => { openMobileMenu() clickMobileNavLink("Playground") - url()->shouldInclude("/try")->ignore + currentUrl()->shouldInclude("/try")->ignore }, ) @@ -174,7 +174,7 @@ describe("Mobile Navigation", () => { () => { openMobileMenu() clickMobileNavLink("Blog") - url()->shouldInclude("/blog")->ignore + currentUrl()->shouldInclude("/blog")->ignore }, ) @@ -183,8 +183,8 @@ describe("Mobile Navigation", () => { () => { openMobileMenu() clickMobileNavLink("Community") - url()->shouldInclude("/community")->ignore - get("h1")->shouldBeVisible->ignore + currentUrl()->shouldInclude("/community")->ignore + get("h1")->should("be.visible")->ignore }, ) }) @@ -195,35 +195,35 @@ describe("Mobile Navigation", () => { () => { // Click Docs in primary nav to reveal the secondary nav clickNavLink(~testId="navbar-primary-left-content", ~text="Docs") - url()->shouldInclude("/docs/manual/introduction")->ignore + currentUrl()->shouldInclude("/docs/manual/introduction")->ignore // Scroll to top so the secondary nav is visible cyScrollTo("top") // Language Manual clickNavLink(~testId="navbar-secondary", ~text="Language Manual") - url()->shouldInclude("/docs/manual/introduction")->ignore + currentUrl()->shouldInclude("/docs/manual/introduction")->ignore // API cyScrollTo("top") clickNavLink(~testId="navbar-secondary", ~text="API") - url()->shouldInclude("/docs/manual/api")->ignore + currentUrl()->shouldInclude("/docs/manual/api")->ignore // Guides cyScrollTo("top") clickNavLink(~testId="navbar-secondary", ~text="Guides") - url()->shouldInclude("/docs/guides/overview")->ignore + currentUrl()->shouldInclude("/docs/guides/overview")->ignore get("h1")->shouldContainText("Guides")->ignore // Syntax Lookup cyScrollTo("top") clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup") - url()->shouldInclude("/syntax-lookup")->ignore + currentUrl()->shouldInclude("/syntax-lookup")->ignore // React cyScrollTo("top") clickNavLink(~testId="navbar-secondary", ~text="React") - url()->shouldInclude("/docs/react/introduction")->ignore + currentUrl()->shouldInclude("/docs/react/introduction")->ignore }, ) }) diff --git a/apps/docs/e2e/OgImage.cy.res b/apps/docs/e2e/OgImage.cy.res new file mode 100644 index 000000000..927315bf6 --- /dev/null +++ b/apps/docs/e2e/OgImage.cy.res @@ -0,0 +1,19 @@ +open Cypress + +describe("Open Graph images", () => { + it("renders a PNG for a same-origin documentation page", () => { + let pageUrl = WebAPI.URL.make(~url="/docs/manual/introduction", ~base=baseUrl()) + let url = `/ogimage/index.png?url=${encodeURIComponent(pageUrl.href)}` + + requestWithOptions({url, encoding: "binary"}) + ->then( + response => { + expect(response.status)->equal(200) + expect(response.headers->Dict.get("content-type")->Option.getOr(""))->include_("image/png") + // The PNG signature verifies that the response is an image rather than an HTML error page. + expect(response.body->String.slice(~start=0, ~end=8))->equal("\x89PNG\r\n\x1a\n") + }, + ) + ->ignore + }) +}) diff --git a/apps/docs/e2e/Playground.cy.res b/apps/docs/e2e/Playground.cy.res index 8e8bf97c6..165510c1e 100644 --- a/apps/docs/e2e/Playground.cy.res +++ b/apps/docs/e2e/Playground.cy.res @@ -1,20 +1,20 @@ -open Cy +open Cypress let waitForHydration = () => { - getByTestId("navbar-primary")->shouldBeVisible->ignore - cyWindow()->its("document.readyState")->shouldWithValue("eq", "complete")->ignore - wait(2000) + getByTestId("navbar-primary")->should("be.visible")->ignore + cyWindow()->propertyString("document.readyState")->shouldEqual("complete")->ignore + pause(2000) } let waitForPlayground = () => { get(".cm-editor")->should("be.visible")->ignore getByTestId("control-panel")->should("be.visible")->ignore - wait(2000) + pause(2000) } let clickNavLink = (~testId, ~text) => { get(`[data-testid="${testId}"] a:visible`) - ->containsChainable(text) + ->containsChild(text) ->click ->ignore } @@ -29,7 +29,7 @@ let compileAndRunImportedCode = () => { ) ->ignore - wait(3000) + pause(3000) contains("JavaScript")->click->ignore get("pre.whitespace-pre-wrap") @@ -39,7 +39,7 @@ let compileAndRunImportedCode = () => { getByTestId("control-panel") ->find("button") - ->containsChainable("Run") + ->containsChild("Run") ->click ->ignore @@ -66,9 +66,9 @@ describe("Playground", () => { it("should highlight JavaScript after a direct playground load", () => { visit("/try") - get(".cm-editor")->shouldBeVisible->ignore - get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore - get(".cm-editor")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore + get(".cm-editor")->should("be.visible")->ignore + get("main")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore + get(".cm-editor")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore get("pre.whitespace-pre-wrap")->shouldContainText("react/jsx-runtime")->ignore get("pre code.lang-js span[class^='hljs-']")->should("exist")->ignore }) @@ -76,7 +76,7 @@ describe("Playground", () => { it("should compile and run imported code in the playground", () => { // Navigate to the playground from the homepage clickNavLink(~testId="navbar-primary-left-content", ~text="Playground") - url()->shouldInclude("/try")->ignore + currentUrl()->shouldInclude("/try")->ignore compileAndRunImportedCode() }) @@ -91,7 +91,7 @@ describe("Playground", () => { contains("Settings")->click->ignore get(`select[name="compilerVersions"]`) ->select(version) - ->shouldWithValue("have.value", version) + ->shouldValue(version) ->ignore compileAndRunImportedCode() @@ -116,7 +116,7 @@ Console.log(isRetriable(HttpError({status: 500})))` get("pre.whitespace-pre-wrap")->shouldContainText("Primitive_exceptions.mjs")->ignore getByTestId("control-panel") ->find("button") - ->containsChainable("Run") + ->containsChild("Run") ->click ->ignore @@ -124,13 +124,13 @@ Console.log(isRetriable(HttpError({status: 500})))` }) it("should open the landing page example in the playground with code and compiled output", () => { - containsSelector("a", "Edit this example in Playground") + containsIn("a", "Edit this example in Playground") ->scrollIntoView ->should("be.visible") ->click ->ignore - url()->shouldInclude("/try?code=")->ignore + currentUrl()->shouldInclude("/try?code=")->ignore waitForPlayground() get(".cm-content") @@ -148,45 +148,43 @@ Console.log(isRetriable(HttpError({status: 500})))` it("should switch to light mode from toast and back to dark mode in settings", () => { // Navigate to playground and wait for initial render clickNavLink(~testId="navbar-primary-left-content", ~text="Playground") - url()->shouldInclude("/try")->ignore + currentUrl()->shouldInclude("/try")->ignore waitForPlayground() // Switch to light mode through the onboarding toast getByTestId("playground-lightmode-toast") ->should("be.visible") ->find("button") - ->containsChainable("Try it now") + ->containsChild("Try it now") ->click ->ignore // Verify playground shell is in light mode - get("main")->shouldWithValue("have.class", "playground-theme-light")->ignore - get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(250, 251, 252)")->ignore + get("main")->shouldClass("playground-theme-light")->ignore + get("main")->shouldCss("background-color", "rgb(250, 251, 252)")->ignore get(".cm-editor") - ->shouldWithKeyValue("have.css", "background-color", "rgb(255, 255, 255)") + ->shouldCss("background-color", "rgb(255, 255, 255)") ->ignore cyWindow() - ->its("localStorage") - ->invokeWithArg("getItem", "playgroundTheme") - ->shouldWithValue("eq", "light") + ->thenMap(window => window.localStorage->WebAPI.Storage.getItem("playgroundTheme")) + ->shouldEqual(Null.make("light")) ->ignore // Switch back to dark mode from Settings contains("Settings")->click->ignore get("main") ->find("button") - ->containsChainable("Dark") + ->containsChild("Dark") ->click ->ignore // Verify playground shell is back to dark mode - get("main")->shouldWithValue("have.class", "playground-theme-dark")->ignore - get("main")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore - get(".cm-editor")->shouldWithKeyValue("have.css", "background-color", "rgb(11, 13, 34)")->ignore + get("main")->shouldClass("playground-theme-dark")->ignore + get("main")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore + get(".cm-editor")->shouldCss("background-color", "rgb(11, 13, 34)")->ignore cyWindow() - ->its("localStorage") - ->invokeWithArg("getItem", "playgroundTheme") - ->shouldWithValue("eq", "dark") + ->thenMap(window => window.localStorage->WebAPI.Storage.getItem("playgroundTheme")) + ->shouldEqual(Null.make("dark")) ->ignore }) }) diff --git a/apps/docs/e2e/WarningFlags.cy.res b/apps/docs/e2e/WarningFlags.cy.res new file mode 100644 index 000000000..62f789c1f --- /dev/null +++ b/apps/docs/e2e/WarningFlags.cy.res @@ -0,0 +1,40 @@ +open Cypress + +let expectSelectedSuggestionInView = (~scrolled, elements) => { + let selected = elements->item(0)->Option.getOrThrow->HtmlRef.fromDom + let parent = selected.parentElement->Null.getOrThrow + let selectedRect = selected->WebAPI.HTMLElement.getBoundingClientRect + let parentRect = parent->WebAPI.HTMLElement.getBoundingClientRect + expect(selectedRect.top)->atLeast(parentRect.top -. 1.0) + expect(parentRect.bottom +. 1.0)->atLeast(selectedRect.bottom) + if scrolled { + expect(parent.scrollTop)->greaterThanFloat(0.0) + } else { + expect(parent.scrollTop)->closeTo(0.0, 1.0) + } +} + +describe("Warning flag suggestions", () => { + it("scrolls the selected suggestion into view in both keyboard directions", () => { + viewport(1280, 720) + visit("/try") + get(".cm-editor")->should("be.visible")->ignore + get("pre.whitespace-pre-wrap")->shouldContainText("react/jsx-runtime")->ignore + containsIn("button", "Settings")->click->ignore + let input = "input[placeholder='Flags']" + get(input)->typeText("+")->ignore + for _ in 1 to 15 { + get(input)->typeText("{downarrow}")->ignore + } + get(".playground-suggestion-active") + ->shouldSatisfy(elements => expectSelectedSuggestionInView(~scrolled=true, elements)) + ->ignore + + for _ in 1 to 15 { + get(input)->typeText("{uparrow}")->ignore + } + get(".playground-suggestion-active") + ->shouldSatisfy(elements => expectSelectedSuggestionInView(~scrolled=false, elements)) + ->ignore + }) +}) diff --git a/apps/docs/e2e/bindings/Cy.res b/apps/docs/e2e/bindings/Cy.res deleted file mode 100644 index d34ef610d..000000000 --- a/apps/docs/e2e/bindings/Cy.res +++ /dev/null @@ -1,141 +0,0 @@ -// -- Chainable type ----------------------------------------------------------- - -type t - -// -- Cypress global commands -------------------------------------------------- - -@val @scope("cy") -external visit: string => unit = "visit" - -@val @scope("cy") -external visitWithOptions: (string, {..}) => unit = "visit" - -@val @scope("cy") -external get: string => t = "get" - -@val @scope("cy") -external contains: string => t = "contains" - -@val @scope("cy") -external containsSelector: (string, string) => t = "contains" - -@val @scope("cy") -external title: unit => t = "title" - -@val @scope("cy") -external url: unit => t = "url" - -@val @scope("cy") -external cyLocation: string => t = "location" - -@val @scope("cy") -external wait: int => unit = "wait" - -@val @scope("cy") -external reload: unit => unit = "reload" - -@val @scope("cy") -external go: string => unit = "go" - -@val @scope("cy") -external viewport: (int, int) => unit = "viewport" - -@val @scope("cy") -external cyScrollTo: string => unit = "scrollTo" - -@val @scope("cy") -external log: string => unit = "log" - -// -- Chainable commands ------------------------------------------------------- - -// Contains (chainable versions) -@send external containsChainable: (t, string) => t = "contains" -@send external containsSelectorChainable: (t, string, string) => t = "contains" - -// Queries -@send external find: (t, string) => t = "find" -@send external first: t => t = "first" -@send external last: t => t = "last" -@send external eq: (t, int) => t = "eq" -@send external children: t => t = "children" -@send external parent: t => t = "parent" -@send external parents: (t, string) => t = "parents" -@send external closest: (t, string) => t = "closest" -@send external next: t => t = "next" -@send external prev: t => t = "prev" -@send external siblings: t => t = "siblings" -@send external filter: (t, string) => t = "filter" -@send external not_: (t, string) => t = "not" - -// Actions -@send external click: t => t = "click" -@send external clickWithOptions: (t, {..}) => t = "click" -@send external dblclick: t => t = "dblclick" -@send external type_: (t, string) => t = "type" -@send external typeWithOptions: (t, string, {..}) => t = "type" -@send external clear: t => t = "clear" -@send external check: t => t = "check" -@send external uncheck: t => t = "uncheck" -@send external select: (t, string) => t = "select" -@send external trigger: (t, string) => t = "trigger" -@send external scrollIntoView: t => t = "scrollIntoView" -@send external focus: t => t = "focus" -@send external blur: t => t = "blur" - -// Assertions -@send external should: (t, string) => t = "should" -@send external shouldWithValue: (t, string, string) => t = "should" -@send external shouldWithKeyValue: (t, string, string, string) => t = "should" -@send external and_: (t, string) => t = "and" -@send external andWithValue: (t, string, string) => t = "and" - -// Traversal -@send external each: (t, t => unit) => t = "each" -@send external then: (t, t => unit) => t = "then" -@send external its: (t, string) => t = "its" - -// Attributes -@send external invoke: (t, string) => t = "invoke" -@send external invokeWithArg: (t, string, string) => t = "invoke" -@send external attr: (t, string) => t = "attr" - -// Visibility & state -@send external asChainable: (t, string) => t = "as" - -// Yielding -@send external within: (t, unit => unit) => t = "within" - -// -- Describe / It (Mocha globals) -------------------------------------------- - -@val external describe: (string, unit => unit) => unit = "describe" -@val external it: (string, unit => unit) => unit = "it" -@val external before: (unit => unit) => unit = "before" -@val external beforeEach: (unit => unit) => unit = "beforeEach" -@val external after: (unit => unit) => unit = "after" -@val external afterEach: (unit => unit) => unit = "afterEach" -@val external context: (string, unit => unit) => unit = "context" - -// -- Window ------------------------------------------------------------------- - -@val @scope("cy") -external cyWindow: unit => t = "window" - -// -- Convenience helpers ------------------------------------------------------ - -let getByTestId = testId => get(`[data-testid="${testId}"]`) - -let getByRole = role => get(`[role="${role}"]`) - -let getByHref = href => get(`a[href="${href}"]`) - -let shouldBeVisible = chain => chain->should("be.visible") - -let shouldBeVisibleAndClick = chain => chain->should("be.visible")->click - -let shouldExist = chain => chain->should("exist") - -let shouldContainText = (chain, text) => chain->shouldWithValue("contain.text", text) - -let shouldHaveAttr = (chain, attr, value) => chain->shouldWithKeyValue("have.attr", attr, value) - -let shouldInclude = (chain, value) => chain->shouldWithValue("include", value) diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index be8c3c607..e9c164ff8 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -12,6 +12,7 @@ type rec window = { console: console, navigator: {clipboard: clipboard}, scrollY: float, + localStorage: WebAPI.WebStorageAPI.storage, } and clipboard type response = {status: int, body: string, headers: Dict.t} @@ -214,3 +215,26 @@ external addEventListenerOnce: (Dom.document, string, unit => unit, listenerOpti @get external fontWeight: cssStyle => string = "fontWeight" @get external fontStyle: cssStyle => string = "fontStyle" @send external propertyValue: (cssStyle, string) => string = "getPropertyValue" + +@val external describe: (string, unit => unit) => unit = "describe" +@val @scope("cy") external currentUrl: unit => chain = "url" +@val @scope("cy") external pause: int => unit = "wait" +@val @scope("cy") external cyScrollTo: string => unit = "scrollTo" +@send external find: (chain, string) => chain = "find" +@send external containsChild: (chain, string) => chain = "contains" +@send external containsChildIn: (chain, string, string) => chain = "contains" +@send external select: (chain, string) => chain = "select" +@send +external typeWithOptions: ( + chain, + string, + {"force": bool, "delay": int}, +) => chain = "type" +@send +external shouldContainText: (chain, @as("contain.text") _, string) => chain = + "should" +@send external shouldInclude: (chain, @as("include") _, string) => chain = "should" +@send +external shouldClass: (chain, @as("have.class") _, string) => chain = "should" + +let getByTestId = testId => get(`[data-testid="${testId}"]`) diff --git a/apps/docs/e2e/homepage/HomepageInteractions.cy.res b/apps/docs/e2e/homepage/HomepageInteractions.cy.res index a5e242d8e..5243097eb 100644 --- a/apps/docs/e2e/homepage/HomepageInteractions.cy.res +++ b/apps/docs/e2e/homepage/HomepageInteractions.cy.res @@ -1,6 +1,16 @@ open Cypress open HomepageHelpers +type clipboardDescriptor = {value: Nullable.t, configurable: bool} + +@val @scope("Object") +external hideClipboardAPI: ('navigator, @as("clipboard") _, clipboardDescriptor) => unit = + "defineProperty" + +@val @scope("cy") +external stubCopyCommand: (Dom.document, @as("execCommand") _) => spy = "stub" +@send external returns: (spy, bool) => spy = "returns" + it("community gallery supports keyboard selection and wraps to the first photo", () => { visit("/") containsIn("h1", headline)->realClick->ignore @@ -18,15 +28,24 @@ it("community gallery supports keyboard selection and wraps to the first photo", get(`img[alt="ReScript community photo 1"]`)->should("be.visible")->ignore }) -it("clipboard denial can recover and both install commands can be copied repeatedly", () => { +it("clipboard failure can recover and both install commands can be copied repeatedly", () => { denyClipboardPermissions() - visit("/") + visitWithOptions( + "/", + { + onBeforeLoad: window => { + // Denying the native API still allows the legacy copy command, so fail it too. + window.document->stubCopyCommand->returns(false)->ignore + }, + }, + ) let first = `button[aria-label="Copy npm install rescript command"]` get(`[role="status"]`)->shouldInt("have.length", 2)->ignore get(`button [role="status"]`)->should("not.exist")->ignore get(first)->realClick->ignore containsIn(`[role="status"]`, "Could not copy. Try again.")->should("be.visible")->ignore get(first)->should("be.enabled")->ignore + get("textarea")->should("not.exist")->ignore grantClipboardPermissions() ["npm install rescript", "npx create-rescript-app"]->Array.forEach(command => { @@ -42,3 +61,23 @@ it("clipboard denial can recover and both install commands can be copied repeate get(button)->should("be.enabled")->ignore }) }) + +it("copying falls back when the Clipboard API is unavailable", () => { + grantClipboardPermissions() + let nativeClipboard = ref(None) + visitWithOptions( + "/", + { + onBeforeLoad: window => { + nativeClipboard := Some(window.navigator.clipboard) + hideClipboardAPI(window.navigator, {value: Nullable.undefined, configurable: true}) + }, + }, + ) + get(`button[aria-label="Copy npm install rescript command"]`)->realClick->ignore + containsIn(`[role="status"]`, "Copied!")->should("be.visible")->ignore + runPromise(() => nativeClipboard.contents->Option.getOrThrow->readText) + ->shouldEqual("npm install rescript") + ->ignore + get("textarea")->should("not.exist")->ignore +}) diff --git a/apps/docs/functions/ogimage/[[path]]/index.png.res b/apps/docs/functions/ogimage/[[path]]/index.png.res index e3526a3f3..7b5d6f480 100644 --- a/apps/docs/functions/ogimage/[[path]]/index.png.res +++ b/apps/docs/functions/ogimage/[[path]]/index.png.res @@ -1,7 +1,5 @@ open WebAPI -%%raw("import React from 'react'") - let loadGoogleFont = async (family: string) => { let url = `https://fonts.googleapis.com/css2?family=${family}` let css = await (await fetch(url))->Response.text diff --git a/apps/docs/functions/tsconfig.json b/apps/docs/functions/tsconfig.json new file mode 100644 index 000000000..a224293f4 --- /dev/null +++ b/apps/docs/functions/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/apps/docs/package.json b/apps/docs/package.json index e7350a978..4345a9bc8 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -63,7 +63,6 @@ "mdast-util-toc": "^7.1.0", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-markdown": "^10.1.0", "react-router": "^8.3.1", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.1", diff --git a/apps/docs/scripts/__tests__/Clipboard_.test.res b/apps/docs/scripts/__tests__/Clipboard_.test.res new file mode 100644 index 000000000..b1e12a4b1 --- /dev/null +++ b/apps/docs/scripts/__tests__/Clipboard_.test.res @@ -0,0 +1,88 @@ +open ScriptTest + +type dom = {window: WebAPI.DOMAPI.window} +type clipboard = {writeText: string => promise} +type navigator = {clipboard?: clipboard} +@module("jsdom") @new external makeDom: string => dom = "JSDOM" +@module("vitest") @scope("vi") external stubGlobal: (string, 'a) => unit = "stubGlobal" +@module("vitest") @scope("vi") external unstubGlobals: unit => unit = "unstubAllGlobals" +@send external close: WebAPI.DOMAPI.window => unit = "close" +@set external setCopyCommand: (WebAPI.DOMAPI.document, string => bool) => unit = "execCommand" +@get external textareaValue: WebAPI.DOMAPI.element => string = "value" + +let setup = (~writeNative=?) => { + let dom = makeDom("

Selected text

") + stubGlobal("document", dom.window.document) + stubGlobal( + "navigator", + {clipboard: ?(writeNative->Option.map(writeText => {writeText: writeText}))}, + ) + onTestFinished(async () => { + unstubGlobals() + close(dom.window) + }) + dom.window.document +} + +let textareaCount = document => (document->WebAPI.Document.querySelectorAll("textarea")).length + +test("clipboard prefers the native API without creating a fallback textarea", async () => { + let copied = ref("") + let document = setup(~writeNative=async text => copied := text) + setCopyCommand(document, _ => { + throw(Failure("Fallback should not run")) + }) + expect(await Clipboard.writeText("let answer = 42"))->toStrictEqual(Ok()) + expect(copied.contents)->toBe("let answer = 42") + expect(textareaCount(document))->toBe(0) +}) + +test("missing Clipboard API falls back and restores focus and selected text", async () => { + let document = setup() + let button = document->WebAPI.Document.querySelector("button")->Null.getOrThrow + Clipboard.focusElement(button) + let paragraph = document->WebAPI.Document.querySelector("p")->Null.getOrThrow + let selection = document->WebAPI.Document.getSelection + let range = document->WebAPI.Document.createRange + range->WebAPI.Range.selectNodeContents(paragraph->WebAPI.Element.asNode) + selection->WebAPI.Selection.removeAllRanges + selection->WebAPI.Selection.addRange(range) + expect(String.make(selection))->toBe("Selected text") + + let copied = ref("") + setCopyCommand(document, command => { + expect(command)->toBe("copy") + let textarea = document->WebAPI.Document.querySelector("textarea")->Null.getOrThrow + copied := textarea->textareaValue + true + }) + + expect(await Clipboard.writeText("let answer = 42"))->toStrictEqual(Ok()) + expect(copied.contents)->toBe("let answer = 42") + expect(document.activeElement)->toBe(Null.make(button)) + expect(String.make(selection))->toBe("Selected text") + expect(textareaCount(document))->toBe(0) +}) + +test("a rejected native clipboard write tries the selection fallback", async () => { + let document = setup(~writeNative=async _ => throw(Failure("Permission denied"))) + let commands = ref([]) + setCopyCommand(document, command => { + commands.contents->Array.push(command) + true + }) + expect(await Clipboard.writeText("share URL"))->toStrictEqual(Ok()) + expect(commands.contents)->toStrictEqual(["copy"]) + expect(textareaCount(document))->toBe(0) +}) + +test("failed fallback commands report failure and remove their temporary textarea", async () => { + let document = setup() + setCopyCommand(document, _ => false) + expect(await Clipboard.writeText("code"))->toStrictEqual(Error(Clipboard.WriteFailed)) + expect(textareaCount(document))->toBe(0) + + setCopyCommand(document, _ => throw(Failure("Copy unavailable"))) + expect(await Clipboard.writeText("code"))->toStrictEqual(Error(Clipboard.WriteFailed)) + expect(textareaCount(document))->toBe(0) +}) diff --git a/apps/docs/scripts/__tests__/Interop_.test.res b/apps/docs/scripts/__tests__/Interop_.test.res new file mode 100644 index 000000000..eed60dbb3 --- /dev/null +++ b/apps/docs/scripts/__tests__/Interop_.test.res @@ -0,0 +1,68 @@ +open ScriptTest + +@module("react-dom/server") external renderHtml: React.element => string = "renderToStaticMarkup" + +test("MDX list items preserve paragraph and nested list structure", async () => { + let html = renderHtml( + + {React.string("Parent")} + + {React.string("Child")} + + , + ) + expect(html)->toContain("Parent

String.includes("

toBe(false) + expect(renderHtml( {React.null} ))->toContain("") +}) + +test( + "MDX code tabs read code, language, labels and line metadata from React children", + async () => { + HighlightLanguages.defaultInstance->ContentHighlighting.register + let html = renderHtml( + + +

+            
+              {React.string("let a = 1\nlet b = 2")}
+            
+          
+
+            
+              {React.string("const a = 1;")}
+            
+          
+ + , + ) + expect(html)->toContain("Source") + expect(html)->toContain("Output") + expect(html)->toContain("lang-rescript") + expect(html)->toContain("opacity-50") + expect(html)->toContain("let") + expect(html->String.replaceAllRegExp(/<[^>]*>/g, ""))->toContain("let b = 2") + }, +) + +test("URL boxes preserve their first paragraph, following content and destination", async () => { + let html = renderHtml( + + +

{React.string("First paragraph")}

+

{React.string("Second paragraph")}

+
+
, + ) + expect(html)->toContain("First paragraph") + expect(html)->toContain("Second paragraph") + expect(html)->toContain("Read more") + expect(html)->toContain("href=\"/docs/manual/introduction\"") + expect(html->String.includes("

toBe(false) +}) + +test("typed string helpers preserve empty strings and repeated hyphen behavior", async () => { + expect(Util.String.camelCase("hello--world-42-next"))->toBe("hello-World-42Next") + expect(Util.String.capitalize(""))->toBe("") + expect(Util.String.capitalize("hello"))->toBe("Hello") +}) diff --git a/apps/docs/src/bindings/HighlightJsBindings.res b/apps/docs/src/bindings/HighlightJsBindings.res index f793d247f..3943cb613 100644 --- a/apps/docs/src/bindings/HighlightJsBindings.res +++ b/apps/docs/src/bindings/HighlightJsBindings.res @@ -1,9 +1,3 @@ -type highlightOptions = {language: string} -type highlightResult = {value: string} - -@module("highlight.js/lib/core") @scope("default") -external make: unit => HighlightLanguages.highlighter = "newInstance" - @module("highlight.js/lib/languages/bash") external bash: HighlightLanguages.definition = "default" @@ -36,14 +30,3 @@ external toml: HighlightLanguages.definition = "default" @module("highlightjs-rescript") external rescript: HighlightLanguages.definition = "default" - -@send -external registerLanguage: ( - HighlightLanguages.highlighter, - string, - HighlightLanguages.definition, -) => unit = "registerLanguage" - -@send -external highlight: (HighlightLanguages.highlighter, string, highlightOptions) => highlightResult = - "highlight" diff --git a/apps/docs/src/common/AlgoliaConfig.res b/apps/docs/src/common/AlgoliaConfig.res index e486ee425..f557262e4 100644 --- a/apps/docs/src/common/AlgoliaConfig.res +++ b/apps/docs/src/common/AlgoliaConfig.res @@ -4,24 +4,12 @@ type publicConfig = { searchApiKey: string, } -let isPresent = value => - switch value { - | Some(v) => v !== "" - | None => false - } - -let missingPublicVars = (~appId, ~indexName, ~searchApiKey): array => { - let missing = [] - if !isPresent(appId) { - missing->Array.push("VITE_ALGOLIA_APP_ID") - } - if !isPresent(indexName) { - missing->Array.push("VITE_ALGOLIA_INDEX_NAME") - } - if !isPresent(searchApiKey) { - missing->Array.push("VITE_ALGOLIA_SEARCH_API_KEY") - } - missing +let missingPublicVars = (~appId, ~indexName, ~searchApiKey) => { + let env = Dict.make() + appId->Option.forEach(value => env->Dict.set("VITE_ALGOLIA_APP_ID", value)) + indexName->Option.forEach(value => env->Dict.set("VITE_ALGOLIA_INDEX_NAME", value)) + searchApiKey->Option.forEach(value => env->Dict.set("VITE_ALGOLIA_SEARCH_API_KEY", value)) + AlgoliaEnvStatus.getMissingPublicAlgoliaVars(~env) } let publicConfigFrom = (~appId, ~indexName, ~searchApiKey): option => diff --git a/apps/docs/src/common/Clipboard.res b/apps/docs/src/common/Clipboard.res deleted file mode 100644 index ea9b53b51..000000000 --- a/apps/docs/src/common/Clipboard.res +++ /dev/null @@ -1,10 +0,0 @@ -type error = WriteFailed - -let writeText = async text => { - try { - await navigator.clipboard->WebAPI.Clipboard.writeText(text) - Ok() - } catch { - | _ => Error(WriteFailed) - } -} diff --git a/apps/docs/src/common/Hooks.res b/apps/docs/src/common/Hooks.res index 682355c21..99b79381f 100644 --- a/apps/docs/src/common/Hooks.res +++ b/apps/docs/src/common/Hooks.res @@ -1,19 +1,3 @@ -/* Contains some generic hooks */ -let useOutsideClick: (ReactDOM.Ref.t, unit => unit) => unit = %raw(`(outerRef, trigger) => { - function handleClickOutside(event) { - if (outerRef.current && !outerRef.current.contains(event.target)) { - trigger(); - } - } - - React.useEffect(() => { - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }); -}`) - /** scrollDir is not memo-friendly. It must be used with pattern matching. Do not pass it directly to child components. */ diff --git a/apps/docs/src/common/Util.res b/apps/docs/src/common/Util.res index 27e8d354e..aabbf2221 100644 --- a/apps/docs/src/common/Util.res +++ b/apps/docs/src/common/Util.res @@ -1,21 +1,14 @@ -module Unsafe = { - external elementAsString: React.element => string = "%identity" -} - module String = { - let camelCase: string => string = %raw("str => { - return str.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); }); - }") - - let kebabCase = string => - string - ->String.replaceAllRegExp(/([a-z])([A-Z])/g, "$1-$2") - ->String.toLowerCase - ->String.replaceAll(" ", "-") - - let capitalize: string => string = %raw("str => { - return str && str.charAt(0).toUpperCase() + str.substring(1); - }") + let camelCase = str => + str->String.replaceRegExpBy1Unsafe(/-([a-z])/g, ( + ~match as _, + ~group1, + ~offset as _, + ~input as _, + ) => group1->String.toUpperCase) + + let capitalize = str => + str->String.slice(~start=0, ~end=1)->String.toUpperCase ++ str->String.slice(~start=1) let capitalizeSentence = str => str diff --git a/apps/docs/src/common/Util.resi b/apps/docs/src/common/Util.resi index fb0da2991..c395ff634 100644 --- a/apps/docs/src/common/Util.resi +++ b/apps/docs/src/common/Util.resi @@ -6,13 +6,8 @@ module Url: { let makeOpenGraphImageUrl: string => string } -module Unsafe: { - let elementAsString: React.element => string -} - module String: { let camelCase: string => string - let kebabCase: string => string let capitalize: string => string let capitalizeSentence: string => string let leadingSlash: string => string diff --git a/apps/docs/src/components/CodeCopyButton.res b/apps/docs/src/components/CodeCopyButton.res new file mode 100644 index 000000000..ed56f0187 --- /dev/null +++ b/apps/docs/src/components/CodeCopyButton.res @@ -0,0 +1,78 @@ +type state = + | Init + | Pending + | Copied + | Failed + +@react.component +let make = (~code, ~writeClipboard=Clipboard.writeText) => { + let (state, setState) = React.useState(_ => Init) + + let buttonRef = React.useRef(Nullable.null) + + let copy = async () => { + setState(_ => Pending) + let result = await writeClipboard(code) + setState(_ => + switch result { + | Ok() => Copied + | Error(_) => Failed + } + ) + } + let onClick = evt => { + ReactEvent.Mouse.preventDefault(evt) + copy()->ignore + } + + React.useEffect(() => { + switch state { + | Copied => + let buttonEl = Nullable.toOption(buttonRef.current)->Option.getOrThrow + + // Note on this imperative DOM nonsense: + // For Tailwind transitions to behave correctly, we need to first paint the DOM element in the tree, + // and in the next tick, add the opacity-100 class, so the transition animation actually takes place. + // If we don't do that, the banner will essentially pop up without any animation + let bannerEl = WebAPI.Document.createElement(document, "div") + bannerEl.className = "opacity-0 absolute -top-6 right-0 -mt-5 -mr-4 px-4 py-2 w-40 rounded-lg captions text-white bg-gray-100 text-gray-80-tr transition-all duration-1000 ease-in-out " + + let textNode = WebAPI.Document.createTextNode(document, "Copied to clipboard") + + WebAPI.Element.appendChild(bannerEl, textNode)->ignore + WebAPI.Element.appendChild(buttonEl, bannerEl)->ignore + + let nextFrameId = WebAPI.Window.requestAnimationFrame(window, _ => { + WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-0")->ignore + WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-100")->ignore + }) + + let timeoutId = setTimeout(~handler=() => { + buttonEl->WebAPI.Element.removeChild(bannerEl)->ignore + setState(_ => Init) + }, ~timeout=3000) + + Some( + () => { + cancelAnimationFrame(nextFrameId) + clearTimeout(timeoutId) + bannerEl->WebAPI.Element.remove + }, + ) + | _ => None + } + }, [state]) + //Copy-Button + +} diff --git a/apps/docs/src/components/CodeExample.res b/apps/docs/src/components/CodeExample.res index 3736d8da0..5346b4e49 100644 --- a/apps/docs/src/components/CodeExample.res +++ b/apps/docs/src/components/CodeExample.res @@ -9,102 +9,6 @@ let langShortname = (lang: string) => | rest => rest } -module CopyButton = { - let copyToClipboard: string => bool = %raw(` - function(str) { - try { - const el = document.createElement('textarea'); - el.value = str; - el.setAttribute('readonly', ''); - el.style.position = 'absolute'; - el.style.left = '-9999px'; - document.body.appendChild(el); - const selected = - document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false; - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - if (selected) { - document.getSelection().removeAllRanges(); - document.getSelection().addRange(selected); - } - return true; - } catch(e) { - return false; - } - } - `) - - type state = - | Init - | Copied - | Failed - - @react.component - let make = (~code) => { - let (state, setState) = React.useState(_ => Init) - - let buttonRef = React.useRef(Nullable.null) - - let onClick = evt => { - ReactEvent.Mouse.preventDefault(evt) - if copyToClipboard(code) { - setState(_ => Copied) - } else { - setState(_ => Failed) - } - } - - React.useEffect(() => { - switch state { - | Copied => - let buttonEl = Nullable.toOption(buttonRef.current)->Option.getOrThrow - - // Note on this imperative DOM nonsense: - // For Tailwind transitions to behave correctly, we need to first paint the DOM element in the tree, - // and in the next tick, add the opacity-100 class, so the transition animation actually takes place. - // If we don't do that, the banner will essentially pop up without any animation - let bannerEl = WebAPI.Document.createElement(document, "div") - bannerEl.className = "opacity-0 absolute -top-6 right-0 -mt-5 -mr-4 px-4 py-2 w-40 rounded-lg captions text-white bg-gray-100 text-gray-80-tr transition-all duration-1000 ease-in-out " - - let textNode = WebAPI.Document.createTextNode(document, "Copied to clipboard") - - WebAPI.Element.appendChild(bannerEl, textNode)->ignore - WebAPI.Element.appendChild(buttonEl, bannerEl)->ignore - - let nextFrameId = WebAPI.Window.requestAnimationFrame(window, _ => { - WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-0")->ignore - WebAPI.DOMTokenList.toggle(bannerEl.classList, ~token="opacity-100")->ignore - }) - - let timeoutId = setTimeout(~handler=() => { - buttonEl->WebAPI.Element.removeChild(bannerEl)->ignore - setState(_ => Init) - }, ~timeout=3000) - - Some( - () => { - cancelAnimationFrame(nextFrameId) - clearTimeout(timeoutId) - }, - ) - | _ => None - } - }, [state]) - //Copy-Button - - } -} - @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 deleted file mode 100644 index 7f3655d02..000000000 --- a/apps/docs/src/data/MetaTagsApi.res +++ /dev/null @@ -1,98 +0,0 @@ -type t = { - title: option, - description: option, - image: option, -} - -/** - This function uses JSDOM to fetch a webpage and extract the meta tags from it. - JSDOM is required since this runs on Node. - */ -let extractMetaTags = async (url: string) => { - try { - let response = await fetch(url) - - let html = await response->WebAPI.Response.text - let dom = Jsdom.make(html) - let document = dom.window.document - - let nodeList = document->WebAPI.Document.querySelectorAll("meta") - - let elements = [] - - for i in 0 to nodeList.length { - let node = WebAPI.NodeList.item(nodeList, i) - // cast Node elements to Element - elements->Array.push((Obj.magic(node): WebAPI.DOMAPI.element)) - } - - let metaTags = elements->Array.reduce(Dict.fromArray([]), (tags, meta) => { - let name = meta->WebAPI.Element.getAttribute("name") - let property = meta->WebAPI.Element.getAttribute("property") - let itemprop = meta->WebAPI.Element.getAttribute("itemprop") - - let name = switch (name, property, itemprop) { - | (Value(name), _, _) => Some(name) - | (_, Value(property), _) => Some(property) - | (_, _, Value(itemprop)) => Some(itemprop) - | _ => None - } - - let content = meta->WebAPI.Element.getAttribute("content") - - switch (name, content) { - | (Some(name), Value(content)) => tags->Dict.set(name, content) - | _ => () - } - - tags - }) - - let title = metaTags->Dict.get("og:title") - let description = metaTags->Dict.get("og:description") - let image = metaTags->Dict.get("og:image") - - Some({ - title, - description, - image, - }) - } catch { - | _ => { - Console.error(`Error fetching Open Graph details for ${url}`) - None - } - } -} - -type tags = { - ...t, - url: string, -} - -/* - Pass an array of URLs and get back an array of meta tags for each URL. - */ -let getMetaTags = async (urls: array) => { - let metaTags: array = [] - for i in 0 to Array.length(urls) - 1 { - let url = urls[i] - switch url { - | Some(url) => { - let tags = await extractMetaTags(url) - switch tags { - | Some(tags) => - metaTags->Array.push({ - title: tags.title, - description: tags.description, - image: tags.image, - url, - }) - | None => () - } - } - | None => () - } - } - metaTags -} diff --git a/apps/docs/src/markdown/MdxLegacy.res b/apps/docs/src/markdown/MdxLegacy.res index 922232b7e..5ee546bd3 100644 --- a/apps/docs/src/markdown/MdxLegacy.res +++ b/apps/docs/src/markdown/MdxLegacy.res @@ -1,72 +1,35 @@ -/* - Abstract type for representing mdx - components mostly passed as children to - the component context API - */ -/** - * TODO: POST RR7 The code below is from Next's markdown, and I am not sure it is needed anymore. - */ -type mdxComponent - -external fromReactElement: React.element => mdxComponent = "%identity" - -external arrToReactElement: array => React.element = "%identity" - -let getMdxClassName: mdxComponent => option = %raw("element => { - if(element == null || element.props == null) { - return; - } - return element.props.className; - }") - -let getMdxType: mdxComponent => string = %raw("element => { - if(element == null || element.props == null) { - return 'unknown'; - } - return element.props.mdxType; - }") +type props = { + children?: React.element, + className?: string, + metastring?: string, + mdxType?: string, +} -module MdxChildren = { - type unknown +@get external props: React.element => props = "props" - type t +let getChildren = element => + React.isValidElement(element) ? props(element).children->Option.getOr(React.null) : element - type case = - | String(string) - | Element(mdxComponent) - | Array(array) - | Unknown(unknown) +let getClassName = element => React.isValidElement(element) ? props(element).className : None - let classify = (v: t): case => - if %raw(`function (a) { return a instanceof Array}`)(v) { - Array((Obj.magic(v): array)) - } else if typeof(v) == #string { - String((Obj.magic(v): string)) - } else if typeof(v) == #object { - Element((Obj.magic(v): mdxComponent)) - } else { - Unknown((Obj.magic(v): unknown)) - } +let getMetastring = element => React.isValidElement(element) ? props(element).metastring : None - external toReactElement: t => React.element = "%identity" +type component +@get external elementType: React.element => component = "type" - // Sometimes an mdxComponent element can be a string - // which means it doesn't have any children. - // We will return the element as its own child then - let getMdxChildren: mdxComponent => t = %raw("element => { - if(typeof element === 'string') { - return element; - } - if(element == null || element.props == null || element.props.children == null) { - return; +let getType = element => { + if React.isValidElement(element) { + switch props(element).mdxType { + | Some(tag) => tag + | None => { + let tag = elementType(element) + typeof(tag) == #string ? String.make(tag) : "unknown" } - return element.props.children; - }") + } + } else { + "unknown" + } } -module Components = { - // Used for reflection based logic in - // components such as `code` or `ul` - // with runtime reflection - type unknown -} +@get external component: React.element => React.componentLike<'props, React.element> = "type" +let isComponent = (element, make) => React.isValidElement(element) && component(element) === make diff --git a/apps/guide/__tests__/GuideHome_.test.res b/apps/guide/__tests__/GuideHome_.test.res index 5833174f4..220dc7bf3 100644 --- a/apps/guide/__tests__/GuideHome_.test.res +++ b/apps/guide/__tests__/GuideHome_.test.res @@ -5,7 +5,7 @@ let secondLesson = GuideTestFixtures.secondLesson let guideLessonsWithFinal = GuideTestFixtures.guideLessonsWithFinal let renderGuideHome = GuideTestFixtures.renderGuideHome let renderGuideHomeWithDocsIntroNavigation = GuideTestFixtures.renderGuideHomeWithDocsIntroNavigation -let renderGuideHomeInBrowser = GuideTestFixtures.renderGuideHomeInBrowser +let renderGuideHomeWithHistory = GuideTestFixtures.renderGuideHomeWithHistory test("loads saved guide editor code into the editor", async () => { await viewport(1440, 900) @@ -117,11 +117,6 @@ test("navigates to the function argument page", async () => { GuideLayout.clearExerciseCode(secondLesson.exercise.id) }) -let guideTestUrl = hash => window.location.pathname ++ window.location.search ++ hash - -let resetGuideTestUrl = () => - WebAPI.History.replaceState(window.history, ~data=JSON.Null, ~unused="", ~url=guideTestUrl("")) - test("shows Back before lesson forward actions and returns to the previous lesson", async () => { await viewport(1440, 900) GuideLayout.clearCompletedExercises() @@ -202,37 +197,23 @@ test("Done on a completed final lesson opens the ReScript docs intro", async () GuideLayout.clearExerciseCode(secondLesson.exercise.id) }) -test("browser back returns to the previous guide lesson", async () => { +test("history back returns to the previous guide lesson", async () => { await viewport(1440, 900) GuideLayout.clearCompletedExercises() GuideLayout.clearExerciseCode(firstLesson.exercise.id) GuideLayout.clearExerciseCode(secondLesson.exercise.id) - WebAPI.History.replaceState( - window.history, - ~data=JSON.Null, - ~unused="", - ~url=guideTestUrl("#guide-test-start"), - ) - WebAPI.History.pushState( - window.history, - ~data=JSON.Null, - ~unused="", - ~url=guideTestUrl("#first-contact"), - ) - - let screen = await renderGuideHomeInBrowser() + let screen = await renderGuideHomeWithHistory() await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible await (await screen->getByText("Next"))->click await (await screen->getByText("Call A Function"))->element->toBeVisible - WebAPI.History.back(window.history) + await (await screen->getByText("History back"))->click await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible GuideLayout.clearExerciseCode(firstLesson.exercise.id) GuideLayout.clearExerciseCode(secondLesson.exercise.id) - resetGuideTestUrl() }) test("stretches the output surface to the full output panel", async () => { diff --git a/apps/guide/__tests__/GuideTestFixtures.res b/apps/guide/__tests__/GuideTestFixtures.res index b3d199fb9..e92e5f7b4 100644 --- a/apps/guide/__tests__/GuideTestFixtures.res +++ b/apps/guide/__tests__/GuideTestFixtures.res @@ -71,9 +71,20 @@ let renderGuideHomeWithDocsIntroNavigation = (goToDocsIntro, ~initialEntries=["/ , ) -let renderGuideHomeInBrowser = () => +module HistoryBack = { + @module("react-router") external useNavigate: unit => int => unit = "useNavigate" + + @react.component + let make = () => { + let navigate = useNavigate() + + } +} + +let renderGuideHomeWithHistory = () => render( - + + - , + , ) diff --git a/apps/guide/app/GuideHome.res b/apps/guide/app/GuideHome.res index 250e6dbaf..1462ecde6 100644 --- a/apps/guide/app/GuideHome.res +++ b/apps/guide/app/GuideHome.res @@ -39,7 +39,7 @@ let make = (

{React.string(lesson.title)}

- lesson.content + lesson.content
React.element = "default" diff --git a/apps/guide/e2e/navigation.test.mjs b/apps/guide/e2e/navigation.test.mjs new file mode 100644 index 000000000..10a335aaf --- /dev/null +++ b/apps/guide/e2e/navigation.test.mjs @@ -0,0 +1,42 @@ +import { expect, test } from "playwright/test"; + +test("browser Back and Forward restore the guide lesson and saved code", async ({ + page, +}) => { + const savedCode = 'let greeting = "saved greeting"'; + await page.addInitScript((code) => { + localStorage.setItem( + "rescript-guide:v1:progress", + JSON.stringify(["first-contact/greeting"]), + ); + localStorage.setItem( + "rescript-guide:v1:exercise:first-contact/greeting", + code, + ); + }, savedCode); + + await page.goto("/#first-contact"); + await expect( + page.getByRole("heading", { name: "Learn ReScript Guide", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("guide-code-editor")).toContainText(savedCode); + + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page).toHaveURL(/#functions$/); + await expect( + page.getByRole("heading", { name: "Call A Function", exact: true }), + ).toBeVisible(); + + await page.goBack(); + await expect(page).toHaveURL(/#first-contact$/); + await expect( + page.getByRole("heading", { name: "Learn ReScript Guide", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("guide-code-editor")).toContainText(savedCode); + + await page.goForward(); + await expect(page).toHaveURL(/#functions$/); + await expect( + page.getByRole("heading", { name: "Call A Function", exact: true }), + ).toBeVisible(); +}); diff --git a/apps/guide/package.json b/apps/guide/package.json index eda0193b4..8cd420036 100644 --- a/apps/guide/package.json +++ b/apps/guide/package.json @@ -10,7 +10,8 @@ "build": "yarn build:res && yarn build:vite", "ci:test": "yarn build:res && yarn vitest --run --browser.headless", "dev:vite": "yarn build:res && vite --host", - "vitest": "vitest" + "vitest": "vitest", + "test:e2e": "yarn build && playwright test" }, "dependencies": { "@babel/generator": "^7.29.1", @@ -24,7 +25,6 @@ "isbot": "^5", "react": "^19.3.0", "react-dom": "^19.3.0", - "react-markdown": "^10.1.0", "react-router": "^8.3.1", "remark-comment": "^1.0.0", "remark-frontmatter": "^5.0.0", diff --git a/apps/guide/playwright.config.mjs b/apps/guide/playwright.config.mjs new file mode 100644 index 000000000..7740eee02 --- /dev/null +++ b/apps/guide/playwright.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from "playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + use: { + baseURL: "http://localhost:4174", + viewport: { width: 1440, height: 900 }, + trace: "retain-on-failure", + }, + webServer: { + command: + "yarn vite preview --outDir build/client --host localhost --port 4174 --strictPort", + url: "http://localhost:4174", + }, +}); diff --git a/packages/playground/src/EvalIFrame.res b/packages/playground/src/EvalIFrame.res index 3531edcf1..aaee8db3d 100644 --- a/packages/playground/src/EvalIFrame.res +++ b/packages/playground/src/EvalIFrame.res @@ -69,12 +69,14 @@ let srcDoc = ` ` +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..08a2cab58 100644 --- a/packages/playground/src/Playground.res +++ b/packages/playground/src/Playground.res @@ -605,17 +605,12 @@ 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) - 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 @@ -779,14 +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)) => - Some(() => scrollToElement(~parent, (Obj.magic(el): WebAPI.DOMAPI.htmlElement))) - | _ => 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 @@ -833,7 +828,6 @@ module WarningFlagsWidget = { let suggestionBox = Option.map(suggestions, elements =>
>))} 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 +900,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 +1191,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 +1283,7 @@ module ControlPanel = { - +
| _ => React.null } @@ -1524,7 +1455,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 +1470,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 +1734,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 +2126,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 +2156,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 +2164,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/packages/shared/src/Clipboard.res b/packages/shared/src/Clipboard.res new file mode 100644 index 000000000..af4355463 --- /dev/null +++ b/packages/shared/src/Clipboard.res @@ -0,0 +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 { + | _ => writeWithSelection(text) + } +} 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"