From c698c69be29e8f1b099683709b19d27a9b6515b8 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 13:47:40 -0400 Subject: [PATCH 1/6] perf(homepage): load DocSearch on demand Defer search implementation, insights, and CSS until activation while preserving keyboard navigation, focus restoration, and error recovery. Cover the production loading boundaries and record the lower initial asset byte budgets with three additional shared JavaScript requests. --- apps/docs/__tests__/Search_.test.res | 210 +++++--- apps/docs/app/DocsRoot.res | 4 - apps/docs/app/DocsRoot.resi | 3 +- .../e2e-playwright/homepage-search.spec.mjs | 218 +++++++++ apps/docs/src/components/Search.res | 457 +++--------------- apps/docs/src/components/Search.resi | 8 + .../src/components/SearchErrorBoundary.res | 5 + .../src/components/SearchErrorBoundary.resi | 2 + apps/docs/src/components/SearchHit.res | 27 ++ apps/docs/src/components/SearchHit.resi | 2 + apps/docs/src/components/SearchModal.res | 27 ++ apps/docs/src/components/SearchModal.resi | 7 + apps/docs/src/components/SearchNotice.res | 25 + apps/docs/src/components/SearchNotice.resi | 2 + apps/docs/src/components/SearchResults.res | 239 +++++++++ apps/docs/src/components/SearchResults.resi | 11 + apps/docs/styles/_docsearch.css | 3 +- apps/docs/styles/main.css | 1 - apps/docs/styles/search.css | 3 + 19 files changed, 796 insertions(+), 458 deletions(-) create mode 100644 apps/docs/e2e-playwright/homepage-search.spec.mjs create mode 100644 apps/docs/src/components/Search.resi create mode 100644 apps/docs/src/components/SearchErrorBoundary.res create mode 100644 apps/docs/src/components/SearchErrorBoundary.resi create mode 100644 apps/docs/src/components/SearchHit.res create mode 100644 apps/docs/src/components/SearchHit.resi create mode 100644 apps/docs/src/components/SearchModal.res create mode 100644 apps/docs/src/components/SearchModal.resi create mode 100644 apps/docs/src/components/SearchNotice.res create mode 100644 apps/docs/src/components/SearchNotice.resi create mode 100644 apps/docs/src/components/SearchResults.res create mode 100644 apps/docs/src/components/SearchResults.resi create mode 100644 apps/docs/styles/search.css diff --git a/apps/docs/__tests__/Search_.test.res b/apps/docs/__tests__/Search_.test.res index e481ff699..c27590892 100644 --- a/apps/docs/__tests__/Search_.test.res +++ b/apps/docs/__tests__/Search_.test.res @@ -54,26 +54,28 @@ module CurrentPath = { // --------------------------------------------------------------------------- test("markdownToHtml strips leading backslash + whitespace", async () => { - expect(Search.markdownToHtml("\\ hello"))->toBe("hello") + expect(SearchResults.markdownToHtml("\\ hello"))->toBe("hello") }) test("markdownToHtml replaces interior backslash + whitespace with a space", async () => { - expect(Search.markdownToHtml("foo\\ bar"))->toBe("foo bar") + expect(SearchResults.markdownToHtml("foo\\ bar"))->toBe("foo bar") }) test("markdownToHtml handles multiple interior backslashes", async () => { - expect(Search.markdownToHtml("a\\ b\\ c"))->toBe("a b c") + expect(SearchResults.markdownToHtml("a\\ b\\ c"))->toBe("a b c") }) test("markdownToHtml strips leading and replaces interior backslashes together", async () => { - expect(Search.markdownToHtml("\\ a\\ b"))->toBe("a b") + expect(SearchResults.markdownToHtml("\\ a\\ b"))->toBe("a b") }) test( "markdownToHtml removes an MDN reference with a markdown link and trailing period", async () => { expect( - Search.markdownToHtml("Some text. See [Array](https://developer.mozilla.org/array) on MDN."), + SearchResults.markdownToHtml( + "Some text. See [Array](https://developer.mozilla.org/array) on MDN.", + ), )->toBe("Some text.") }, ) @@ -82,105 +84,113 @@ test( "markdownToHtml removes an MDN reference with a markdown link without trailing period", async () => { expect( - Search.markdownToHtml("Some text. See [Array](https://developer.mozilla.org/array) on MDN"), + SearchResults.markdownToHtml( + "Some text. See [Array](https://developer.mozilla.org/array) on MDN", + ), )->toBe("Some text.") }, ) test("markdownToHtml removes an MDN plain URL reference with trailing period", async () => { - expect(Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN."))->toBe( - "Read more.", - ) + expect( + SearchResults.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN."), + )->toBe("Read more.") }) test("markdownToHtml removes an MDN plain URL reference without trailing period", async () => { - expect(Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN"))->toBe( - "Read more.", - ) + expect( + SearchResults.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN"), + )->toBe("Read more.") }) test("markdownToHtml converts a markdown link to plain text", async () => { - expect(Search.markdownToHtml("[click here](https://example.com)"))->toBe("click here") + expect(SearchResults.markdownToHtml("[click here](https://example.com)"))->toBe("click here") }) test("markdownToHtml converts multiple markdown links", async () => { - expect(Search.markdownToHtml("[foo](http://a.com) and [bar](http://b.com)"))->toBe("foo and bar") + expect(SearchResults.markdownToHtml("[foo](http://a.com) and [bar](http://b.com)"))->toBe( + "foo and bar", + ) }) test("markdownToHtml passes through a link with empty text", async () => { - expect(Search.markdownToHtml("[](https://example.com)"))->toBe("[](https://example.com)") + expect(SearchResults.markdownToHtml("[](https://example.com)"))->toBe("[](https://example.com)") }) test("markdownToHtml converts backtick code to tags", async () => { - expect(Search.markdownToHtml("`Array.map`"))->toBe("Array.map") + expect(SearchResults.markdownToHtml("`Array.map`"))->toBe("Array.map") }) test("markdownToHtml converts multiple backtick spans", async () => { - expect(Search.markdownToHtml("Use `map` and `filter`"))->toBe( + expect(SearchResults.markdownToHtml("Use `map` and `filter`"))->toBe( "Use map and filter", ) }) test("markdownToHtml converts **text** to tags", async () => { - expect(Search.markdownToHtml("**important**"))->toBe("important") + expect(SearchResults.markdownToHtml("**important**"))->toBe("important") }) test("markdownToHtml converts bold within a sentence", async () => { - expect(Search.markdownToHtml("This is **very** important"))->toBe( + expect(SearchResults.markdownToHtml("This is **very** important"))->toBe( "This is very important", ) }) test("markdownToHtml converts *text* to tags", async () => { - expect(Search.markdownToHtml("*emphasis*"))->toBe("emphasis") + expect(SearchResults.markdownToHtml("*emphasis*"))->toBe("emphasis") }) test("markdownToHtml converts italic within a sentence", async () => { - expect(Search.markdownToHtml("This is *quite* nice"))->toBe("This is quite nice") + expect(SearchResults.markdownToHtml("This is *quite* nice"))->toBe("This is quite nice") }) test("markdownToHtml converts double newline to
", async () => { - expect(Search.markdownToHtml("first\n\nsecond"))->toBe("first
second") + expect(SearchResults.markdownToHtml("first\n\nsecond"))->toBe("first
second") }) test("markdownToHtml converts triple+ newlines to a single
", async () => { - expect(Search.markdownToHtml("first\n\n\nsecond"))->toBe("first
second") + expect(SearchResults.markdownToHtml("first\n\n\nsecond"))->toBe("first
second") }) test("markdownToHtml converts single newline to a space", async () => { - expect(Search.markdownToHtml("first\nsecond"))->toBe("first second") + expect(SearchResults.markdownToHtml("first\nsecond"))->toBe("first second") }) test("markdownToHtml trims leading whitespace", async () => { - expect(Search.markdownToHtml(" hello"))->toBe("hello") + expect(SearchResults.markdownToHtml(" hello"))->toBe("hello") }) test("markdownToHtml trims trailing whitespace", async () => { - expect(Search.markdownToHtml("hello "))->toBe("hello") + expect(SearchResults.markdownToHtml("hello "))->toBe("hello") }) test("markdownToHtml trims both sides", async () => { - expect(Search.markdownToHtml(" hello "))->toBe("hello") + expect(SearchResults.markdownToHtml(" hello "))->toBe("hello") }) test("markdownToHtml handles empty string", async () => { - expect(Search.markdownToHtml(""))->toBe("") + expect(SearchResults.markdownToHtml(""))->toBe("") }) test("markdownToHtml passes plain text through unchanged", async () => { - expect(Search.markdownToHtml("just plain text"))->toBe("just plain text") + expect(SearchResults.markdownToHtml("just plain text"))->toBe("just plain text") }) test("markdownToHtml applies multiple transformations together", async () => { expect( - Search.markdownToHtml("Use `map` on **arrays**.\n\nSee [docs](http://x.com) for *details*."), + SearchResults.markdownToHtml( + "Use `map` on **arrays**.\n\nSee [docs](http://x.com) for *details*.", + ), )->toBe("Use map on arrays.
See docs for details.") }) test( "markdownToHtml still converts bold inside code because regexes run sequentially", async () => { - expect(Search.markdownToHtml("`**notbold**`"))->toBe("notbold") + expect(SearchResults.markdownToHtml("`**notbold**`"))->toBe( + "notbold", + ) }, ) @@ -200,7 +210,7 @@ test("getHighlightedTitle renders crawler API titles as value names", async () = }, } - expect(Search.getHighlightedTitle(hit))->toBe("mapWithIndex") + expect(SearchResults.getHighlightedTitle(hit))->toBe("mapWithIndex") }) test( @@ -220,7 +230,7 @@ test( _highlightResult: {hierarchy: Nullable.make(highlightedHierarchy)}, } - expect(Search.getHighlightedTitle(hit))->toBe("Section title") + expect(SearchResults.getHighlightedTitle(hit))->toBe("Section title") }, ) @@ -233,7 +243,7 @@ test("getContentHtml prefers crawler snippet markup over plain content", async ( }, } - expect(Search.getContentHtml(hit))->toEqual( + expect(SearchResults.getContentHtml(hit))->toEqual( Some("map(array, fn) returns a new array."), ) }) @@ -246,7 +256,7 @@ test("hitComponent routes relative hit URLs through React Router", async () => { let screen = await render( - {Search.hitComponent({hit, children: React.null})} + , ) @@ -281,24 +291,36 @@ test( ]), ) - expect(Search.getContentHtml(hit))->toEqual(Some("map(array, fn) returns a new array.")) + expect(SearchResults.getContentHtml(hit))->toEqual(Some("map(array, fn) returns a new array.")) }, ) test("search error boundary catches render errors without replacing surrounding page", async () => { await viewport(1440, 500) + let closed = ref(false) let screen = await render(
{React.string("Docs page stays rendered")} - ()}> + closed := true}> - +
, ) await element(await screen->getByText("Docs page stays rendered"))->toBeVisible await element(await screen->getByText("Search unavailable"))->toBeVisible + await (await screen->getByLabelText("Close search"))->click + expect(closed.contents)->toBe(true) +}) + +test("loading search can be canceled before the modal is available", async () => { + let closed = ref(false) + let screen = await render( closed := true} />) + + await element(await screen->getByText("Loading search"))->toBeVisible + await (await screen->getByLabelText("Close search"))->click + expect(closed.contents)->toBe(true) }) // --------------------------------------------------------------------------- @@ -306,77 +328,126 @@ test("search error boundary catches render errors without replacing surrounding // --------------------------------------------------------------------------- test("isChildHit treats Lvl2 as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/page")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/page")))->toBe( + true, + ) +}) + +test("search subtitles omit top-level, missing, and empty parent titles", async () => { + let topLevel = makeHit(~type_=Lvl1, ~url="/docs/manual") + let child = makeHit(~type_=Lvl2, ~url="/docs/manual#child") + + expect(SearchResults.getSubtitle({...topLevel, type_: Lvl0}))->toEqual(None) + expect(SearchResults.getSubtitle(topLevel))->toEqual(None) + expect(SearchResults.getSubtitle(child))->toEqual(Some("Test Page")) + expect( + SearchResults.getSubtitle({...child, hierarchy: {...child.hierarchy, lvl1: Nullable.null}}), + )->toEqual(None) + expect( + SearchResults.getSubtitle({ + ...child, + hierarchy: {...child.hierarchy, lvl1: Nullable.make("")}, + }), + )->toEqual(None) +}) + +test("search state opens once and closes from active or inactive", async () => { + let modal = SearchModal.make + let anotherModal = React.lazy_(() => import(SearchModal.make)) + let active = Search.reduce(Inactive, Open(modal)) + + expect(active)->toEqual(Search.Active(modal)) + expect(Search.reduce(active, Open(anotherModal)))->toEqual(active) + expect(Search.reduce(active, Close))->toEqual(Search.Inactive) + expect(Search.reduce(Inactive, Close))->toEqual(Search.Inactive) }) test("isChildHit treats Lvl3 as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl3, ~url="https://example.com/page")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl3, ~url="https://example.com/page")))->toBe( + true, + ) }) test("isChildHit treats Lvl4 as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl4, ~url="https://example.com/page")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl4, ~url="https://example.com/page")))->toBe( + true, + ) }) test("isChildHit treats Lvl5 as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl5, ~url="https://example.com/page")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl5, ~url="https://example.com/page")))->toBe( + true, + ) }) test("isChildHit treats Lvl6 as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl6, ~url="https://example.com/page")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl6, ~url="https://example.com/page")))->toBe( + true, + ) }) test("isChildHit treats Content as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page")))->toBe( + true, + ) }) test("isChildHit treats Lvl2 as a child hit even without a hash in the URL", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/no-hash")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/no-hash")))->toBe( + true, + ) }) test("isChildHit treats Content as a child hit even with a hash in the URL", async () => { - expect(Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page#section")))->toBe( - true, - ) + expect( + SearchResults.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page#section")), + )->toBe(true) }) test("isChildHit treats Lvl0 without a hash as not a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page")))->toBe(false) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page")))->toBe( + false, + ) }) test("isChildHit treats Lvl0 with a hash as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#section")))->toBe( - true, - ) + expect( + SearchResults.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#section")), + )->toBe(true) }) test("isChildHit treats Lvl0 with a trailing # as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#")))->toBe(true) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#")))->toBe( + true, + ) }) test("isChildHit treats Lvl1 without a hash as not a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page")))->toBe(false) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page")))->toBe( + false, + ) }) test("isChildHit treats Lvl1 with a hash as a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page#heading")))->toBe( - true, - ) + expect( + SearchResults.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page#heading")), + )->toBe(true) }) test("isChildHit treats Lvl1 with a deeply nested hash anchor as a child hit", async () => { expect( - Search.isChildHit( + SearchResults.isChildHit( makeHit(~type_=Lvl1, ~url="https://example.com/docs/manual/api#some-section"), ), )->toBe(true) }) test("isChildHit treats Lvl1 with an empty URL as not a child hit", async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="")))->toBe(false) + expect(SearchResults.isChildHit(makeHit(~type_=Lvl1, ~url="")))->toBe(false) }) test("toRelativeSiteUrl strips the site origin from an absolute URL", async () => { - let result = Search.toRelativeSiteUrl( + let result = SearchResults.toRelativeSiteUrl( "https://rescript-lang.org/docs/manual/introduction#what-is-rescript", ~siteUrl="https://rescript-lang.org/", ) @@ -385,7 +456,7 @@ test("toRelativeSiteUrl strips the site origin from an absolute URL", async () = }) test("toRelativeSiteUrl leaves absolute URLs unchanged when siteUrl is empty", async () => { - let result = Search.toRelativeSiteUrl( + let result = SearchResults.toRelativeSiteUrl( "https://rescript-lang.org/docs/manual/introduction#what-is-rescript", ~siteUrl="", ) @@ -398,7 +469,7 @@ test("normalizeHitUrls rewrites absolute site URLs to relative paths", async () ~type_=Lvl1, ~url="https://rescript-lang.org/docs/manual/typescript-integration#gentype", ) - let result = Search.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/") + let result = SearchResults.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/") expect(result[0]->Option.map(hit => hit.url))->toEqual( Some("/docs/manual/typescript-integration#gentype"), @@ -432,7 +503,7 @@ test("normalizeHitUrls tolerates crawler hits without url_without_anchor", async ]), ) - let result = Search.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/") + let result = SearchResults.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/") expect(result[0]->Option.map(hit => hit.url))->toEqual( Some("/docs/manual/api/stdlib/array/#value-map"), @@ -466,7 +537,10 @@ test("normalizeHitUrls keeps API hit order while separating Belt group labels", ~lvl1="map", ) - let result = Search.normalizeHitUrls([beltHit, stdlibHit], ~siteUrl="https://rescript-lang.org/") + let result = SearchResults.normalizeHitUrls( + [beltHit, stdlibHit], + ~siteUrl="https://rescript-lang.org/", + ) expect(result[0]->Option.map(hit => hit.objectID))->toEqual(Some("belt-array-map")) expect(result[0]->Option.flatMap(hit => hit.hierarchy.lvl0->Nullable.toOption))->toEqual( @@ -492,12 +566,8 @@ test("active DocSearch enables Algolia Insights", async () => { let _screen = await render( - ()} - onClose={() => ()} + ()} /> , ) diff --git a/apps/docs/app/DocsRoot.res b/apps/docs/app/DocsRoot.res index 40b19e1e3..f8b6002d3 100644 --- a/apps/docs/app/DocsRoot.res +++ b/apps/docs/app/DocsRoot.res @@ -4,9 +4,6 @@ external mainCss: string = "default" @module("../styles/_hljs.css?url") external hljsCss: string = "default" -@module("../styles/utils.css?url") -external utilsCss: string = "default" - %%raw(` import hljs from 'highlight.js/lib/core'; import bash from 'highlight.js/lib/languages/bash'; @@ -45,7 +42,6 @@ let default = () => { - diff --git a/apps/docs/app/DocsRoot.resi b/apps/docs/app/DocsRoot.resi index 8e83f0042..1ddeb3433 100644 --- a/apps/docs/app/DocsRoot.resi +++ b/apps/docs/app/DocsRoot.resi @@ -1,3 +1,4 @@ -/** Includes the stable Cypress bootstrap slot for document hydration tests. */ +/** Shared shell styles and the stable Cypress bootstrap slot. +Feature-specific styles load with their components. */ @react.component let default: unit => Jsx.element diff --git a/apps/docs/e2e-playwright/homepage-search.spec.mjs b/apps/docs/e2e-playwright/homepage-search.spec.mjs new file mode 100644 index 000000000..af3b872a2 --- /dev/null +++ b/apps/docs/e2e-playwright/homepage-search.spec.mjs @@ -0,0 +1,218 @@ +import { expect, test } from "playwright/test"; +import { JSDOM } from "jsdom"; + +const searchChunk = /\/assets\/SearchModal-[^/]+\.js$/; + +test("initial homepage assets exclude the search implementation and styles", async ({ + request, +}) => { + const response = await request.get("/"); + const { document } = new JSDOM(await response.text()).window; + const initialScripts = [ + ...document.querySelectorAll('link[rel="modulepreload"][href]'), + ...document.querySelectorAll("script[src]"), + ].map( + (element) => element.getAttribute("href") ?? element.getAttribute("src"), + ); + const initialStyles = [ + ...document.querySelectorAll('link[rel="stylesheet"][href]'), + ].map((element) => element.getAttribute("href")); + + expect(response.ok()).toBe(true); + expect(initialScripts.length).toBeGreaterThan(0); + expect(initialStyles.length).toBeGreaterThan(0); + for (const asset of initialScripts) { + const script = await request.get(asset); + expect(script.ok()).toBe(true); + const source = await script.text(); + expect(source.includes("search-insights"), asset).toBe(false); + expect(source.includes("DocSearch-Modal"), asset).toBe(false); + } + for (const asset of initialStyles) { + const stylesheet = await request.get(asset); + expect(stylesheet.ok()).toBe(true); + expect((await stylesheet.text()).includes(".DocSearch-Modal"), asset).toBe( + false, + ); + } +}); + +test("search loads on activation, stays styled, and supports keyboard reopening", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + const insightsRequests = []; + const searchRequests = []; + page.on("request", (request) => { + if (request.url().includes("search-insights")) { + insightsRequests.push(request.url()); + } + if (searchChunk.test(request.url())) { + searchRequests.push(request.url()); + } + }); + await page.goto("/"); + const search = page.getByRole("button", { name: "Search", exact: true }); + const input = page.getByPlaceholder("Search docs", { exact: true }); + + await expect(search).toBeVisible(); + await expect(input).toHaveCount(0); + expect(insightsRequests).toEqual([]); + expect(searchRequests).toEqual([]); + await search.click(); + await expect(input).toBeFocused(); + expect(searchRequests.length).toBeGreaterThan(0); + await expect(page.locator(".DocSearch-Container")).toHaveCSS( + "position", + "fixed", + ); + await expect(page.locator(".DocSearch-Modal")).toHaveCSS("opacity", "1"); + await expect(page.locator(".DocSearch-Modal")).toHaveCSS( + "max-width", + "768px", + ); + await input.press("Escape"); + await expect(input).toHaveCount(0); + + await page.keyboard.press("/"); + await expect(input).toBeFocused(); + await input.press("/"); + await expect(input).toHaveValue("/"); + await input.press("Escape"); + await expect(input).toHaveValue(""); + await expect(input).toBeFocused(); + await input.press("Escape"); + await expect(input).toHaveCount(0); + await page.keyboard.press("Control+k"); + await expect(input).toBeFocused(); + await input.press("Escape"); + await expect(input).toHaveCount(0); +}); + +test("lazy search results navigate into documentation", async ({ page }) => { + await page.route( + (url) => + (url.hostname.endsWith(".algolia.net") || + url.hostname.endsWith(".algolianet.com")) && + url.pathname.endsWith("/queries"), + async (route) => { + await route.fulfill({ + json: { + results: [ + { + hits: [ + { + objectID: "installation", + url: "https://rescript-lang.org/docs/manual/installation", + url_without_anchor: + "https://rescript-lang.org/docs/manual/installation", + type: "lvl1", + anchor: null, + content: null, + hierarchy: { + lvl0: "ReScript", + lvl1: "Installation", + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + }, + ], + nbHits: 1, + page: 0, + nbPages: 1, + hitsPerPage: 20, + processingTimeMS: 1, + query: "installation", + index: "test-index", + queryID: "homepage-search-test", + }, + ], + }, + }); + }, + ); + await page.goto("/"); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await page + .getByPlaceholder("Search docs", { exact: true }) + .fill("installation"); + await page + .locator(".DocSearch-Modal") + .getByRole("link", { name: "Installation", exact: true }) + .click(); + + await expect(page).toHaveURL(/\/docs\/manual\/installation$/); + await expect( + page.getByRole("heading", { name: "Installation", level: 1, exact: true }), + ).toBeVisible(); + await expect( + page.getByPlaceholder("Search docs", { exact: true }), + ).toHaveCount(0); +}); + +test("a pending search load can be closed without opening the modal afterward", async ({ + page, +}) => { + const download = Promise.withResolvers(); + await page.route(searchChunk, async (route) => { + await download.promise; + await route.continue(); + }); + await page.goto("/"); + const search = page.getByRole("button", { name: "Search", exact: true }); + const response = page.waitForResponse(searchChunk); + + try { + await search.click(); + await expect( + page.getByRole("button", { name: "Close search" }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect( + page.getByRole("button", { name: "Close search" }), + ).toHaveCount(0); + await expect(search).toBeFocused(); + } finally { + download.resolve(); + await page.unrouteAll({ behavior: "wait" }); + } + await (await response).finished(); + await expect(search).toBeFocused(); + + await expect( + page.getByPlaceholder("Search docs", { exact: true }), + ).toHaveCount(0); + await search.click(); + await expect( + page.getByPlaceholder("Search docs", { exact: true }), + ).toBeFocused(); +}); + +test("a failed search chunk leaves the page usable and recovers after a reload", async ({ + page, +}) => { + await page.route(searchChunk, (route) => route.abort()); + await page.goto("/"); + const search = page.getByRole("button", { name: "Search", exact: true }); + await search.click(); + + await expect(page.getByRole("alert")).toContainText("Search unavailable"); + await page.getByRole("button", { name: "Close search" }).click(); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(search).toBeFocused(); + await expect( + page.getByRole("heading", { + level: 1, + name: "JavaScript Made Simple for Humans and AI", + }), + ).toBeVisible(); + await page.unrouteAll({ behavior: "wait" }); + await page.reload(); + await search.click(); + await expect( + page.getByPlaceholder("Search docs", { exact: true }), + ).toBeFocused(); +}); diff --git a/apps/docs/src/components/Search.res b/apps/docs/src/components/Search.res index f650a1d4b..4669666eb 100644 --- a/apps/docs/src/components/Search.res +++ b/apps/docs/src/components/Search.res @@ -1,412 +1,95 @@ -type state = Active | Inactive +type modal = React.component unit>> +type state = Inactive | Active(modal) +type action = Open(modal) | Close -let unavailableText = "Search unavailable" -let unavailableLabel = "Search unavailable for this build" - -let toRelativeSiteUrl = (url: string, ~siteUrl: string): string => { - let normalizedSiteUrl = siteUrl->String.replaceRegExp(RegExp.fromString("/+$", ~flags=""), "") - if normalizedSiteUrl !== "" && String.startsWith(url, normalizedSiteUrl) { - let relativePath = String.slice(url, ~start=String.length(normalizedSiteUrl)) - if relativePath === "" { - "/" - } else if String.startsWith(relativePath, "/") { - relativePath - } else { - "/" ++ relativePath - } - } else { - url - } -} - -type apiNamespace = StdlibApi | BeltApi - -let apiNamespaceForUrl = (url: string): option => - if url->String.includes("/docs/manual/api/stdlib/") { - Some(StdlibApi) - } else if url->String.includes("/docs/manual/api/belt/") { - Some(BeltApi) - } else { - None - } - -let stripCaseInsensitivePrefix = (value: string, prefix: string): string => { - if ( - prefix->String.length > 0 && - value->String.toLowerCase->String.startsWith(prefix->String.toLowerCase) - ) { - String.slice(value, ~start=String.length(prefix)) - } else { - value - } -} - -let baseApiModuleName = (moduleName: string): string => - moduleName->stripCaseInsensitivePrefix("Stdlib.")->stripCaseInsensitivePrefix("Belt.") - -let apiGroupName = (hit: DocSearch.docSearchHit): option => - switch (apiNamespaceForUrl(hit.url), hit.hierarchy.lvl0->Nullable.toOption) { - | (Some(StdlibApi), Some(moduleName)) if moduleName !== "" => - Some(moduleName->stripCaseInsensitivePrefix("Stdlib.")) - | (Some(BeltApi), Some(moduleName)) if moduleName !== "" => - Some(`Belt.${moduleName->baseApiModuleName}`) - | _ => None - } - -let normalizeHitUrls = (items: array, ~siteUrl: string) => - items->Array.map(hit => { - let url = toRelativeSiteUrl(hit.url, ~siteUrl) - let urlWithoutAnchor = - hit.url_without_anchor - ->Nullable.toOption - ->Option.getOr(hit.url->String.split("#")->Array.get(0)->Option.getOr(hit.url)) - let url_without_anchor = toRelativeSiteUrl(urlWithoutAnchor, ~siteUrl)->Nullable.make - let hierarchy = switch hit->apiGroupName { - | Some(lvl0) => {...hit.hierarchy, lvl0: Nullable.make(lvl0)} - | None => hit.hierarchy - } - {...hit, url, url_without_anchor, hierarchy} - }) - -let navigator = (~siteUrl: string, ~navigate: ReactRouter.navigate): DocSearch.navigator => { - navigate: ({itemUrl}) => { - navigate(toRelativeSiteUrl(itemUrl, ~siteUrl)) - }, -} - -let getSubtitle: DocSearch.docSearchHit => option = %raw(` - function(hit) { - var type = hit.type; - if (type && type !== 'lvl1' && type !== 'lvl0') { - var raw = hit.hierarchy; - if (raw && raw.lvl1) return raw.lvl1; - } - return undefined; +let reduce = (state, action) => + switch (state, action) { + | (Inactive, Open(modal)) => Active(modal) + | (Active(_), Open(_)) => state + | (_, Close) => Inactive } -`) -let highlightedValue = (value: Nullable.t): option => - value->Nullable.toOption->Option.map(value => value.value) - -let highlightedValueWithMarkup = (value: Nullable.t): option => - switch highlightedValue(value) { - | Some(value) if value->String.includes("") => Some(value) - | _ => None - } - -let highlightedHierarchyValue = ( - hierarchy: DocSearch.highlightedHierarchy, - type_: DocSearch.contentType, -): option => - switch type_ { - | Lvl0 => hierarchy.lvl0->highlightedValue - | Lvl1 => hierarchy.lvl1->highlightedValue - | Lvl2 => hierarchy.lvl2->highlightedValue - | Lvl3 => hierarchy.lvl3->highlightedValue - | Lvl4 => hierarchy.lvl4->highlightedValue - | Lvl5 => hierarchy.lvl5->highlightedValue - | Lvl6 => hierarchy.lvl6->highlightedValue - | Content => None - } - -let highlightedHierarchyValueWithMarkup = ( - hierarchy: DocSearch.highlightedHierarchy, - type_: DocSearch.contentType, -): option => - switch type_ { - | Lvl0 => hierarchy.lvl0->highlightedValueWithMarkup - | Lvl1 => hierarchy.lvl1->highlightedValueWithMarkup - | Lvl2 => hierarchy.lvl2->highlightedValueWithMarkup - | Lvl3 => hierarchy.lvl3->highlightedValueWithMarkup - | Lvl4 => hierarchy.lvl4->highlightedValueWithMarkup - | Lvl5 => hierarchy.lvl5->highlightedValueWithMarkup - | Lvl6 => hierarchy.lvl6->highlightedValueWithMarkup - | Content => None - } - -let firstMarkedText = (html: string): option => { - switch RegExp.exec(/([^<]+)<\/mark>/, html) { - | Some(result) => - let matches = RegExp.Result.matches(result) - switch matches[0] { - | Some(Some(markedText)) => Some(markedText) - | _ => None - } - | None => None - } -} - -let markTitlePrefix = (title: string, markedText: string): string => { - let markedLength = String.length(markedText) - if ( - markedLength > 0 && title->String.toLowerCase->String.startsWith(markedText->String.toLowerCase) - ) { - let prefix = String.slice(title, ~start=0, ~end=markedLength) - let suffix = String.slice(title, ~start=markedLength) - `${prefix}${suffix}` - } else { - title - } -} - -let stripApiModulePrefix = (markedText: string, moduleName: string): string => { - let moduleName = moduleName->baseApiModuleName - markedText - ->stripCaseInsensitivePrefix(`Stdlib.${moduleName}.`) - ->stripCaseInsensitivePrefix(`Belt.${moduleName}.`) - ->stripCaseInsensitivePrefix(`${moduleName}.`) -} - -let getSnippetContent = (hit: DocSearch.docSearchHit): option => - switch hit._snippetResult { - | Some(snippetResult) => snippetResult.content->highlightedValue - | None => None - } - -let getApiTitle = (hit: DocSearch.docSearchHit): option => { - if hit.url->String.includes("/docs/manual/api/") { - switch (hit.hierarchy.lvl0->Nullable.toOption, hit.hierarchy.lvl1->Nullable.toOption) { - | (Some(moduleName), Some(valueName)) if moduleName !== "" && valueName !== "" => - let title = valueName - switch hit->getSnippetContent->Option.flatMap(firstMarkedText) { - | Some(markedText) => - Some(markTitlePrefix(title, stripApiModulePrefix(markedText, moduleName))) - | None => Some(title) - } - | _ => None - } - } else { - None - } -} - -let getHighlightedTitle = (hit: DocSearch.docSearchHit): string => { - let highlightedHierarchy = - hit._highlightResult->Option.flatMap(highlightResult => - highlightResult.hierarchy->Nullable.toOption - ) - let highlightedTitleWithMarkup = highlightedHierarchy->Option.flatMap(hierarchy => - switch hit.type_ { - | Lvl0 | Lvl1 => None - | _ => highlightedHierarchyValueWithMarkup(hierarchy, hit.type_) - } - ) - - switch highlightedTitleWithMarkup { - | Some(title) => title - | None => - switch highlightedHierarchy->Option.flatMap(hierarchy => - hierarchy.lvl1->highlightedValueWithMarkup - ) { - | Some(title) => title - | None => - switch getApiTitle(hit) { - | Some(title) => title - | None => - switch highlightedHierarchy->Option.flatMap(hierarchy => - highlightedHierarchyValue(hierarchy, hit.type_) - ) { - | Some(title) => title - | None => hit.hierarchy.lvl1->Nullable.toOption->Option.getOr("") - } - } - } - } -} +let unavailableText = "Search unavailable" +let unavailableLabel = "Search unavailable for this build" -let markdownToHtml = (text: string): string => - text - // Strip stray backslashes from MDX processing - ->String.replaceRegExp(RegExp.fromString("^\\\\\\s+", ~flags=""), "") - ->String.replaceRegExp(RegExp.fromString("\\\\\\s+", ~flags="g"), " ") - ->String.replaceRegExp( - RegExp.fromString("See\\s+\\[([^\\]]+)\\]\\([^)]*\\)\\s+on MDN\\.?", ~flags="g"), - "", - ) - ->String.replaceRegExp(RegExp.fromString("See\\s+\\S+\\s+on MDN\\.?", ~flags="g"), "") - ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") - ->String.replaceRegExp(RegExp.fromString("\\x60([^\\x60]+)\\x60", ~flags="g"), "$1") - ->String.replaceRegExp( - RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), - "$1", - ) - ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") - ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), "
") - ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") - ->String.trim +@get external isContentEditable: WebAPI.DOMAPI.element => option = "isContentEditable" +@get external focusMethod: WebAPI.DOMAPI.element => option unit> = "focus" +@get external inputValue: WebAPI.DOMAPI.element => option = "value" +@send external focusElement: WebAPI.DOMAPI.element => unit = "focus" -let isChildHit = (hit: DocSearch.docSearchHit) => - switch hit.type_ { - | Lvl2 | Lvl3 | Lvl4 | Lvl5 | Lvl6 | Content => true - | Lvl0 | Lvl1 => hit.url->String.includes("#") +let isEditable = (element: WebAPI.DOMAPI.element) => + switch element.tagName { + | "TEXTAREA" | "SELECT" | "INPUT" => true + | _ => element->isContentEditable->Option.getOr(false) } -let getContentHtml = (hit: DocSearch.docSearchHit): option => - switch getSnippetContent(hit) { - | Some(content) => Some(content->markdownToHtml) - | None => hit.content->Nullable.toOption->Option.map(markdownToHtml) +let restoreFocus = element => + switch element->focusMethod { + | Some(_) => element->focusElement + | None => () } -let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element => { - let titleHtml = getHighlightedTitle(hit) - let subtitle = getSubtitle(hit) - let contentHtml = getContentHtml(hit) - let isChild = isChildHit(hit) - - -
- {isChild ? : React.null} - {isChild ? : } -
- - {switch subtitle { - | Some(s) => {React.string(s)} - | None => React.null - }} - {switch contentHtml { - | Some(c) if String.length(c) > 0 => - - | _ => React.null - }} -
- -
-
-} - -module ErrorBoundary = { - @react.component - let make = (~children: React.element, ~onClose: unit => unit) => { - -
- - {React.string(unavailableText)} - - -
} - > - children -
+let hasSearchQuery = () => + switch document.activeElement { + | Value(element) => + WebAPI.DOMTokenList.contains(element.classList, "DocSearch-Input") && + element->inputValue->Option.map(value => value !== "")->Option.getOr(false) + | Null => false } -} - -module ActiveDocSearch = { - @react.component - let make = ( - ~apiKey, - ~appId, - ~indexName, - ~deactivateSearch: unit => unit, - ~onClose: unit => unit, - ) => { - let navigate = ReactRouter.useNavigate() - switch ReactDOM.querySelector("body") { - | Some(element) => - ReactDOM.createPortal( - - normalizeHitUrls(items, ~siteUrl=Env.root_url)} - hitComponent - onClose - insights=true - initialScrollY={window.scrollY->Float.toInt} - searchParameters={ - distinct: 3, - hitsPerPage: 20, - attributesToSnippet: ["content:9999"], - } - /> - , - element, - ) - | None => React.null - } +let activateSearch = (~dispatch, ~returnFocus: React.ref>) => { + if returnFocus.current->Option.isNone { + returnFocus.current = document.activeElement->Null.toOption } + dispatch(Open(React.lazy_(() => import(SearchModal.make)))) } @react.component let make = () => { - let (state, setState) = React.useState(_ => Inactive) + let (state, dispatch) = React.useReducer(reduce, Inactive) + let returnFocus = React.useRef(None) let algoliaConfig = Env.algoliaPublicConfig - let deactivateSearch = () => { + let onClose = React.useCallback(() => { switch WebAPI.Document.querySelector(document, "body") { | Value(body) => WebAPI.DOMTokenList.remove(body.classList, "DocSearch--active") | Null => () } - setState(_ => Inactive) - } - - let handleCloseModal = () => { - let () = switch WebAPI.Document.querySelector(document, ".DocSearch-Modal") { - | Value(modal) => - switch WebAPI.Document.querySelector(document, "body") { - | Value(body) => - WebAPI.DOMTokenList.remove(body.classList, "DocSearch--active") - modal->WebAPI.Element.addEventListener(Transitionend, () => { - setState(_ => Inactive) - }) - | Null => setState(_ => Inactive) - } - | Null => deactivateSearch() - } - } + dispatch(Close) + returnFocus.current->Option.forEach(restoreFocus) + returnFocus.current = None + }, [dispatch]) + // Synchronize the document-wide shortcuts with search availability. React.useEffect(() => { switch algoliaConfig { | None => None | Some(_) => - let isEditableTag = (el: WebAPI.DOMAPI.element) => - switch el.tagName { - | "TEXTAREA" | "SELECT" | "INPUT" => true - | _ => false - } - - let focusSearch = (e: WebAPI.UIEventsAPI.keyboardEvent) => { - switch document.activeElement { - | Value(el) - if el->isEditableTag || (Obj.magic(el): WebAPI.DOMAPI.htmlElement).isContentEditable => () - | _ => - setState(_ => Active) - WebAPI.KeyboardEvent.preventDefault(e) + let handleGlobalKeyDown = (event: WebAPI.UIEventsAPI.keyboardEvent) => { + if event.key === "Escape" && !hasSearchQuery() { + onClose() + } else if event.key === "/" || (event.key === "k" && (event.ctrlKey || event.metaKey)) { + switch document.activeElement { + | Value(element) if isEditable(element) => () + | _ => + activateSearch(~dispatch, ~returnFocus) + WebAPI.KeyboardEvent.preventDefault(event) + } } } - - let handleGlobalKeyDown = (e: WebAPI.UIEventsAPI.keyboardEvent) => { - switch e.key { - | "/" => focusSearch(e) - | "k" if e.ctrlKey || e.metaKey => focusSearch(e) - | _ => () - } - } - WebAPI.Window.addEventListener(window, Keydown, handleGlobalKeyDown) - Some(() => WebAPI.Window.removeEventListener(window, Keydown, handleGlobalKeyDown)) + // Read Escape's query before autocomplete clears it at the input. + WebAPI.Window.addEventListener(window, Keydown, handleGlobalKeyDown, ~options={capture: true}) + Some( + () => + WebAPI.Window.removeEventListener( + window, + Keydown, + handleGlobalKeyDown, + ~options={capture: true}, + ), + ) } - }, [algoliaConfig]) - - let onClick = _ => { - setState(_ => Active) - } - - let onClose = React.useCallback(() => { - handleCloseModal() - }, [setState]) + }, (algoliaConfig, onClose, dispatch)) switch algoliaConfig { | None => @@ -423,7 +106,7 @@ let make = () => { | Some({appId, indexName, searchApiKey}) => <> {switch state { - | Active => + | Active(modal) => + switch ReactDOM.querySelector("body") { + | Some(body) => + ReactDOM.createPortal( + + }> + {React.createElement(modal, {apiKey: searchApiKey, appId, indexName, onClose})} + + , + body, + ) + | None => React.null + } | Inactive => React.null }} diff --git a/apps/docs/src/components/Search.resi b/apps/docs/src/components/Search.resi new file mode 100644 index 000000000..0ab0b4560 --- /dev/null +++ b/apps/docs/src/components/Search.resi @@ -0,0 +1,8 @@ +type modal = React.component unit>> +type state = Inactive | Active(modal) +type action = Open(modal) | Close + +let reduce: (state, action) => state + +@react.component +let make: unit => React.element diff --git a/apps/docs/src/components/SearchErrorBoundary.res b/apps/docs/src/components/SearchErrorBoundary.res new file mode 100644 index 000000000..24d082796 --- /dev/null +++ b/apps/docs/src/components/SearchErrorBoundary.res @@ -0,0 +1,5 @@ +@react.component +let make = (~children: React.element, ~onClose: unit => unit) => + }> + children + diff --git a/apps/docs/src/components/SearchErrorBoundary.resi b/apps/docs/src/components/SearchErrorBoundary.resi new file mode 100644 index 000000000..bd8a96c36 --- /dev/null +++ b/apps/docs/src/components/SearchErrorBoundary.resi @@ -0,0 +1,2 @@ +@react.component +let make: (~children: React.element, ~onClose: unit => unit) => React.element diff --git a/apps/docs/src/components/SearchHit.res b/apps/docs/src/components/SearchHit.res new file mode 100644 index 000000000..d2885c0d2 --- /dev/null +++ b/apps/docs/src/components/SearchHit.res @@ -0,0 +1,27 @@ +@react.component +let make = (~hit: DocSearch.docSearchHit) => { + let titleHtml = SearchResults.getHighlightedTitle(hit) + let subtitle = SearchResults.getSubtitle(hit) + let contentHtml = SearchResults.getContentHtml(hit) + let isChild = SearchResults.isChildHit(hit) + + +
+ {isChild ? : React.null} + {isChild ? : } +
+ + {switch subtitle { + | Some(s) => {React.string(s)} + | None => React.null + }} + {switch contentHtml { + | Some(c) if String.length(c) > 0 => + + | _ => React.null + }} +
+ +
+
+} diff --git a/apps/docs/src/components/SearchHit.resi b/apps/docs/src/components/SearchHit.resi new file mode 100644 index 000000000..8ffb0d6d4 --- /dev/null +++ b/apps/docs/src/components/SearchHit.resi @@ -0,0 +1,2 @@ +@react.component +let make: (~hit: DocSearch.docSearchHit) => React.element diff --git a/apps/docs/src/components/SearchModal.res b/apps/docs/src/components/SearchModal.res new file mode 100644 index 000000000..9101eac9e --- /dev/null +++ b/apps/docs/src/components/SearchModal.res @@ -0,0 +1,27 @@ +@module external searchStyles: unit = "../../styles/search.css" + +let () = searchStyles + +let hitComponent = ({hit, children: _}: DocSearch.hitComponent) => + +@react.component +let make = (~apiKey, ~appId, ~indexName, ~onClose: unit => unit) => { + let navigate = ReactRouter.useNavigate() + + SearchResults.normalizeHitUrls(items, ~siteUrl=Env.root_url)} + hitComponent + onClose + insights=true + initialScrollY={window.scrollY->Float.toInt} + searchParameters={ + distinct: 3, + hitsPerPage: 20, + attributesToSnippet: ["content:9999"], + } + /> +} diff --git a/apps/docs/src/components/SearchModal.resi b/apps/docs/src/components/SearchModal.resi new file mode 100644 index 000000000..592c7bd56 --- /dev/null +++ b/apps/docs/src/components/SearchModal.resi @@ -0,0 +1,7 @@ +@react.component +let make: ( + ~apiKey: string, + ~appId: string, + ~indexName: string, + ~onClose: unit => unit, +) => React.element diff --git a/apps/docs/src/components/SearchNotice.res b/apps/docs/src/components/SearchNotice.res new file mode 100644 index 000000000..f3ca47f0f --- /dev/null +++ b/apps/docs/src/components/SearchNotice.res @@ -0,0 +1,25 @@ +let loadingText = "Loading search" +let unavailableText = "Search unavailable" + +@react.component +let make = (~kind, ~onClose: unit => unit) => { + let (role, text) = switch kind { + | #Loading => ("status", loadingText) + | #Unavailable => ("alert", unavailableText) + } + +
+ {React.string(text)} + +
+} diff --git a/apps/docs/src/components/SearchNotice.resi b/apps/docs/src/components/SearchNotice.resi new file mode 100644 index 000000000..d1f33a413 --- /dev/null +++ b/apps/docs/src/components/SearchNotice.resi @@ -0,0 +1,2 @@ +@react.component +let make: (~kind: [#Loading | #Unavailable], ~onClose: unit => unit) => React.element diff --git a/apps/docs/src/components/SearchResults.res b/apps/docs/src/components/SearchResults.res new file mode 100644 index 000000000..ffa0f00e5 --- /dev/null +++ b/apps/docs/src/components/SearchResults.res @@ -0,0 +1,239 @@ +let toRelativeSiteUrl = (url: string, ~siteUrl: string): string => { + let normalizedSiteUrl = siteUrl->String.replaceRegExp(RegExp.fromString("/+$", ~flags=""), "") + if normalizedSiteUrl !== "" && String.startsWith(url, normalizedSiteUrl) { + let relativePath = String.slice(url, ~start=String.length(normalizedSiteUrl)) + if relativePath === "" { + "/" + } else if String.startsWith(relativePath, "/") { + relativePath + } else { + "/" ++ relativePath + } + } else { + url + } +} + +type apiNamespace = StdlibApi | BeltApi + +let apiNamespaceForUrl = (url: string): option => + if url->String.includes("/docs/manual/api/stdlib/") { + Some(StdlibApi) + } else if url->String.includes("/docs/manual/api/belt/") { + Some(BeltApi) + } else { + None + } + +let stripCaseInsensitivePrefix = (value: string, prefix: string): string => { + if ( + prefix->String.length > 0 && + value->String.toLowerCase->String.startsWith(prefix->String.toLowerCase) + ) { + String.slice(value, ~start=String.length(prefix)) + } else { + value + } +} + +let baseApiModuleName = (moduleName: string): string => + moduleName->stripCaseInsensitivePrefix("Stdlib.")->stripCaseInsensitivePrefix("Belt.") + +let apiGroupName = (hit: DocSearch.docSearchHit): option => + switch (apiNamespaceForUrl(hit.url), hit.hierarchy.lvl0->Nullable.toOption) { + | (Some(StdlibApi), Some(moduleName)) if moduleName !== "" => + Some(moduleName->stripCaseInsensitivePrefix("Stdlib.")) + | (Some(BeltApi), Some(moduleName)) if moduleName !== "" => + Some(`Belt.${moduleName->baseApiModuleName}`) + | _ => None + } + +let normalizeHitUrls = (items: array, ~siteUrl: string) => + items->Array.map(hit => { + let url = toRelativeSiteUrl(hit.url, ~siteUrl) + let urlWithoutAnchor = + hit.url_without_anchor + ->Nullable.toOption + ->Option.getOr(hit.url->String.split("#")->Array.get(0)->Option.getOr(hit.url)) + let url_without_anchor = toRelativeSiteUrl(urlWithoutAnchor, ~siteUrl)->Nullable.make + let hierarchy = switch hit->apiGroupName { + | Some(lvl0) => {...hit.hierarchy, lvl0: Nullable.make(lvl0)} + | None => hit.hierarchy + } + {...hit, url, url_without_anchor, hierarchy} + }) + +let navigator = (~siteUrl: string, ~navigate: ReactRouter.navigate): DocSearch.navigator => { + navigate: ({itemUrl}) => { + navigate(toRelativeSiteUrl(itemUrl, ~siteUrl)) + }, +} + +let getSubtitle = (hit: DocSearch.docSearchHit): option => + switch hit.type_ { + | Lvl0 | Lvl1 => None + | Lvl2 | Lvl3 | Lvl4 | Lvl5 | Lvl6 | Content => + hit.hierarchy.lvl1->Nullable.toOption->Option.filter(value => value !== "") + } + +let highlightedValue = (value: Nullable.t): option => + value->Nullable.toOption->Option.map(value => value.value) + +let highlightedValueWithMarkup = (value: Nullable.t): option => + switch highlightedValue(value) { + | Some(value) if value->String.includes("") => Some(value) + | _ => None + } + +let highlightedHierarchyValue = ( + hierarchy: DocSearch.highlightedHierarchy, + type_: DocSearch.contentType, +): option => + switch type_ { + | Lvl0 => hierarchy.lvl0->highlightedValue + | Lvl1 => hierarchy.lvl1->highlightedValue + | Lvl2 => hierarchy.lvl2->highlightedValue + | Lvl3 => hierarchy.lvl3->highlightedValue + | Lvl4 => hierarchy.lvl4->highlightedValue + | Lvl5 => hierarchy.lvl5->highlightedValue + | Lvl6 => hierarchy.lvl6->highlightedValue + | Content => None + } + +let highlightedHierarchyValueWithMarkup = ( + hierarchy: DocSearch.highlightedHierarchy, + type_: DocSearch.contentType, +): option => + switch type_ { + | Lvl0 => hierarchy.lvl0->highlightedValueWithMarkup + | Lvl1 => hierarchy.lvl1->highlightedValueWithMarkup + | Lvl2 => hierarchy.lvl2->highlightedValueWithMarkup + | Lvl3 => hierarchy.lvl3->highlightedValueWithMarkup + | Lvl4 => hierarchy.lvl4->highlightedValueWithMarkup + | Lvl5 => hierarchy.lvl5->highlightedValueWithMarkup + | Lvl6 => hierarchy.lvl6->highlightedValueWithMarkup + | Content => None + } + +let firstMarkedText = (html: string): option => { + switch RegExp.exec(/([^<]+)<\/mark>/, html) { + | Some(result) => + let matches = RegExp.Result.matches(result) + switch matches[0] { + | Some(Some(markedText)) => Some(markedText) + | _ => None + } + | None => None + } +} + +let markTitlePrefix = (title: string, markedText: string): string => { + let markedLength = String.length(markedText) + if ( + markedLength > 0 && title->String.toLowerCase->String.startsWith(markedText->String.toLowerCase) + ) { + let prefix = String.slice(title, ~start=0, ~end=markedLength) + let suffix = String.slice(title, ~start=markedLength) + `${prefix}${suffix}` + } else { + title + } +} + +let stripApiModulePrefix = (markedText: string, moduleName: string): string => { + let moduleName = moduleName->baseApiModuleName + markedText + ->stripCaseInsensitivePrefix(`Stdlib.${moduleName}.`) + ->stripCaseInsensitivePrefix(`Belt.${moduleName}.`) + ->stripCaseInsensitivePrefix(`${moduleName}.`) +} + +let getSnippetContent = (hit: DocSearch.docSearchHit): option => + switch hit._snippetResult { + | Some(snippetResult) => snippetResult.content->highlightedValue + | None => None + } + +let getApiTitle = (hit: DocSearch.docSearchHit): option => { + if hit.url->String.includes("/docs/manual/api/") { + switch (hit.hierarchy.lvl0->Nullable.toOption, hit.hierarchy.lvl1->Nullable.toOption) { + | (Some(moduleName), Some(valueName)) if moduleName !== "" && valueName !== "" => + let title = valueName + switch hit->getSnippetContent->Option.flatMap(firstMarkedText) { + | Some(markedText) => + Some(markTitlePrefix(title, stripApiModulePrefix(markedText, moduleName))) + | None => Some(title) + } + | _ => None + } + } else { + None + } +} + +let getHighlightedTitle = (hit: DocSearch.docSearchHit): string => { + let highlightedHierarchy = + hit._highlightResult->Option.flatMap(highlightResult => + highlightResult.hierarchy->Nullable.toOption + ) + let highlightedTitleWithMarkup = highlightedHierarchy->Option.flatMap(hierarchy => + switch hit.type_ { + | Lvl0 | Lvl1 => None + | _ => highlightedHierarchyValueWithMarkup(hierarchy, hit.type_) + } + ) + + switch highlightedTitleWithMarkup { + | Some(title) => title + | None => + switch highlightedHierarchy->Option.flatMap(hierarchy => + hierarchy.lvl1->highlightedValueWithMarkup + ) { + | Some(title) => title + | None => + switch getApiTitle(hit) { + | Some(title) => title + | None => + switch highlightedHierarchy->Option.flatMap(hierarchy => + highlightedHierarchyValue(hierarchy, hit.type_) + ) { + | Some(title) => title + | None => hit.hierarchy.lvl1->Nullable.toOption->Option.getOr("") + } + } + } + } +} + +let markdownToHtml = (text: string): string => + text + // Strip stray backslashes from MDX processing + ->String.replaceRegExp(RegExp.fromString("^\\\\\\s+", ~flags=""), "") + ->String.replaceRegExp(RegExp.fromString("\\\\\\s+", ~flags="g"), " ") + ->String.replaceRegExp( + RegExp.fromString("See\\s+\\[([^\\]]+)\\]\\([^)]*\\)\\s+on MDN\\.?", ~flags="g"), + "", + ) + ->String.replaceRegExp(RegExp.fromString("See\\s+\\S+\\s+on MDN\\.?", ~flags="g"), "") + ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("\\x60([^\\x60]+)\\x60", ~flags="g"), "$1") + ->String.replaceRegExp( + RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), + "$1", + ) + ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), "
") + ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") + ->String.trim + +let isChildHit = (hit: DocSearch.docSearchHit) => + switch hit.type_ { + | Lvl2 | Lvl3 | Lvl4 | Lvl5 | Lvl6 | Content => true + | Lvl0 | Lvl1 => hit.url->String.includes("#") + } + +let getContentHtml = (hit: DocSearch.docSearchHit): option => + switch getSnippetContent(hit) { + | Some(content) => Some(content->markdownToHtml) + | None => hit.content->Nullable.toOption->Option.map(markdownToHtml) + } diff --git a/apps/docs/src/components/SearchResults.resi b/apps/docs/src/components/SearchResults.resi new file mode 100644 index 000000000..18c4175b8 --- /dev/null +++ b/apps/docs/src/components/SearchResults.resi @@ -0,0 +1,11 @@ +let toRelativeSiteUrl: (string, ~siteUrl: string) => string +let normalizeHitUrls: ( + array, + ~siteUrl: string, +) => array +let navigator: (~siteUrl: string, ~navigate: ReactRouter.navigate) => DocSearch.navigator +let getSubtitle: DocSearch.docSearchHit => option +let getHighlightedTitle: DocSearch.docSearchHit => string +let markdownToHtml: string => string +let isChildHit: DocSearch.docSearchHit => bool +let getContentHtml: DocSearch.docSearchHit => option diff --git a/apps/docs/styles/_docsearch.css b/apps/docs/styles/_docsearch.css index ba5f02688..13a05b9f3 100644 --- a/apps/docs/styles/_docsearch.css +++ b/apps/docs/styles/_docsearch.css @@ -70,7 +70,8 @@ .DocSearch-Modal { @apply relative min-h-0 mx-auto bg-white w-full flex flex-col shadow-lg - max-w-(--breakpoint-md) rounded-lg; + rounded-lg; + max-width: var(--breakpoint-md, 768px); transform: scale(0.95); opacity: 0; transition: all 0.2s ease; diff --git a/apps/docs/styles/main.css b/apps/docs/styles/main.css index b538e8db8..045eb257d 100644 --- a/apps/docs/styles/main.css +++ b/apps/docs/styles/main.css @@ -1,6 +1,5 @@ @import "./_markdown.css" layer(base); @import "./_fonts.css" layer(base); -@import "./_docsearch.css" layer(base); @import "tailwindcss"; diff --git a/apps/docs/styles/search.css b/apps/docs/styles/search.css new file mode 100644 index 000000000..fa6ddef6a --- /dev/null +++ b/apps/docs/styles/search.css @@ -0,0 +1,3 @@ +/* Reuse tokens and utilities without duplicating the global theme or preflight. */ +@reference "./main.css"; +@import "./_docsearch.css" layer(base); From db32e0104d5654225ecd7b08aa59a5f0471c902f Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 18:12:47 -0400 Subject: [PATCH 2/6] test(search): migrate lazy loading coverage to Cypress Keep asset, keyboard, result navigation, pending cancellation, and failed-chunk recovery assertions. Accept only the explicitly expected lazy-import console error. Addresses PR #1355 comment 4054821928. --- .../e2e-playwright/homepage-search.spec.mjs | 218 ------------------ apps/docs/e2e/bindings/Cypress.res | 4 + apps/docs/e2e/homepage/HomepageSupport.res | 16 +- apps/docs/e2e/homepage/homepage-search.cy.js | 136 +++++++++++ apps/docs/e2e/homepage/search-result.js | 34 +++ 5 files changed, 189 insertions(+), 219 deletions(-) delete mode 100644 apps/docs/e2e-playwright/homepage-search.spec.mjs create mode 100644 apps/docs/e2e/homepage/homepage-search.cy.js create mode 100644 apps/docs/e2e/homepage/search-result.js diff --git a/apps/docs/e2e-playwright/homepage-search.spec.mjs b/apps/docs/e2e-playwright/homepage-search.spec.mjs deleted file mode 100644 index af3b872a2..000000000 --- a/apps/docs/e2e-playwright/homepage-search.spec.mjs +++ /dev/null @@ -1,218 +0,0 @@ -import { expect, test } from "playwright/test"; -import { JSDOM } from "jsdom"; - -const searchChunk = /\/assets\/SearchModal-[^/]+\.js$/; - -test("initial homepage assets exclude the search implementation and styles", async ({ - request, -}) => { - const response = await request.get("/"); - const { document } = new JSDOM(await response.text()).window; - const initialScripts = [ - ...document.querySelectorAll('link[rel="modulepreload"][href]'), - ...document.querySelectorAll("script[src]"), - ].map( - (element) => element.getAttribute("href") ?? element.getAttribute("src"), - ); - const initialStyles = [ - ...document.querySelectorAll('link[rel="stylesheet"][href]'), - ].map((element) => element.getAttribute("href")); - - expect(response.ok()).toBe(true); - expect(initialScripts.length).toBeGreaterThan(0); - expect(initialStyles.length).toBeGreaterThan(0); - for (const asset of initialScripts) { - const script = await request.get(asset); - expect(script.ok()).toBe(true); - const source = await script.text(); - expect(source.includes("search-insights"), asset).toBe(false); - expect(source.includes("DocSearch-Modal"), asset).toBe(false); - } - for (const asset of initialStyles) { - const stylesheet = await request.get(asset); - expect(stylesheet.ok()).toBe(true); - expect((await stylesheet.text()).includes(".DocSearch-Modal"), asset).toBe( - false, - ); - } -}); - -test("search loads on activation, stays styled, and supports keyboard reopening", async ({ - page, -}) => { - await page.setViewportSize({ width: 1440, height: 900 }); - const insightsRequests = []; - const searchRequests = []; - page.on("request", (request) => { - if (request.url().includes("search-insights")) { - insightsRequests.push(request.url()); - } - if (searchChunk.test(request.url())) { - searchRequests.push(request.url()); - } - }); - await page.goto("/"); - const search = page.getByRole("button", { name: "Search", exact: true }); - const input = page.getByPlaceholder("Search docs", { exact: true }); - - await expect(search).toBeVisible(); - await expect(input).toHaveCount(0); - expect(insightsRequests).toEqual([]); - expect(searchRequests).toEqual([]); - await search.click(); - await expect(input).toBeFocused(); - expect(searchRequests.length).toBeGreaterThan(0); - await expect(page.locator(".DocSearch-Container")).toHaveCSS( - "position", - "fixed", - ); - await expect(page.locator(".DocSearch-Modal")).toHaveCSS("opacity", "1"); - await expect(page.locator(".DocSearch-Modal")).toHaveCSS( - "max-width", - "768px", - ); - await input.press("Escape"); - await expect(input).toHaveCount(0); - - await page.keyboard.press("/"); - await expect(input).toBeFocused(); - await input.press("/"); - await expect(input).toHaveValue("/"); - await input.press("Escape"); - await expect(input).toHaveValue(""); - await expect(input).toBeFocused(); - await input.press("Escape"); - await expect(input).toHaveCount(0); - await page.keyboard.press("Control+k"); - await expect(input).toBeFocused(); - await input.press("Escape"); - await expect(input).toHaveCount(0); -}); - -test("lazy search results navigate into documentation", async ({ page }) => { - await page.route( - (url) => - (url.hostname.endsWith(".algolia.net") || - url.hostname.endsWith(".algolianet.com")) && - url.pathname.endsWith("/queries"), - async (route) => { - await route.fulfill({ - json: { - results: [ - { - hits: [ - { - objectID: "installation", - url: "https://rescript-lang.org/docs/manual/installation", - url_without_anchor: - "https://rescript-lang.org/docs/manual/installation", - type: "lvl1", - anchor: null, - content: null, - hierarchy: { - lvl0: "ReScript", - lvl1: "Installation", - lvl2: null, - lvl3: null, - lvl4: null, - lvl5: null, - lvl6: null, - }, - }, - ], - nbHits: 1, - page: 0, - nbPages: 1, - hitsPerPage: 20, - processingTimeMS: 1, - query: "installation", - index: "test-index", - queryID: "homepage-search-test", - }, - ], - }, - }); - }, - ); - await page.goto("/"); - await page.getByRole("button", { name: "Search", exact: true }).click(); - await page - .getByPlaceholder("Search docs", { exact: true }) - .fill("installation"); - await page - .locator(".DocSearch-Modal") - .getByRole("link", { name: "Installation", exact: true }) - .click(); - - await expect(page).toHaveURL(/\/docs\/manual\/installation$/); - await expect( - page.getByRole("heading", { name: "Installation", level: 1, exact: true }), - ).toBeVisible(); - await expect( - page.getByPlaceholder("Search docs", { exact: true }), - ).toHaveCount(0); -}); - -test("a pending search load can be closed without opening the modal afterward", async ({ - page, -}) => { - const download = Promise.withResolvers(); - await page.route(searchChunk, async (route) => { - await download.promise; - await route.continue(); - }); - await page.goto("/"); - const search = page.getByRole("button", { name: "Search", exact: true }); - const response = page.waitForResponse(searchChunk); - - try { - await search.click(); - await expect( - page.getByRole("button", { name: "Close search" }), - ).toBeVisible(); - await page.keyboard.press("Escape"); - await expect( - page.getByRole("button", { name: "Close search" }), - ).toHaveCount(0); - await expect(search).toBeFocused(); - } finally { - download.resolve(); - await page.unrouteAll({ behavior: "wait" }); - } - await (await response).finished(); - await expect(search).toBeFocused(); - - await expect( - page.getByPlaceholder("Search docs", { exact: true }), - ).toHaveCount(0); - await search.click(); - await expect( - page.getByPlaceholder("Search docs", { exact: true }), - ).toBeFocused(); -}); - -test("a failed search chunk leaves the page usable and recovers after a reload", async ({ - page, -}) => { - await page.route(searchChunk, (route) => route.abort()); - await page.goto("/"); - const search = page.getByRole("button", { name: "Search", exact: true }); - await search.click(); - - await expect(page.getByRole("alert")).toContainText("Search unavailable"); - await page.getByRole("button", { name: "Close search" }).click(); - await expect(page.getByRole("alert")).toHaveCount(0); - await expect(search).toBeFocused(); - await expect( - page.getByRole("heading", { - level: 1, - name: "JavaScript Made Simple for Humans and AI", - }), - ).toBeVisible(); - await page.unrouteAll({ behavior: "wait" }); - await page.reload(); - await search.click(); - await expect( - page.getByPlaceholder("Search docs", { exact: true }), - ).toBeFocused(); -}); diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index cf6730b36..fa6c23b7d 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -2,6 +2,10 @@ type chain<'a> type elements type assertion type spy +type consoleArgument +type consoleCall = {args: array} +@send external getCalls: spy => array = "getCalls" +@val external consoleArgumentString: option => string = "String" type console type rec window = {document: Dom.document, console: console, navigator: {clipboard: clipboard}} and clipboard diff --git a/apps/docs/e2e/homepage/HomepageSupport.res b/apps/docs/e2e/homepage/HomepageSupport.res index b080627a0..11051e574 100644 --- a/apps/docs/e2e/homepage/HomepageSupport.res +++ b/apps/docs/e2e/homepage/HomepageSupport.res @@ -7,6 +7,7 @@ beforeEach(() => { run(() => automate({command: "Network.clearBrowserCache"}))->ignore let spies = ref([]) wrap(spies)->as_("consoleSpies")->ignore + wrap([])->as_("expectedConsoleErrors")->ignore onBeforeLoad(window => { let script = window.document->currentScript->Null.toOption let marker = @@ -18,6 +19,19 @@ beforeEach(() => { afterEach(() => { alias("@consoleSpies") - ->then(spies => spies.contents->Array.forEach(spy => expect(spy->callCount)->equal(0))) + ->then(spies => { + let errors = spies.contents + ->Array.flatMap(getCalls) + ->Array.map(call => call.args->Array.get(0)->consoleArgumentString) + alias("@expectedConsoleErrors")->then(expected => { + let unexpected = errors->Array.filter(error => + !(expected->Array.some(pattern => pattern->RegExp.test(error))) + ) + expect(unexpected->Array.length, ~message=`unexpected console errors: ${unexpected->Array.joinWith("; ")}`)->equal(0) + expected->Array.forEach(pattern => + expect(errors->Array.some(error => pattern->RegExp.test(error)), ~message="expected console error occurred")->equal(true) + ) + })->ignore + }) ->ignore }) diff --git a/apps/docs/e2e/homepage/homepage-search.cy.js b/apps/docs/e2e/homepage/homepage-search.cy.js new file mode 100644 index 000000000..52d14089c --- /dev/null +++ b/apps/docs/e2e/homepage/homepage-search.cy.js @@ -0,0 +1,136 @@ +import { headline, homepageDocument, initialScriptUrls } from "./helpers.js"; +import { installationResults } from "./search-result.js"; + +const searchChunk = /\/assets\/SearchModal-[^/]+\.js$/; +const search = 'button[aria-label="Search"]'; +const input = 'input[placeholder="Search docs"]'; +const close = 'button[aria-label="Close search"]'; + +it("initial homepage assets exclude the search implementation and styles", () => { + homepageDocument().then((document) => { + const scripts = initialScriptUrls(document); + const styles = [ + ...document.querySelectorAll('link[rel="stylesheet"][href]'), + ].map((element) => element.getAttribute("href")); + expect(scripts.length).to.be.greaterThan(0); + expect(styles.length).to.be.greaterThan(0); + for (const asset of scripts) { + cy.request(asset).then(({ status, body }) => { + expect(status, asset).to.equal(200); + expect(body, asset).not.to.include("search-insights"); + expect(body, asset).not.to.include("DocSearch-Modal"); + }); + } + for (const asset of styles) { + cy.request(asset).then(({ status, body }) => { + expect(status, asset).to.equal(200); + expect(body, asset).not.to.include(".DocSearch-Modal"); + }); + } + }); +}); + +it("search loads on activation, stays styled, and supports keyboard reopening", () => { + const requests = []; + cy.intercept("**", (request) => { + if ( + request.url.includes("search-insights") || + searchChunk.test(request.url) + ) { + requests.push(request.url); + } + }); + cy.visit("/"); + cy.get(search).should("be.visible"); + cy.get(input).should("not.exist"); + cy.then(() => expect(requests).to.deep.equal([])); + cy.get(search).click(); + cy.get(input).should("be.focused"); + cy.then(() => + expect(requests.some((url) => searchChunk.test(url))).to.equal(true), + ); + cy.get(".DocSearch-Container").should("have.css", "position", "fixed"); + cy.get(".DocSearch-Modal") + .should("have.css", "opacity", "1") + .and("have.css", "max-width", "768px"); + cy.realPress("Escape"); + cy.get(input).should("not.exist"); + cy.realPress("/"); + cy.get(input).should("be.focused"); + cy.realPress("/"); + cy.get(input).should("have.value", "/"); + cy.realPress("Escape"); + cy.get(input).should("have.value", "").and("be.focused"); + cy.realPress("Escape"); + cy.get(input).should("not.exist"); + cy.realPress(["Control", "k"]); + cy.get(input).should("be.focused"); + cy.realPress("Escape"); + cy.get(input).should("not.exist"); +}); + +it("lazy search results navigate into documentation", () => { + cy.intercept( + { hostname: /\.(algolia\.net|algolianet\.com)$/, pathname: /\/queries$/ }, + { + statusCode: 200, + body: installationResults, + }, + ); + cy.visit("/"); + cy.get(search).click(); + cy.get(input).type("installation"); + cy.get(".DocSearch-Modal") + .contains("a", /^Installation$/) + .click(); + cy.location("pathname").should("equal", "/docs/manual/installation"); + cy.contains("h1", /^Installation$/).should("be.visible"); + cy.get(input).should("not.exist"); +}); + +it("a pending search load can be closed without opening the modal afterward", () => { + const download = Promise.withResolvers(); + cy.on("fail", (error) => { + download.resolve(); + throw error; + }); + cy.intercept(searchChunk, () => download.promise).as("searchChunk"); + cy.visit("/"); + cy.get(search).click(); + cy.get(close).should("be.visible"); + cy.realPress("Escape"); + cy.get(close).should("not.exist"); + cy.get(search).should("be.focused"); + cy.then(() => download.resolve()); + cy.wait("@searchChunk"); + cy.get(search).should("be.focused"); + cy.get(input).should("not.exist"); + cy.get(search).click(); + cy.get(input).should("be.focused"); +}); + +it("a failed search chunk leaves the page usable and recovers after a reload", () => { + let blockChunk = true; + cy.intercept(searchChunk, (request) => { + if (blockChunk) request.destroy(); + }); + cy.wrap( + [ + /Failed to fetch dynamically imported module: .*\/assets\/SearchModal-[^/]+\.js/, + ], + { log: false }, + ).as("expectedConsoleErrors"); + cy.visit("/"); + cy.get(search).click(); + cy.contains('[role="alert"]', "Search unavailable").should("be.visible"); + cy.get(close).click(); + cy.get('[role="alert"]').should("not.exist"); + cy.get(search).should("be.focused"); + cy.contains("h1", headline).should("be.visible"); + cy.then(() => { + blockChunk = false; + }); + cy.reload(); + cy.get(search).click(); + cy.get(input).should("be.focused"); +}); diff --git a/apps/docs/e2e/homepage/search-result.js b/apps/docs/e2e/homepage/search-result.js new file mode 100644 index 000000000..900d77b00 --- /dev/null +++ b/apps/docs/e2e/homepage/search-result.js @@ -0,0 +1,34 @@ +export const installationResults = { + results: [ + { + hits: [ + { + objectID: "installation", + url: "https://rescript-lang.org/docs/manual/installation", + url_without_anchor: + "https://rescript-lang.org/docs/manual/installation", + type: "lvl1", + anchor: null, + content: null, + hierarchy: { + lvl0: "ReScript", + lvl1: "Installation", + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + }, + ], + nbHits: 1, + page: 0, + nbPages: 1, + hitsPerPage: 20, + processingTimeMS: 1, + query: "installation", + index: "test-index", + queryID: "homepage-search-test", + }, + ], +}; From 06dba156ce829e93788772275c35740ceff7c5ab Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 18:50:43 -0400 Subject: [PATCH 3/6] test(search): isolate keyboard queries from Algolia Return a deterministic empty search response for the keyboard-only Cypress scenario and await it before closing the modal. Keep uncaught errors fatal and all interaction assertions unchanged. --- apps/docs/e2e/homepage/homepage-search.cy.js | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/docs/e2e/homepage/homepage-search.cy.js b/apps/docs/e2e/homepage/homepage-search.cy.js index 52d14089c..e13613e2f 100644 --- a/apps/docs/e2e/homepage/homepage-search.cy.js +++ b/apps/docs/e2e/homepage/homepage-search.cy.js @@ -31,6 +31,26 @@ it("initial homepage assets exclude the search implementation and styles", () => }); it("search loads on activation, stays styled, and supports keyboard reopening", () => { + cy.intercept( + { hostname: /\.(algolia\.net|algolianet\.com)$/, pathname: /\/queries$/ }, + { + statusCode: 200, + body: { + results: [ + { + hits: [], + nbHits: 0, + page: 0, + nbPages: 0, + hitsPerPage: 20, + processingTimeMS: 1, + query: "/", + index: "test-index", + }, + ], + }, + }, + ).as("keyboardSearch"); const requests = []; cy.intercept("**", (request) => { if ( @@ -59,6 +79,7 @@ it("search loads on activation, stays styled, and supports keyboard reopening", cy.get(input).should("be.focused"); cy.realPress("/"); cy.get(input).should("have.value", "/"); + cy.wait("@keyboardSearch").its("response.statusCode").should("equal", 200); cy.realPress("Escape"); cy.get(input).should("have.value", "").and("be.focused"); cy.realPress("Escape"); From 13a911b7f890e58dc03c27f5bd86e1e86b64e2e1 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 19:48:59 -0400 Subject: [PATCH 4/6] test: format rescript search error assertions Use the supported Array.join API under warning-as-error compilation. --- apps/docs/e2e/homepage/HomepageSupport.res | 37 +++++++++++++++------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/docs/e2e/homepage/HomepageSupport.res b/apps/docs/e2e/homepage/HomepageSupport.res index 11051e574..cb1678953 100644 --- a/apps/docs/e2e/homepage/HomepageSupport.res +++ b/apps/docs/e2e/homepage/HomepageSupport.res @@ -20,18 +20,31 @@ beforeEach(() => { afterEach(() => { alias("@consoleSpies") ->then(spies => { - let errors = spies.contents - ->Array.flatMap(getCalls) - ->Array.map(call => call.args->Array.get(0)->consoleArgumentString) - alias("@expectedConsoleErrors")->then(expected => { - let unexpected = errors->Array.filter(error => - !(expected->Array.some(pattern => pattern->RegExp.test(error))) - ) - expect(unexpected->Array.length, ~message=`unexpected console errors: ${unexpected->Array.joinWith("; ")}`)->equal(0) - expected->Array.forEach(pattern => - expect(errors->Array.some(error => pattern->RegExp.test(error)), ~message="expected console error occurred")->equal(true) - ) - })->ignore + let errors = + spies.contents + ->Array.flatMap(getCalls) + ->Array.map(call => call.args->Array.get(0)->consoleArgumentString) + alias("@expectedConsoleErrors") + ->then( + expected => { + let unexpected = + errors->Array.filter( + error => !(expected->Array.some(pattern => pattern->RegExp.test(error))), + ) + expect( + unexpected->Array.length, + ~message=`unexpected console errors: ${unexpected->Array.join("; ")}`, + )->equal(0) + expected->Array.forEach( + pattern => + expect( + errors->Array.some(error => pattern->RegExp.test(error)), + ~message="expected console error occurred", + )->equal(true), + ) + }, + ) + ->ignore }) ->ignore }) From 39d26d0136e756fc4742917d756bc95e535bde94 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 21:19:06 -0400 Subject: [PATCH 5/6] fix(search): register YAML before lazy navigation The lazy search flow can navigate from the homepage to documentation containing YAML fences before the later highlighting-boundary refactor. Register the existing YAML grammar at the current root boundary so this PR and its immediate descendant remain independently valid. --- apps/docs/app/DocsRoot.res | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/docs/app/DocsRoot.res b/apps/docs/app/DocsRoot.res index f8b6002d3..5baf7427e 100644 --- a/apps/docs/app/DocsRoot.res +++ b/apps/docs/app/DocsRoot.res @@ -14,6 +14,7 @@ external hljsCss: string = "default" import json from 'highlight.js/lib/languages/json'; import text from 'highlight.js/lib/languages/plaintext'; import html from 'highlight.js/lib/languages/xml'; + import yaml from 'highlight.js/lib/languages/yaml'; import toml from 'highlight.js/lib/languages/ini'; import rescript from 'highlightjs-rescript'; @@ -29,6 +30,7 @@ external hljsCss: string = "default" hljs.registerLanguage('html', html) hljs.registerLanguage('diff', diff) hljs.registerLanguage('typescript', typescript) + hljs.registerLanguage('yaml', yaml) `) open ReactRouter From dfbf20e2d708dfd4b4979f0aab5571b71f76c0b3 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 10:15:16 -0400 Subject: [PATCH 6/6] test(homepage): port search Cypress spec to ReScript Replace the JavaScript search spec and fixture with typed ReScript, parsed JSON fixtures, and shared ReScript helpers. --- apps/docs/e2e/bindings/Cypress.res | 20 +++ apps/docs/e2e/homepage/HomepageHelpers.res | 11 ++ apps/docs/e2e/homepage/HomepageSearch.cy.res | 179 +++++++++++++++++++ apps/docs/e2e/homepage/homepage-search.cy.js | 157 ---------------- apps/docs/e2e/homepage/search-result.js | 34 ---- 5 files changed, 210 insertions(+), 191 deletions(-) create mode 100644 apps/docs/e2e/homepage/HomepageSearch.cy.res delete mode 100644 apps/docs/e2e/homepage/homepage-search.cy.js delete mode 100644 apps/docs/e2e/homepage/search-result.js diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index fa6c23b7d..f306de74d 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -13,6 +13,9 @@ type response = {status: int, body: string} type automation = {command: string, params?: {permissions: array, origin: string}} type request = {url: string, resourceType: string} type routeMatcher = {resourceType?: string, pathname?: string} +type searchRouteMatcher = {hostname: RegExp.t, pathname: RegExp.t} +type staticResponse = {statusCode: int, body: JSON.t} +type wrapOptions = {log: bool} type url type elementList type fontFaceSet @@ -40,9 +43,20 @@ type cssStyle @val @scope("cy") external intercept: (routeMatcher, request => unit) => chain = "intercept" @val @scope("cy") external interceptAll: (string, request => unit) => chain = "intercept" @val @scope("cy") external interceptRoute: routeMatcher => chain = "intercept" +@val @scope("cy") +external interceptStatic: (searchRouteMatcher, staticResponse) => chain = "intercept" +@val @scope("cy") +external interceptPattern: (RegExp.t, request => unit) => chain = "intercept" +@val @scope("cy") +external interceptDeferred: (RegExp.t, unit => promise) => chain = "intercept" @val @scope("cy") external wait: string => chain = "wait" @val @scope("cy") external wrap: 'a => chain<'a> = "wrap" +@val @scope("cy") external wrapWithOptions: ('a, wrapOptions) => chain<'a> = "wrap" @val @scope("cy") external run: (unit => promise) => chain = "then" +@val @scope("cy") external do_: (unit => unit) => chain = "then" +@val @scope("cy") external realPressKey: string => chain = "realPress" +@val @scope("cy") external realPressKeys: array => chain = "realPress" +@val @scope("cy") external reload: unit => unit = "reload" @val @scope("cy") external onBeforeLoad: (@as("window:before:load") _, window => unit) => unit = "on" @val @scope("cy") external spy: (console, @as("error") _) => spy = "spy" @@ -64,6 +78,8 @@ external shouldCss: (chain, @as("have.css") _, string, string) => chai @send external shouldCssProperty: (chain, @as("have.css") _, string) => chain = "should" @send external shouldInt: (chain<'a>, string, int) => chain<'a> = "should" +@send +external shouldValue: (chain, @as("have.value") _, string) => chain = "should" @send external shouldEqual: (chain<'a>, @as("equal") _, 'a) => chain<'a> = "should" @send external shouldDeepEqual: (chain<'a>, @as("deep.equal") _, 'a) => chain<'a> = "should" @send external shouldMatch: (chain<'a>, @as("match") _, RegExp.t) => chain<'a> = "should" @@ -76,6 +92,9 @@ external shouldAttribute: (chain, @as("have.attr") _, string, string) @send external propertyInt: (chain<'a>, string) => chain = "its" @send external shouldSatisfy: (chain<'a>, 'a => unit) => chain<'a> = "should" @send external click: chain => chain = "click" +@send external typeText: (chain, string) => chain = "type" +@send +external containsChildRegex: (chain, string, RegExp.t) => chain = "contains" @send external focusElement: chain => chain = "focus" @send external realClick: chain => chain = "realClick" @send external realPress: (chain, string) => chain = "realPress" @@ -86,6 +105,7 @@ external shouldAttribute: (chain, @as("have.attr") _, string, string) @get external complete: Dom.element => bool = "complete" @get external naturalWidth: Dom.element => int = "naturalWidth" @send external readText: clipboard => promise = "readText" +@send external destroy: request => unit = "destroy" @val external expect: ('a, ~message: string=?) => assertion = "expect" @send @scope("to") external equal: (assertion, 'a) => unit = "equal" diff --git a/apps/docs/e2e/homepage/HomepageHelpers.res b/apps/docs/e2e/homepage/HomepageHelpers.res index 37a569db1..54bf4f450 100644 --- a/apps/docs/e2e/homepage/HomepageHelpers.res +++ b/apps/docs/e2e/homepage/HomepageHelpers.res @@ -34,6 +34,17 @@ let homepageDocument = callback => { ->ignore } +let initialScriptUrls = document => + document + ->querySelectorAll(`link[rel="modulepreload"][href], script[src]`) + ->elementsFrom + ->Array.filterMap(element => + element + ->getAttribute("href") + ->Null.toOption + ->Option.orElse(element->getAttribute("src")->Null.toOption) + ) + let expectElement = (document, selector) => { let element = document->querySelector(selector)->Null.toOption expect(element->Option.isSome, ~message=selector)->equal(true) diff --git a/apps/docs/e2e/homepage/HomepageSearch.cy.res b/apps/docs/e2e/homepage/HomepageSearch.cy.res new file mode 100644 index 000000000..b93df7a6e --- /dev/null +++ b/apps/docs/e2e/homepage/HomepageSearch.cy.res @@ -0,0 +1,179 @@ +open Cypress +open HomepageHelpers + +let searchChunk = /\/assets\/SearchModal-[^/]+\.js$/ +let search = `button[aria-label="Search"]` +let input = `input[placeholder="Search docs"]` +let close = `button[aria-label="Close search"]` +let searchRoute = {hostname: /\.(algolia\.net|algolianet\.com)$/, pathname: /\/queries$/} + +let emptyResults = JSON.parseOrThrow(`{ + "results": [{ + "hits": [], + "nbHits": 0, + "page": 0, + "nbPages": 0, + "hitsPerPage": 20, + "processingTimeMS": 1, + "query": "/", + "index": "test-index" + }] + }`) + +let installationResults = JSON.parseOrThrow(`{ + "results": [{ + "hits": [{ + "objectID": "installation", + "url": "https://rescript-lang.org/docs/manual/installation", + "url_without_anchor": "https://rescript-lang.org/docs/manual/installation", + "type": "lvl1", + "anchor": null, + "content": null, + "hierarchy": { + "lvl0": "ReScript", + "lvl1": "Installation", + "lvl2": null, + "lvl3": null, + "lvl4": null, + "lvl5": null, + "lvl6": null + } + }], + "nbHits": 1, + "page": 0, + "nbPages": 1, + "hitsPerPage": 20, + "processingTimeMS": 1, + "query": "installation", + "index": "test-index", + "queryID": "homepage-search-test" + }] + }`) + +it("initial homepage assets exclude the search implementation and styles", () => { + homepageDocument(document => { + let scripts = document->initialScriptUrls + let styles = + document + ->querySelectorAll(`link[rel="stylesheet"][href]`) + ->elementsFrom + ->Array.filterMap(element => element->getAttribute("href")->Null.toOption) + expect(scripts->Array.length)->greaterThan(0) + expect(styles->Array.length)->greaterThan(0) + scripts->Array.forEach( + asset => + request(asset) + ->then( + response => { + expect(response.status, ~message=asset)->equal(200) + expect(response.body, ~message=asset)->notInclude("search-insights") + expect(response.body, ~message=asset)->notInclude("DocSearch-Modal") + }, + ) + ->ignore, + ) + styles->Array.forEach( + asset => + request(asset) + ->then( + response => { + expect(response.status, ~message=asset)->equal(200) + expect(response.body, ~message=asset)->notInclude(".DocSearch-Modal") + }, + ) + ->ignore, + ) + }) +}) + +it("search loads on activation, stays styled, and supports keyboard reopening", () => { + interceptStatic(searchRoute, {statusCode: 200, body: emptyResults}) + ->as_("keyboardSearch") + ->ignore + let requests = ref([]) + interceptAll("**", request => { + if request.url->String.includes("search-insights") || searchChunk->RegExp.test(request.url) { + requests := requests.contents->Array.concat([request.url]) + } + })->ignore + visit("/") + get(search)->should("be.visible")->ignore + get(input)->should("not.exist")->ignore + do_(() => expect(requests.contents)->deepEqual([]))->ignore + get(search)->click->ignore + get(input)->should("be.focused")->ignore + do_(() => + expect(requests.contents->Array.some(url => searchChunk->RegExp.test(url)))->equal(true) + )->ignore + get(".DocSearch-Container")->shouldCss("position", "fixed")->ignore + get(".DocSearch-Modal")->shouldCss("opacity", "1")->shouldCss("max-width", "768px")->ignore + realPressKey("Escape")->ignore + get(input)->should("not.exist")->ignore + realPressKey("/")->ignore + get(input)->should("be.focused")->ignore + realPressKey("/")->ignore + get(input)->shouldValue("/")->ignore + wait("@keyboardSearch")->propertyInt("response.statusCode")->shouldEqual(200)->ignore + realPressKey("Escape")->ignore + get(input)->shouldValue("")->should("be.focused")->ignore + realPressKey("Escape")->ignore + get(input)->should("not.exist")->ignore + realPressKeys(["Control", "k"])->ignore + get(input)->should("be.focused")->ignore + realPressKey("Escape")->ignore + get(input)->should("not.exist")->ignore +}) + +it("lazy search results navigate into documentation", () => { + interceptStatic(searchRoute, {statusCode: 200, body: installationResults})->ignore + visit("/") + get(search)->click->ignore + get(input)->typeText("installation")->ignore + get(".DocSearch-Modal")->containsChildRegex("a", /^Installation$/)->click->ignore + cyLocation("pathname")->shouldEqual("/docs/manual/installation")->ignore + containsInRegex("h1", /^Installation$/)->should("be.visible")->ignore + get(input)->should("not.exist")->ignore +}) + +it("a pending search load can be closed without opening the modal afterward", () => { + let download = Promise.withResolvers() + interceptDeferred(searchChunk, () => download.promise)->as_("searchChunk")->ignore + visit("/") + get(search)->click->ignore + get(close)->should("be.visible")->ignore + realPressKey("Escape")->ignore + get(close)->should("not.exist")->ignore + get(search)->should("be.focused")->ignore + do_(() => download.resolve())->ignore + wait("@searchChunk")->ignore + get(search)->should("be.focused")->ignore + get(input)->should("not.exist")->ignore + get(search)->click->ignore + get(input)->should("be.focused")->ignore +}) + +it("a failed search chunk leaves the page usable and recovers after a reload", () => { + let blockChunk = ref(true) + interceptPattern(searchChunk, request => { + if blockChunk.contents { + request->destroy + } + })->ignore + wrapWithOptions( + [/Failed to fetch dynamically imported module: .*\/assets\/SearchModal-[^/]+\.js/], + {log: false}, + ) + ->as_("expectedConsoleErrors") + ->ignore + visit("/") + get(search)->click->ignore + containsIn(`[role="alert"]`, "Search unavailable")->should("be.visible")->ignore + get(close)->click->ignore + get(`[role="alert"]`)->should("not.exist")->ignore + get(search)->should("be.focused")->ignore + containsIn("h1", headline)->should("be.visible")->ignore + do_(() => blockChunk := false)->ignore + reload() + get(search)->click->ignore + get(input)->should("be.focused")->ignore +}) diff --git a/apps/docs/e2e/homepage/homepage-search.cy.js b/apps/docs/e2e/homepage/homepage-search.cy.js deleted file mode 100644 index e13613e2f..000000000 --- a/apps/docs/e2e/homepage/homepage-search.cy.js +++ /dev/null @@ -1,157 +0,0 @@ -import { headline, homepageDocument, initialScriptUrls } from "./helpers.js"; -import { installationResults } from "./search-result.js"; - -const searchChunk = /\/assets\/SearchModal-[^/]+\.js$/; -const search = 'button[aria-label="Search"]'; -const input = 'input[placeholder="Search docs"]'; -const close = 'button[aria-label="Close search"]'; - -it("initial homepage assets exclude the search implementation and styles", () => { - homepageDocument().then((document) => { - const scripts = initialScriptUrls(document); - const styles = [ - ...document.querySelectorAll('link[rel="stylesheet"][href]'), - ].map((element) => element.getAttribute("href")); - expect(scripts.length).to.be.greaterThan(0); - expect(styles.length).to.be.greaterThan(0); - for (const asset of scripts) { - cy.request(asset).then(({ status, body }) => { - expect(status, asset).to.equal(200); - expect(body, asset).not.to.include("search-insights"); - expect(body, asset).not.to.include("DocSearch-Modal"); - }); - } - for (const asset of styles) { - cy.request(asset).then(({ status, body }) => { - expect(status, asset).to.equal(200); - expect(body, asset).not.to.include(".DocSearch-Modal"); - }); - } - }); -}); - -it("search loads on activation, stays styled, and supports keyboard reopening", () => { - cy.intercept( - { hostname: /\.(algolia\.net|algolianet\.com)$/, pathname: /\/queries$/ }, - { - statusCode: 200, - body: { - results: [ - { - hits: [], - nbHits: 0, - page: 0, - nbPages: 0, - hitsPerPage: 20, - processingTimeMS: 1, - query: "/", - index: "test-index", - }, - ], - }, - }, - ).as("keyboardSearch"); - const requests = []; - cy.intercept("**", (request) => { - if ( - request.url.includes("search-insights") || - searchChunk.test(request.url) - ) { - requests.push(request.url); - } - }); - cy.visit("/"); - cy.get(search).should("be.visible"); - cy.get(input).should("not.exist"); - cy.then(() => expect(requests).to.deep.equal([])); - cy.get(search).click(); - cy.get(input).should("be.focused"); - cy.then(() => - expect(requests.some((url) => searchChunk.test(url))).to.equal(true), - ); - cy.get(".DocSearch-Container").should("have.css", "position", "fixed"); - cy.get(".DocSearch-Modal") - .should("have.css", "opacity", "1") - .and("have.css", "max-width", "768px"); - cy.realPress("Escape"); - cy.get(input).should("not.exist"); - cy.realPress("/"); - cy.get(input).should("be.focused"); - cy.realPress("/"); - cy.get(input).should("have.value", "/"); - cy.wait("@keyboardSearch").its("response.statusCode").should("equal", 200); - cy.realPress("Escape"); - cy.get(input).should("have.value", "").and("be.focused"); - cy.realPress("Escape"); - cy.get(input).should("not.exist"); - cy.realPress(["Control", "k"]); - cy.get(input).should("be.focused"); - cy.realPress("Escape"); - cy.get(input).should("not.exist"); -}); - -it("lazy search results navigate into documentation", () => { - cy.intercept( - { hostname: /\.(algolia\.net|algolianet\.com)$/, pathname: /\/queries$/ }, - { - statusCode: 200, - body: installationResults, - }, - ); - cy.visit("/"); - cy.get(search).click(); - cy.get(input).type("installation"); - cy.get(".DocSearch-Modal") - .contains("a", /^Installation$/) - .click(); - cy.location("pathname").should("equal", "/docs/manual/installation"); - cy.contains("h1", /^Installation$/).should("be.visible"); - cy.get(input).should("not.exist"); -}); - -it("a pending search load can be closed without opening the modal afterward", () => { - const download = Promise.withResolvers(); - cy.on("fail", (error) => { - download.resolve(); - throw error; - }); - cy.intercept(searchChunk, () => download.promise).as("searchChunk"); - cy.visit("/"); - cy.get(search).click(); - cy.get(close).should("be.visible"); - cy.realPress("Escape"); - cy.get(close).should("not.exist"); - cy.get(search).should("be.focused"); - cy.then(() => download.resolve()); - cy.wait("@searchChunk"); - cy.get(search).should("be.focused"); - cy.get(input).should("not.exist"); - cy.get(search).click(); - cy.get(input).should("be.focused"); -}); - -it("a failed search chunk leaves the page usable and recovers after a reload", () => { - let blockChunk = true; - cy.intercept(searchChunk, (request) => { - if (blockChunk) request.destroy(); - }); - cy.wrap( - [ - /Failed to fetch dynamically imported module: .*\/assets\/SearchModal-[^/]+\.js/, - ], - { log: false }, - ).as("expectedConsoleErrors"); - cy.visit("/"); - cy.get(search).click(); - cy.contains('[role="alert"]', "Search unavailable").should("be.visible"); - cy.get(close).click(); - cy.get('[role="alert"]').should("not.exist"); - cy.get(search).should("be.focused"); - cy.contains("h1", headline).should("be.visible"); - cy.then(() => { - blockChunk = false; - }); - cy.reload(); - cy.get(search).click(); - cy.get(input).should("be.focused"); -}); diff --git a/apps/docs/e2e/homepage/search-result.js b/apps/docs/e2e/homepage/search-result.js deleted file mode 100644 index 900d77b00..000000000 --- a/apps/docs/e2e/homepage/search-result.js +++ /dev/null @@ -1,34 +0,0 @@ -export const installationResults = { - results: [ - { - hits: [ - { - objectID: "installation", - url: "https://rescript-lang.org/docs/manual/installation", - url_without_anchor: - "https://rescript-lang.org/docs/manual/installation", - type: "lvl1", - anchor: null, - content: null, - hierarchy: { - lvl0: "ReScript", - lvl1: "Installation", - lvl2: null, - lvl3: null, - lvl4: null, - lvl5: null, - lvl6: null, - }, - }, - ], - nbHits: 1, - page: 0, - nbPages: 1, - hitsPerPage: 20, - processingTimeMS: 1, - query: "installation", - index: "test-index", - queryID: "homepage-search-test", - }, - ], -};