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..5baf7427e 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'; @@ -17,6 +14,7 @@ external utilsCss: 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'; @@ -32,6 +30,7 @@ external utilsCss: string = "default" hljs.registerLanguage('html', html) hljs.registerLanguage('diff', diff) hljs.registerLanguage('typescript', typescript) + hljs.registerLanguage('yaml', yaml) `) open ReactRouter @@ -45,7 +44,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/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index cf6730b36..f306de74d 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 @@ -9,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 @@ -36,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" @@ -60,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" @@ -72,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" @@ -82,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/HomepageSupport.res b/apps/docs/e2e/homepage/HomepageSupport.res index b080627a0..cb1678953 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,32 @@ 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.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 }) 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);