diff --git a/apps/docs/__tests__/LandingPageBehavior_.test.res b/apps/docs/__tests__/LandingPageBehavior_.test.res index b159b7c93..1d7b5bb20 100644 --- a/apps/docs/__tests__/LandingPageBehavior_.test.res +++ b/apps/docs/__tests__/LandingPageBehavior_.test.res @@ -1,20 +1,6 @@ open ReactRouter open Vitest -let expectedExample = `module Button = { - @react.component - let make = (~count) => { - let times = switch count { - | 1 => "once" - | 2 => "twice" - | n => n->Int.toString ++ " times" - } - let text = \`Click me $\{times\}\` - - - } -}` - let getOpenGraphImageUrl = () => { switch document->WebAPI.Document.querySelector("meta[property='og:image']") { | Value(meta) => @@ -29,7 +15,7 @@ let getOpenGraphImageUrl = () => { test("landing page Open Graph image targets its absolute page URL", async () => { let _screen = await render( - + , ) let pageUrl = WebAPI.URL.make(~url=Env.root_url) @@ -52,7 +38,7 @@ test( async () => { let screen = await render( - + , ) @@ -82,14 +68,14 @@ test( ->Nullable.toOption expect(decodedCode->Option.isSome)->toBe(true) - expect(decodedCode->Option.getOrThrow)->toBe(expectedExample) + expect(decodedCode->Option.getOrThrow)->toBe(LandingPageFixture.expectedExample) }, ) test("landing page playground hero renders highlighted code tokens", async () => { let screen = await render( - + , ) @@ -111,12 +97,21 @@ test("landing page playground hero renders highlighted code tokens", async () => expect(rescriptCodeBlock.innerHTML->String.includes("toBe(true) expect(javascriptCodeBlock.innerHTML->String.includes("toBe(true) + expect(rescriptCodeBlock.textContent->Null.toOption)->toEqual( + Some(LandingPageFixture.expectedExample), + ) + expect(javascriptCodeBlock.textContent->Null.toOption)->toEqual( + Some(LandingPageFixture.expectedJavascript), + ) + expect(rescriptCodeBlock.className)->toBe("hljs lang-res dark") + expect(javascriptCodeBlock.className)->toBe("hljs lang-js dark") + expect(rescriptCodeBlock->WebAPI.Element.querySelector("button")->Null.toOption)->toEqual(None) }) test("landing page renders its critical sections", async () => { let screen = await render( - + , ) @@ -142,7 +137,7 @@ test("landing page renders its critical sections", async () => { test("landing page copy button shows success feedback", async () => { let screen = await render( - + , ) @@ -160,7 +155,7 @@ test( let screen = await render( - + , ) diff --git a/apps/docs/__tests__/LandingPagePlaygroundLoader_.test.res b/apps/docs/__tests__/LandingPagePlaygroundLoader_.test.res new file mode 100644 index 000000000..b9c1389f8 --- /dev/null +++ b/apps/docs/__tests__/LandingPagePlaygroundLoader_.test.res @@ -0,0 +1,31 @@ +open Vitest + +test( + "homepage example loader produces only serializable highlighted code and its URL", + async () => { + let data = LandingPagePlaygroundLoader.build() + let serialized = data->JSON.stringifyAny->Option.getOrThrow + let decoded = JSON.parseOrThrow(serialized) + let expected = JSON.Object( + Dict.fromArray([ + ("rescriptHtml", JSON.String(data.rescriptHtml)), + ("javascriptHtml", JSON.String(data.javascriptHtml)), + ("playgroundHref", JSON.String(data.playgroundHref)), + ]), + ) + + expect(decoded)->toEqual(expected) + expect(data.rescriptHtml->String.includes("toBe(true) + expect(data.javascriptHtml->String.includes("toBe(true) + let {pathname, searchParams} = WebAPI.URL.make( + ~url=data.playgroundHref, + ~base="https://rescript-lang.org", + ) + let code = searchParams->WebAPI.URLSearchParams.get("code") + + expect(pathname)->toBe("/try") + expect(LzString.lzString.decompressFromEncodedURIComponent(code))->toBe( + LandingPageFixture.expectedExample, + ) + }, +) diff --git a/apps/docs/__tests__/visual/LandingPage_.test.res b/apps/docs/__tests__/visual/LandingPage_.test.res index 3dc6b6e44..cbab54d93 100644 --- a/apps/docs/__tests__/visual/LandingPage_.test.res +++ b/apps/docs/__tests__/visual/LandingPage_.test.res @@ -6,7 +6,7 @@ let snapshotSection = async (~width, ~height, ~sectionTestId, ~screenshotName) = let screen = await render( - + , ) diff --git a/apps/docs/app/routes/LandingPage.res b/apps/docs/app/routes/LandingPage.res index b9a63f28f..2cae95441 100644 --- a/apps/docs/app/routes/LandingPage.res +++ b/apps/docs/app/routes/LandingPage.res @@ -1,5 +1,5 @@ @react.component -let make = () => { +let make = (~playgroundData) => { <> {
- + diff --git a/apps/docs/app/routes/LandingPage.resi b/apps/docs/app/routes/LandingPage.resi index 54ccfceef..8430d7604 100644 --- a/apps/docs/app/routes/LandingPage.resi +++ b/apps/docs/app/routes/LandingPage.resi @@ -1,3 +1,3 @@ /** Use hosted Inter faces within the homepage without changing other routes. */ @react.component -let make: unit => React.element +let make: (~playgroundData: LandingPagePlayground.playgroundData) => React.element diff --git a/apps/docs/app/routes/LandingPageRoute.res b/apps/docs/app/routes/LandingPageRoute.res index 6cd64e3df..fd83445f4 100644 --- a/apps/docs/app/routes/LandingPageRoute.res +++ b/apps/docs/app/routes/LandingPageRoute.res @@ -23,8 +23,14 @@ let links = () => [ fontPreload("/fonts/red-hat-mono-700.woff2"), ] +type loaderData = {playgroundData: LandingPagePlayground.playgroundData} + +let loader: ReactRouter.Loader.t = async _ => { + playgroundData: LandingPagePlaygroundLoader.build(), +} + +@react.component let default = () => { - <> - - + let {playgroundData}: loaderData = ReactRouter.useLoaderData() + } diff --git a/apps/docs/app/routes/LandingPageRoute.resi b/apps/docs/app/routes/LandingPageRoute.resi index 90c8cc322..1c6062553 100644 --- a/apps/docs/app/routes/LandingPageRoute.resi +++ b/apps/docs/app/routes/LandingPageRoute.resi @@ -10,4 +10,10 @@ type fontPreload = { /** Preload above-the-fold faces, including Inter 700 only at the desktop breakpoint. */ let links: unit => array +type loaderData = {playgroundData: LandingPagePlayground.playgroundData} + +/** Prerender the fixed example so highlighting and URL compression stay off the client. */ +let loader: ReactRouter.Loader.t + +@react.component let default: unit => React.element diff --git a/apps/docs/e2e/bindings/Cypress.res b/apps/docs/e2e/bindings/Cypress.res index f306de74d..c104ed58c 100644 --- a/apps/docs/e2e/bindings/Cypress.res +++ b/apps/docs/e2e/bindings/Cypress.res @@ -88,6 +88,9 @@ external shouldValue: (chain, @as("have.value") _, string) => chain, @as("have.attr") _, string, string) => chain = "should" +@send +external shouldProperty: (chain, @as("have.prop") _, string, string) => chain = + "should" @send external attribute: (chain, @as("attr") _, string) => chain = "invoke" @send external propertyInt: (chain<'a>, string) => chain = "its" @send external shouldSatisfy: (chain<'a>, 'a => unit) => chain<'a> = "should" @@ -125,6 +128,7 @@ type parser @get external documentElement: Dom.document => Dom.element = "documentElement" @get external currentScript: Dom.document => Null.t = "currentScript" @get external textContent: Dom.element => Null.t = "textContent" +@get external innerHTML: Dom.element => string = "innerHTML" @get external outerHTML: Dom.element => string = "outerHTML" @send external getAttribute: (Dom.element, string) => Null.t = "getAttribute" diff --git a/apps/docs/e2e/homepage/HomepageHighlighting.cy.res b/apps/docs/e2e/homepage/HomepageHighlighting.cy.res new file mode 100644 index 000000000..fa6a82b36 --- /dev/null +++ b/apps/docs/e2e/homepage/HomepageHighlighting.cy.res @@ -0,0 +1,61 @@ +open Cypress +open HomepageHelpers + +it("initial homepage scripts do not include example preparation or compression", () => { + homepageDocument(document => { + let scripts = document->initialScriptUrls + expect(scripts->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("compressToEncodedURIComponent") + expect(response.body, ~message=asset)->notInclude("function Playground$Button(props)") + }, + ) + ->ignore, + ) + }) +}) + +it("prepared examples survive hydration and navigation back from documentation", () => { + homepageDocument(document => { + let examples = ["res", "js"]->Array.map( + language => { + let selector = `code.lang-${language}` + let html = + document + ->querySelector(selector) + ->Null.toOption + ->Option.map(element => element->innerHTML) + ->Option.getOrThrow + (selector, html) + }, + ) + let playgroundHref = + document + ->querySelector(`a[href^="/try?code="]`) + ->Null.toOption + ->Option.flatMap(element => element->getAttribute("href")->Null.toOption) + ->Option.getOrThrow + expect(playgroundHref)->match_(/^\/try\?code=.+/) + examples->Array.forEach(((_, html)) => expect(html)->include_(`Array.forEach( + ((selector, html)) => get(selector)->shouldProperty("innerHTML", html)->ignore, + ) + containsInRegex("a", /^Docs$/)->click->ignore + cyLocation("pathname")->shouldEqual("/docs/manual/introduction")->ignore + get(`a[aria-label="homepage"]`)->click->ignore + cyLocation("pathname")->shouldEqual("/")->ignore + examples->Array.forEach( + ((selector, html)) => + get(selector)->should("be.visible")->shouldProperty("innerHTML", html)->ignore, + ) + containsIn("a", "Edit this example in Playground") + ->shouldAttribute("href", playgroundHref) + ->ignore + }) +}) diff --git a/apps/docs/src/bindings/HighlightJsBindings.res b/apps/docs/src/bindings/HighlightJsBindings.res new file mode 100644 index 000000000..cbfe747c0 --- /dev/null +++ b/apps/docs/src/bindings/HighlightJsBindings.res @@ -0,0 +1,19 @@ +type t +type language +type highlightOptions = {language: string} +type highlightResult = {value: string} + +@module("highlight.js/lib/core") @scope("default") +external make: unit => t = "newInstance" + +@module("highlight.js/lib/languages/javascript") +external javascript: language = "default" + +@module("highlightjs-rescript") +external rescript: language = "default" + +@send +external registerLanguage: (t, string, language) => unit = "registerLanguage" + +@send +external highlight: (t, string, highlightOptions) => highlightResult = "highlight" diff --git a/apps/docs/src/components/LandingPagePlayground.res b/apps/docs/src/components/LandingPagePlayground.res index a4211005b..cd43452c1 100644 --- a/apps/docs/src/components/LandingPagePlayground.res +++ b/apps/docs/src/components/LandingPagePlayground.res @@ -1,46 +1,11 @@ -type example = { - res: string, - js: string, -} - -let example = { - res: `module Button = { - @react.component - let make = (~count) => { - let times = switch count { - | 1 => "once" - | 2 => "twice" - | n => n->Int.toString ++ " times" - } - let text = \`Click me $\{times\}\` - - - } -}`, - js: `import * as JsxRuntime from "react/jsx-runtime"; - -function Playground$Button(props) { - let count = props.count; - let times = count !== 1 ? ( - count !== 2 ? count.toString() + " times" : "twice" - ) : "once"; - let text = "Click me " + times; - return JsxRuntime.jsx("button", { - children: text - }); -} - -let Button = { - make: Playground$Button -}; - -export { - Button, -}`, +type playgroundData = { + rescriptHtml: string, + javascriptHtml: string, + playgroundHref: string, } @react.component -let make = () => { +let make = (~playgroundData: playgroundData) => {
{ {React.string("Write in ReScript")}
-            {HighlightJs.renderHLJS(~darkmode=true, ~code=example.res, ~lang="res", ())}
+            
           
@@ -63,12 +31,15 @@ let make = () => { {React.string("Compile to JavaScript")}
-            {HighlightJs.renderHLJS(~darkmode=true, ~code=example.js, ~lang="js", ())}
+            
           
{React.string("Edit this example in Playground")} diff --git a/apps/docs/src/components/LandingPagePlayground.resi b/apps/docs/src/components/LandingPagePlayground.resi index 1ca44ce26..fa01d2021 100644 --- a/apps/docs/src/components/LandingPagePlayground.resi +++ b/apps/docs/src/components/LandingPagePlayground.resi @@ -1,2 +1,9 @@ +type playgroundData = { + rescriptHtml: string, + javascriptHtml: string, + playgroundHref: string, +} + +/** Render only trusted highlighted markup produced by the homepage loader. */ @react.component -let make: unit => React.element +let make: (~playgroundData: playgroundData) => React.element diff --git a/apps/docs/src/data/LandingPagePlaygroundLoader.res b/apps/docs/src/data/LandingPagePlaygroundLoader.res new file mode 100644 index 000000000..c8f946591 --- /dev/null +++ b/apps/docs/src/data/LandingPagePlaygroundLoader.res @@ -0,0 +1,57 @@ +type example = { + res: string, + js: string, +} + +let example = { + res: `module Button = { + @react.component + let make = (~count) => { + let times = switch count { + | 1 => "once" + | 2 => "twice" + | n => n->Int.toString ++ " times" + } + let text = \`Click me $\{times\}\` + + + } +}`, + js: `import * as JsxRuntime from "react/jsx-runtime"; + +function Playground$Button(props) { + let count = props.count; + let times = count !== 1 ? ( + count !== 2 ? count.toString() + " times" : "twice" + ) : "once"; + let text = "Click me " + times; + return JsxRuntime.jsx("button", { + children: text + }); +} + +let Button = { + make: Playground$Button +}; + +export { + Button, +}`, +} + +let build = (): LandingPagePlayground.playgroundData => { + // Keep prerendering independent of global language registration and other routes. + let highlighter = HighlightJsBindings.make() + highlighter->HighlightJsBindings.registerLanguage("rescript", HighlightJsBindings.rescript) + highlighter->HighlightJsBindings.registerLanguage("javascript", HighlightJsBindings.javascript) + + { + rescriptHtml: ( + highlighter->HighlightJsBindings.highlight(example.res, {language: "rescript"}) + ).value, + javascriptHtml: ( + highlighter->HighlightJsBindings.highlight(example.js, {language: "javascript"}) + ).value, + playgroundHref: `/try?code=${LzString.lzString.compressToEncodedURIComponent(example.res)}`, + } +} diff --git a/apps/docs/src/data/LandingPagePlaygroundLoader.resi b/apps/docs/src/data/LandingPagePlaygroundLoader.resi new file mode 100644 index 000000000..407a5e468 --- /dev/null +++ b/apps/docs/src/data/LandingPagePlaygroundLoader.resi @@ -0,0 +1,2 @@ +/** Build trusted highlighted markup and the share URL for the fixed homepage example. */ +let build: unit => LandingPagePlayground.playgroundData diff --git a/apps/docs/test-utils/LandingPageFixture.res b/apps/docs/test-utils/LandingPageFixture.res new file mode 100644 index 000000000..b2a5ce3e4 --- /dev/null +++ b/apps/docs/test-utils/LandingPageFixture.res @@ -0,0 +1,36 @@ +let expectedExample = `module Button = { + @react.component + let make = (~count) => { + let times = switch count { + | 1 => "once" + | 2 => "twice" + | n => n->Int.toString ++ " times" + } + let text = \`Click me $\{times\}\` + + + } +}` + +let expectedJavascript = `import * as JsxRuntime from "react/jsx-runtime"; + +function Playground$Button(props) { + let count = props.count; + let times = count !== 1 ? ( + count !== 2 ? count.toString() + " times" : "twice" + ) : "once"; + let text = "Click me " + times; + return JsxRuntime.jsx("button", { + children: text + }); +} + +let Button = { + make: Playground$Button +}; + +export { + Button, +}` + +let playgroundData = LandingPagePlaygroundLoader.build()