diff --git a/.gitignore b/.gitignore index 0677c6b6a..1594fa311 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules/ .next/ out/ apps/docs/out/ +apps/guide/out/ index_data/*.json apps/docs/index_data/*.json @@ -25,12 +26,15 @@ apps/docs/temp-jsx-preserve .merlin lib/ apps/docs/lib/ +apps/guide/lib/ packages/*/lib/ .vercel apps/docs/src/**/*.mjs apps/docs/src/**/*.jsx +apps/guide/src/**/*.mjs +apps/guide/src/**/*.jsx packages/*/src/**/*.mjs packages/*/src/**/*.jsx apps/docs/scripts/gendocs.mjs @@ -51,17 +55,26 @@ dist build apps/docs/dist apps/docs/build +apps/guide/dist +apps/guide/build .react-router apps/docs/.react-router +apps/guide/.react-router mdx-manifest.json apps/docs/mdx-manifest.json apps/docs/app/**/*.mjs apps/docs/app/**/*.jsx +apps/guide/app/**/*.mjs +apps/guide/app/**/*.jsx +!apps/guide/app/root.jsx +!apps/guide/app/routes.jsx apps/docs/functions/**/*.mjs apps/docs/functions/**/*.jsx apps/docs/__tests__/**/*.mjs apps/docs/__tests__/**/*.jsx +apps/guide/__tests__/**/*.mjs +apps/guide/__tests__/**/*.jsx apps/docs/e2e/**/*.mjs apps/docs/e2e/**/*.jsx !_shims.mjs @@ -94,3 +107,4 @@ apps/docs/.env.local !apps/docs/__tests__/__screenshots__/**/* .vitest-attachments apps/docs/.vitest-attachments +apps/guide/.vitest-attachments diff --git a/apps/docs/src/components/Search.res b/apps/docs/src/components/Search.res index 42c5e7c41..4a0b213f1 100644 --- a/apps/docs/src/components/Search.res +++ b/apps/docs/src/components/Search.res @@ -68,9 +68,9 @@ let normalizeHitUrls = (items: array, ~siteUrl: string) {...hit, url, url_without_anchor, hierarchy} }) -let navigator = (~siteUrl: string): DocSearch.navigator => { +let navigator = (~siteUrl: string, ~navigate: ReactRouter.navigate): DocSearch.navigator => { navigate: ({itemUrl}) => { - ReactRouter.navigate(toRelativeSiteUrl(itemUrl, ~siteUrl)) + navigate(toRelativeSiteUrl(itemUrl, ~siteUrl)) }, } @@ -301,6 +301,44 @@ module ErrorBoundary = { } } +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 + initialScrollY={window.scrollY->Float.toInt} + searchParameters={ + distinct: 3, + hitsPerPage: 20, + attributesToSnippet: ["content:9999"], + } + /> + , + element, + ) + | None => React.null + } + } +} + @react.component let make = () => { let (state, setState) = React.useState(_ => Inactive) @@ -392,31 +430,7 @@ let make = () => { {switch state { - | Active => - switch ReactDOM.querySelector("body") { - | Some(element) => - ReactDOM.createPortal( - - normalizeHitUrls(items, ~siteUrl=Env.root_url)} - hitComponent - onClose - initialScrollY={window.scrollY->Float.toInt} - searchParameters={ - distinct: 3, - hitsPerPage: 20, - attributesToSnippet: ["content:9999"], - } - /> - , - element, - ) - | None => React.null - } + | Active => | Inactive => React.null }} diff --git a/apps/guide/__tests__/GuideCompilerFeedback_.test.res b/apps/guide/__tests__/GuideCompilerFeedback_.test.res new file mode 100644 index 000000000..c426035bb --- /dev/null +++ b/apps/guide/__tests__/GuideCompilerFeedback_.test.res @@ -0,0 +1,83 @@ +open Vitest + +test("maps compiler diagnostics into editor errors", async () => { + let locMsg: RescriptCompilerApi.LocMsg.t = { + fullMsg: "Full compiler error", + shortMsg: "Expected a string", + row: 2, + column: 7, + endRow: 2, + endColumn: 12, + } + + let editorError = GuideCompilerFeedback.locMsgToEditorError(~kind=#Error, locMsg) + + expect(editorError.row)->toBe(2) + expect(editorError.column)->toBe(7) + expect(editorError.endRow)->toBe(2) + expect(editorError.endColumn)->toBe(12) + expect(editorError.text)->toBe("Expected a string") + + let outputLine = + GuideCompilerFeedback.compileFailToOutputLines(TypecheckErr([locMsg])) + ->Array.get(0) + ->Option.getOrThrow + expect(outputLine)->toBe("[E] Line 2, 7: Expected a string") +}) + +test("maps compiler type hints into hover hints", async () => { + let typeHint = RescriptCompilerApi.TypeHint.Binding({ + start: {line: 1, col: 4}, + end: {line: 1, col: 12}, + hint: "string", + }) + + let hoverHint = + GuideCompilerFeedback.typeHintsToHoverHints([typeHint])->Array.get(0)->Option.getOrThrow + + expect(hoverHint.start.line)->toBe(1) + expect(hoverHint.start.col)->toBe(4) + expect(hoverHint.end.line)->toBe(1) + expect(hoverHint.end.col)->toBe(12) + expect(hoverHint.hint)->toBe("string") +}) + +test("keeps the previous output while a successful compile waits for runtime logs", async () => { + let typeHint = RescriptCompilerApi.TypeHint.Binding({ + start: {line: 1, col: 4}, + end: {line: 1, col: 12}, + hint: "string", + }) + + let outputUpdate = GuideCompilerFeedback.compilationResultToOutputUpdate( + Success({ + jsCode: "let value = 52;", + warnings: [], + typeHints: [typeHint], + time: 1.0, + }), + ) + + expect(outputUpdate->Option.isNone)->toBe(true) +}) + +test("shows compiler errors as output updates", async () => { + let locMsg: RescriptCompilerApi.LocMsg.t = { + fullMsg: "Full compiler error", + shortMsg: "Expected a string", + row: 2, + column: 7, + endRow: 2, + endColumn: 12, + } + + let output = + GuideCompilerFeedback.compilationResultToOutputUpdate( + Fail(TypecheckErr([locMsg])), + )->Option.getOrThrow + + expect(output.status)->toBe("Compiler error") + expect(output.diagnostics->Array.get(0)->Option.getOrThrow)->toBe( + "[E] Line 2, 7: Expected a string", + ) +}) diff --git a/apps/guide/__tests__/GuideCompilerSettings_.test.res b/apps/guide/__tests__/GuideCompilerSettings_.test.res new file mode 100644 index 000000000..853643400 --- /dev/null +++ b/apps/guide/__tests__/GuideCompilerSettings_.test.res @@ -0,0 +1,18 @@ +open Vitest + +test("selects the latest stable ReScript compiler with ESM output", async () => { + let version = + GuideCompilerSettings.latestStableVersion([ + "v12.1.0-alpha.1", + "v11.1.4", + "v12.0.0", + "v12.2.0-beta.1", + "v12.1.3", + ]) + ->Option.getOrThrow + ->Semver.toString + + expect(version)->toBe("v12.1.3") + expect(GuideCompilerSettings.moduleSystem)->toBe("esmodule") + expect(GuideCompilerSettings.warnFlags->String.includes("-109"))->toBe(true) +}) diff --git a/apps/guide/__tests__/GuideHome_.test.res b/apps/guide/__tests__/GuideHome_.test.res new file mode 100644 index 000000000..21fade5f9 --- /dev/null +++ b/apps/guide/__tests__/GuideHome_.test.res @@ -0,0 +1,210 @@ +open Vitest + +let firstLesson = GuideTestFixtures.firstLesson +let secondLesson = GuideTestFixtures.secondLesson +let renderGuideHome = GuideTestFixtures.renderGuideHome +let renderGuideHomeWithDocsIntroNavigation = GuideTestFixtures.renderGuideHomeWithDocsIntroNavigation +let renderGuideHomeInBrowser = GuideTestFixtures.renderGuideHomeInBrowser + +test("loads saved guide editor code into the editor", async () => { + await viewport(1440, 900) + let exerciseId = firstLesson.exercise.id + GuideLayout.clearExerciseCode(exerciseId) + GuideLayout.saveExerciseCode(~exerciseId, ~code="let sisko = \"emissary\"") + + let screen = await renderGuideHome() + let editor = await screen->getByTestId("guide-code-editor") + + await editor->element->toHaveTextContent("let sisko = \"emissary\"") + + GuideLayout.clearExerciseCode(exerciseId) +}) + +test("renders resize handles and toggles dark mode", async () => { + await viewport(1440, 900) + + let screen = await renderGuideHome() + let shell = await screen->getByTestId("guide-mvp") + let columnHandle = await screen->getByTestId("guide-column-resize") + let rowHandle = await screen->getByTestId("guide-row-resize") + let themeToggle = await screen->getByLabelText("Switch to dark mode") + + await columnHandle->element->toBeVisible + await rowHandle->element->toBeVisible + await shell->element->toHaveClass("guide-theme-light") + await themeToggle->click + await shell->element->toHaveClass("guide-theme-dark") +}) + +test("shows a minimum screen size message on narrow viewports", async () => { + await viewport(800, 900) + + let screen = await renderGuideHome() + let message = await screen->getByText("This guide needs a wider screen.") + + await message->element->toBeVisible +}) + +test("shows the first checkpoint as complete when output matches", async () => { + await viewport(1440, 900) + + let screen = await renderGuideHome() + let checkpoint = await screen->getByTestId("guide-check-status") + + await checkpoint->element->toHaveTextContent("Checkpoint complete") +}) + +test("navigates to the function argument page", async () => { + await viewport(1440, 900) + GuideLayout.clearCompletedExercises() + GuideLayout.clearExerciseCode(secondLesson.exercise.id) + + let screen = await renderGuideHome() + let nextButton = await screen->getByText("Next") + + await nextButton->click + + await (await screen->getByText("Call A Function"))->element->toBeVisible + await (await screen->getByText("Change the argument passed to greet from ReScript to Spock.")) + ->element + ->toBeVisible + let editor = await screen->getByTestId("guide-code-editor") + await editor->element->toHaveTextContent(`let greet = name => "Hello, " ++ name ++ "!"`) + await editor->element->toHaveTextContent(`let greeting = greet("ReScript")`) + let checkpoint = await screen->getByTestId("guide-check-status") + await checkpoint->element->toHaveTextContent("Waiting for matching output") + + GuideLayout.clearExerciseCode(secondLesson.exercise.id) +}) + +let guideTestUrl = hash => window.location.pathname ++ window.location.search ++ hash + +let resetGuideTestUrl = () => + WebAPI.History.replaceState(window.history, ~data=JSON.Null, ~unused="", ~url=guideTestUrl("")) + +test("shows Back before lesson forward actions and returns to the previous lesson", async () => { + await viewport(1440, 900) + GuideLayout.clearCompletedExercises() + GuideLayout.clearExerciseCode(firstLesson.exercise.id) + GuideLayout.clearExerciseCode(secondLesson.exercise.id) + + let screen = await renderGuideHome(~initialEntries=["/#first-contact"], ()) + let firstLessonText = screen->container->textContent->Nullable.toOption->Option.getOrThrow + let beforeNext = firstLessonText->String.split("Next")->Array.get(0)->Option.getOrThrow + + expect(beforeNext->String.includes("Back"))->toBe(true) + await (await screen->getByText("Back"))->element->toBeVisible + await (await screen->getByText("Next"))->click + + await (await screen->getByText("Call A Function"))->element->toBeVisible + + let secondLessonText = screen->container->textContent->Nullable.toOption->Option.getOrThrow + let beforeDone = secondLessonText->String.split("Done")->Array.get(0)->Option.getOrThrow + + expect(beforeDone->String.includes("Back"))->toBe(true) + await (await screen->getByText("Back"))->click + await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible + + GuideLayout.clearExerciseCode(firstLesson.exercise.id) + GuideLayout.clearExerciseCode(secondLesson.exercise.id) +}) + +test("enables Done on a completed final lesson", async () => { + await viewport(1440, 900) + GuideLayout.clearCompletedExercises() + GuideLayout.clearExerciseCode(secondLesson.exercise.id) + GuideLayout.saveCompletedExercise(secondLesson.exercise.id) + + let screen = await renderGuideHome(~initialEntries=["/#functions"], ()) + let doneButton = await screen->getByText("Done") + + await doneButton->element->notToBeDisabled + + GuideLayout.clearCompletedExercises() + GuideLayout.clearExerciseCode(secondLesson.exercise.id) +}) + +test("Done on a completed final lesson opens the ReScript docs intro", async () => { + await viewport(1440, 900) + GuideLayout.clearCompletedExercises() + GuideLayout.clearExerciseCode(secondLesson.exercise.id) + GuideLayout.saveCompletedExercise(secondLesson.exercise.id) + let openedUrl = ref("") + + let screen = await renderGuideHomeWithDocsIntroNavigation( + url => openedUrl.contents = url, + ~initialEntries=["/#functions"], + ) + + await (await screen->getByText("Done"))->click + + expect(openedUrl.contents)->toBe(GuideLessonNavigationHook.docsIntroUrl) + + GuideLayout.clearCompletedExercises() + GuideLayout.clearExerciseCode(secondLesson.exercise.id) +}) + +test("browser back returns to the previous guide lesson", async () => { + await viewport(1440, 900) + GuideLayout.clearCompletedExercises() + GuideLayout.clearExerciseCode(firstLesson.exercise.id) + GuideLayout.clearExerciseCode(secondLesson.exercise.id) + WebAPI.History.replaceState( + window.history, + ~data=JSON.Null, + ~unused="", + ~url=guideTestUrl("#guide-test-start"), + ) + WebAPI.History.pushState( + window.history, + ~data=JSON.Null, + ~unused="", + ~url=guideTestUrl("#first-contact"), + ) + + let screen = await renderGuideHomeInBrowser() + + await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible + await (await screen->getByText("Next"))->click + await (await screen->getByText("Call A Function"))->element->toBeVisible + + WebAPI.History.back(window.history) + + await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible + + GuideLayout.clearExerciseCode(firstLesson.exercise.id) + GuideLayout.clearExerciseCode(secondLesson.exercise.id) + resetGuideTestUrl() +}) + +test("stretches the output surface to the full output panel", async () => { + await viewport(1440, 900) + + let screen = await renderGuideHome() + let output = await screen->getByTestId("guide-output") + + await output->element->toHaveClass("guide-output-frame") +}) + +test("renders the first guide MVP exercise and output", async () => { + await viewport(1440, 900) + + let screen = await renderGuideHome() + + await (await screen->getByText("Learn ReScript Guide"))->element->toBeVisible + await ( + await screen->getByText( + "The editor runs automatically. For this first checkpoint, the final value should print hello, world! in the output log.", + ) + ) + ->element + ->toBeVisible + await (await screen->getByText("Next"))->element->toBeVisible + let editor = await screen->getByTestId("guide-code-editor") + await editor->element->toBeVisible + await editor->element->toHaveTextContent("let greeting = \"hello, world!\"") + let output = await screen->getByTestId("guide-output") + await output->element->toBeVisible + + await output->element->toHaveTextContent("hello, world!") +}) diff --git a/apps/guide/__tests__/GuideLayout_.test.res b/apps/guide/__tests__/GuideLayout_.test.res new file mode 100644 index 000000000..11e1672c7 --- /dev/null +++ b/apps/guide/__tests__/GuideLayout_.test.res @@ -0,0 +1,65 @@ +open Vitest + +test("clamps resized pane dimensions", async () => { + expect(GuideLayout.clampInstructionsWidth(~viewportWidth=1200.0, ~pointerX=120.0))->toBe(320.0) + expect(GuideLayout.clampInstructionsWidth(~viewportWidth=1200.0, ~pointerX=900.0))->toBe(720.0) + expect(GuideLayout.clampInstructionsWidth(~viewportWidth=1200.0, ~pointerX=520.0))->toBe(520.0) + + expect(GuideLayout.clampOutputHeight(~viewportHeight=900.0, ~pointerY=820.0))->toBe(160.0) + expect(GuideLayout.clampOutputHeight(~viewportHeight=900.0, ~pointerY=120.0))->toBe(660.0) + expect(GuideLayout.clampOutputHeight(~viewportHeight=900.0, ~pointerY=640.0))->toBe(260.0) +}) + +test("serializes pane sizes as guide CSS variables", async () => { + let style = GuideLayout.paneSizesStyle({ + instructionsWidth: Some(420.0), + outputHeight: 250.0, + }) + + expect(style->String.includes("--guide-instructions-width: 420px"))->toBe(true) + expect(style->String.includes("--guide-output-height: 250px"))->toBe(true) +}) + +test("stores resized pane dimensions in local storage", async () => { + GuideLayout.clearPaneSizes() + + GuideLayout.savePaneSizes({ + instructionsWidth: Some(420.0), + outputHeight: 250.0, + }) + + let savedPaneSizes = GuideLayout.loadPaneSizes() + let savedInstructionsWidth = + savedPaneSizes.instructionsWidth->Option.map(width => width->Float.toString)->Option.getOrThrow + + expect(savedInstructionsWidth)->toBe("420") + expect(savedPaneSizes.outputHeight)->toBe(250.0) + + GuideLayout.clearPaneSizes() +}) + +test("stores guide exercise code in local storage", async () => { + let exerciseId = GuideTestFixtures.firstLesson.exercise.id + GuideLayout.clearExerciseCode(exerciseId) + + GuideLayout.saveExerciseCode(~exerciseId, ~code="let voyager = 1701") + + expect(GuideLayout.loadExerciseCode(exerciseId)->Option.getOrThrow)->toBe("let voyager = 1701") + + GuideLayout.clearExerciseCode(exerciseId) +}) + +test("stores completed guide exercises in local storage", async () => { + let exerciseId = GuideTestFixtures.firstLesson.exercise.id + GuideLayout.clearCompletedExercises() + + GuideLayout.saveCompletedExercise(exerciseId) + GuideLayout.saveCompletedExercise(exerciseId) + + let completedExerciseIds = GuideLayout.loadCompletedExerciseIds() + + expect(completedExerciseIds->Array.includes(exerciseId))->toBe(true) + expect(completedExerciseIds->Array.length)->toBe(1) + + GuideLayout.clearCompletedExercises() +}) diff --git a/apps/guide/__tests__/GuideLesson_.test.res b/apps/guide/__tests__/GuideLesson_.test.res new file mode 100644 index 000000000..47dbea768 --- /dev/null +++ b/apps/guide/__tests__/GuideLesson_.test.res @@ -0,0 +1,59 @@ +open Vitest + +let firstLesson = GuideTestFixtures.firstLesson +let secondLesson = GuideTestFixtures.secondLesson +let guideLessons = GuideTestFixtures.guideLessons + +test("checks expected output for the first lesson exercise", async () => { + let matchingOutput = GuideCompilerFeedback.Output.make( + ~status="Output", + ~runtimeLogs=[{GuideCompilerFeedback.Output.level: #log, content: ["hello, world!"]}], + ) + let nonMatchingOutput = GuideCompilerFeedback.Output.make( + ~status="Output", + ~runtimeLogs=[{GuideCompilerFeedback.Output.level: #log, content: ["goodbye"]}], + ) + + expect(firstLesson.exercise.initialCode)->toBe(`let greeting = "hello, world!"`) + expect( + GuideLesson.isExerciseComplete(~exercise=firstLesson.exercise, ~output=matchingOutput), + )->toBe(true) + expect( + GuideLesson.isExerciseComplete(~exercise=firstLesson.exercise, ~output=nonMatchingOutput), + )->toBe(false) +}) + +test("checks expected output for the function argument exercise", async () => { + let matchingOutput = GuideCompilerFeedback.Output.make( + ~status="Output", + ~runtimeLogs=[{GuideCompilerFeedback.Output.level: #log, content: ["Hello, Spock!"]}], + ) + let nonMatchingOutput = GuideCompilerFeedback.Output.make( + ~status="Output", + ~runtimeLogs=[{GuideCompilerFeedback.Output.level: #log, content: ["Hello, ReScript!"]}], + ) + + expect(secondLesson.exercise.initialCode)->toBe(`let greet = name => "Hello, " ++ name ++ "!" + +let greeting = greet("ReScript")`) + expect( + GuideLesson.isExerciseComplete(~exercise=secondLesson.exercise, ~output=matchingOutput), + )->toBe(true) + expect( + GuideLesson.isExerciseComplete(~exercise=secondLesson.exercise, ~output=nonMatchingOutput), + )->toBe(false) +}) + +test("orders guide lessons by position", async () => { + let ordered = [secondLesson, firstLesson]->GuideLesson.sort + + expect(ordered->Array.get(0)->Option.getOrThrow)->toBe(firstLesson) + expect(ordered->Array.get(1)->Option.getOrThrow)->toBe(secondLesson) +}) + +test("resolves lesson hashes and falls back to the first lesson", async () => { + expect(GuideLesson.indexForHash(~lessons=guideLessons, "#functions"))->toBe(1) + expect(GuideLesson.indexForHash(~lessons=guideLessons, "functions"))->toBe(1) + expect(GuideLesson.indexForHash(~lessons=guideLessons, "#missing"))->toBe(0) + expect(GuideLesson.lessonAt(~lessons=guideLessons, 99))->toBe(firstLesson) +}) diff --git a/apps/guide/__tests__/GuideOutputPanel_.test.res b/apps/guide/__tests__/GuideOutputPanel_.test.res new file mode 100644 index 000000000..0cf2003de --- /dev/null +++ b/apps/guide/__tests__/GuideOutputPanel_.test.res @@ -0,0 +1,37 @@ +open Vitest + +test("renders output diagnostics as error lines", async () => { + let output = GuideCompilerFeedback.Output.make( + ~status="Compiler error", + ~diagnostics=["[E] Line 2, 7: Expected a string"], + ) + + let screen = await render() + let diagnostic = await screen->getByText("[E] Line 2, 7: Expected a string") + + await diagnostic->element->toHaveClass("guide-output-line-error") +}) + +test("renders runtime logs in the output panel", async () => { + let output = GuideCompilerFeedback.Output.make( + ~status="Output", + ~runtimeLogs=[{GuideCompilerFeedback.Output.level: #log, content: ["52"]}], + ) + + let screen = await render() + + await (await screen->getByText("Result"))->element->toBeVisible + await (await screen->getByText("52"))->element->toBeVisible +}) + +test("does not render redundant output status inside the output panel", async () => { + let output = GuideCompilerFeedback.Output.make( + ~status="Output", + ~runtimeLogs=[{GuideCompilerFeedback.Output.level: #log, content: ["52"]}], + ) + + let screen = await render() + let outputText = screen->container->textContent->Nullable.toOption->Option.getOrThrow + + expect(outputText->String.includes("Output"))->toBe(false) +}) diff --git a/apps/guide/__tests__/GuideRuntime_.test.res b/apps/guide/__tests__/GuideRuntime_.test.res new file mode 100644 index 000000000..8c708ea54 --- /dev/null +++ b/apps/guide/__tests__/GuideRuntime_.test.res @@ -0,0 +1,141 @@ +open Vitest + +test("rewrites the compiled final expression into a console log", async () => { + let result = GuideRuntimeTransform.transform(`let value = 52; +value; + +export { + value, +};`) + + let {code, imports} = result->Option.getOrThrow + + expect(code->String.includes("console.log(value)"))->toBe(true) + expect(code->String.includes("export"))->toBe(false) + expect(imports->Dict.keysToArray->Array.length)->toBe(0) +}) + +test("rewrites the guide runtime result binding into a console log", async () => { + let result = GuideRuntimeTransform.transform( + ~resultBindingName=GuideRuntimeSource.resultBindingName, + `let __rescriptGuideOutput = 52; + +export { + __rescriptGuideOutput, +};`, + ) + + let {code} = result->Option.getOrThrow + expect(code->String.includes("console.log(__rescriptGuideOutput)"))->toBe(true) +}) + +test("rewrites the last compiled binding into a console log", async () => { + let result = GuideRuntimeTransform.transform(`let greeting = "hello, world!"; + +export { + greeting, +};`) + + let {code} = result->Option.getOrThrow + expect(code->String.includes("console.log(greeting)"))->toBe(true) +}) + +test("skips runtime execution when compiled code has no expression or binding", async () => { + let result = GuideRuntimeTransform.transform(`export {};`) + + expect(result->Option.isNone)->toBe(true) +}) + +test("instruments the compiler-reported final expression range", async () => { + let code = `let greeting = "hello, world!" + +greeting` + let typeHints = [ + RescriptCompilerApi.TypeHint.Binding({ + start: {line: 1, col: 0}, + end: {line: 1, col: 30}, + hint: "string", + }), + RescriptCompilerApi.TypeHint.Expression({ + start: {line: 3, col: 0}, + end: {line: 3, col: 8}, + hint: "string", + }), + ] + + let instrumentedCode = GuideRuntimeSource.instrument(~code, ~typeHints)->Option.getOrThrow + + expect(instrumentedCode->String.includes("let __rescriptGuideOutput = (greeting)"))->toBe(true) +}) + +test("prefers the outermost latest expression range", async () => { + let code = `let add = (a, b) => a + b + +add(10, 42)` + let typeHints = [ + RescriptCompilerApi.TypeHint.Expression({ + start: {line: 3, col: 8}, + end: {line: 3, col: 10}, + hint: "int", + }), + RescriptCompilerApi.TypeHint.Expression({ + start: {line: 3, col: 0}, + end: {line: 3, col: 11}, + hint: "int", + }), + RescriptCompilerApi.TypeHint.Binding({ + start: {line: 1, col: 0}, + end: {line: 1, col: 25}, + hint: "(int, int) => int", + }), + ] + + let instrumentedCode = GuideRuntimeSource.instrument(~code, ~typeHints)->Option.getOrThrow + + expect(instrumentedCode->String.includes("let __rescriptGuideOutput = (add(10, 42))"))->toBe(true) +}) + +test("does not instrument expressions nested inside a binding", async () => { + let code = `let value = 52` + let typeHints = [ + RescriptCompilerApi.TypeHint.Expression({ + start: {line: 1, col: 12}, + end: {line: 1, col: 14}, + hint: "int", + }), + ] + + expect(GuideRuntimeSource.instrument(~code, ~typeHints)->Option.isNone)->toBe(true) +}) + +test("normalizes old compiler runtime import filenames", async () => { + let alpha7 = Semver.parse("v12.0.0-alpha.7")->Option.getOrThrow + let oldV11 = Semver.parse("v11.1.4")->Option.getOrThrow + let stableV12 = Semver.parse("v12.0.0")->Option.getOrThrow + + expect( + GuideRuntimeImport.filenameForCompiler(~compilerVersion=alpha7, "./stdlib/core__array"), + )->toBe("Array") + expect( + GuideRuntimeImport.filenameForCompiler(~compilerVersion=oldV11, "./stdlib/core__array"), + )->toBe("Core__array") + expect( + GuideRuntimeImport.filenameForCompiler(~compilerVersion=stableV12, "./stdlib/core__array"), + )->toBe("core__array") +}) + +test("normalizes old compiler versions for runtime imports", async () => { + let alpha7 = Semver.parse("v12.0.0-alpha.7")->Option.getOrThrow + let alpha9 = Semver.parse("v12.0.0-alpha.9")->Option.getOrThrow + let oldV11 = Semver.parse("v11.1.4")->Option.getOrThrow + + expect(GuideRuntimeImport.compilerVersionForRuntimeImport(alpha7)->Semver.toString)->toBe( + "v12.0.0-alpha.9", + ) + expect(GuideRuntimeImport.compilerVersionForRuntimeImport(alpha9)->Semver.toString)->toBe( + "v12.0.0-alpha.9", + ) + expect(GuideRuntimeImport.compilerVersionForRuntimeImport(oldV11)->Semver.toString)->toBe( + "v11.2.0-beta.2", + ) +}) diff --git a/apps/guide/__tests__/GuideTestFixtures.res b/apps/guide/__tests__/GuideTestFixtures.res new file mode 100644 index 000000000..214b9d179 --- /dev/null +++ b/apps/guide/__tests__/GuideTestFixtures.res @@ -0,0 +1,62 @@ +open Vitest + +let firstLesson: GuideLesson.t = { + id: "first-contact", + position: 1, + sourcePath: "app/lessons/01-first-contact.mdx", + missionLabel: "Mission 01", + title: "Learn ReScript Guide", + description: "Run a small ReScript program and inspect its output.", + content: `This interactive guide introduces ReScript through small examples, steady practice, and a live output log. + +The editor runs automatically. For this first checkpoint, the final value should print \`hello, world!\` in the output log.`, + exercise: { + id: "first-contact/greeting", + title: "Send a greeting", + initialCode: `let greeting = "hello, world!"`, + check: ExpectedOutput("hello, world!"), + }, +} + +let secondLesson: GuideLesson.t = { + id: "functions", + position: 2, + sourcePath: "app/lessons/02-functions.mdx", + missionLabel: "Mission 02", + title: "Call A Function", + description: "Change a function call and inspect the result.", + content: `Functions take values as input and return a new value. + +Change the argument passed to \`greet\` from \`ReScript\` to \`Spock\`.`, + exercise: { + id: "functions/greet-spock", + title: "Greet Spock", + initialCode: `let greet = name => "Hello, " ++ name ++ "!" + +let greeting = greet("ReScript")`, + check: ExpectedOutput("Hello, Spock!"), + }, +} + +let guideLessons = [firstLesson, secondLesson] + +let renderGuideHome = (~initialEntries=["/"], ()) => + render( + + + , + ) + +let renderGuideHomeWithDocsIntroNavigation = (goToDocsIntro, ~initialEntries=["/"]) => + render( + + + , + ) + +let renderGuideHomeInBrowser = () => + render( + + + , + ) diff --git a/apps/guide/app/GuideCompilerBridge.res b/apps/guide/app/GuideCompilerBridge.res new file mode 100644 index 000000000..18072bd76 --- /dev/null +++ b/apps/guide/app/GuideCompilerBridge.res @@ -0,0 +1,20 @@ +@react.component +let make = ( + ~bundleBaseUrl, + ~versions: array, + ~code, + ~editorRef: React.ref>, + ~setOutput, +) => { + GuideCompilerBridgeHook.useCompilerBridge( + ~bundleBaseUrl, + ~versions, + ~code, + ~editorRef, + ~setOutput, + ) + +
+ +
+} diff --git a/apps/guide/app/GuideCompilerBridgeHook.res b/apps/guide/app/GuideCompilerBridgeHook.res new file mode 100644 index 000000000..2b19d1a1b --- /dev/null +++ b/apps/guide/app/GuideCompilerBridgeHook.res @@ -0,0 +1,123 @@ +let useCompilerBridge = ( + ~bundleBaseUrl, + ~versions, + ~code, + ~editorRef: React.ref>, + ~setOutput, +) => { + let compilerVersions = React.useMemo( + () => GuideCompilerSettings.supportedVersions(versions), + [versions], + ) + let initialVersion = React.useMemo( + () => compilerVersions->GuideCompilerSettings.latestStableParsedVersion, + [compilerVersions], + ) + + let (compilerState, compilerDispatch) = CompilerManagerHook.useCompilerManager( + ~bundleBaseUrl, + ~initialVersion?, + ~initialModuleSystem=GuideCompilerSettings.moduleSystem, + ~initialWarnFlags=GuideCompilerSettings.warnFlags, + ~syncUrl=false, + ~versions=compilerVersions, + ) + + let lastCompiledCode = React.useRef("") + let lastExecutedJsCode = React.useRef("") + let isWaitingForRuntimeOutput = React.useRef(false) + + React.useEffect(() => { + switch compilerState { + | Ready({targetLang}) if code !== lastCompiledCode.current => + let timer = setTimeout(~handler=() => { + lastCompiledCode.current = code + compilerDispatch(CompileCode(targetLang, code)) + }, ~timeout=150) + Some(() => clearTimeout(timer)) + | _ => None + } + }, (code, compilerState, compilerDispatch)) + + React.useEffect(() => { + let cb = event => { + let data = event["data"] + let appendLog = (level, content) => { + let runtimeLog = {GuideCompilerFeedback.Output.level, content} + if isWaitingForRuntimeOutput.current { + isWaitingForRuntimeOutput.current = false + setOutput(_ => runtimeLog->GuideCompilerFeedback.Output.fromRuntimeLog) + } else { + setOutput(output => output->GuideCompilerFeedback.Output.withRuntimeLog(runtimeLog)) + } + } + + switch data["type"] { + | #log => appendLog(#log, data["args"]) + | #warn => appendLog(#warn, data["args"]) + | #error => appendLog(#error, data["args"]) + | _ => () + } + } + WebAPI.Window.addEventListener(window, Custom("message"), cb) + Some(() => WebAPI.Window.removeEventListener(window, Custom("message"), cb)) + }, [setOutput]) + + React.useEffect(() => { + let feedback = compilerState->GuideCompilerFeedback.editorFeedbackFromState + editorRef.current->Option.forEach(editor => { + CodeMirror.editorSetErrors(editor, feedback.errors) + CodeMirror.editorSetHoverHints(editor, feedback.hoverHints) + }) + switch compilerState->GuideCompilerFeedback.outputUpdateFromState { + | Some(output) => + isWaitingForRuntimeOutput.current = false + setOutput(_ => output) + | None => () + } + None + }, (compilerState, setOutput)) + + React.useEffect(() => { + switch compilerState { + | Ready({selected, result: Comp(Success({jsCode, typeHints}))}) + if jsCode !== lastExecutedJsCode.current => + lastExecutedJsCode.current = jsCode + let runtimeJsCode = switch GuideRuntimeSource.instrument(~code, ~typeHints) { + | Some(runtimeCode) => + // The instrumented source may fail on compiler internals; fall back to user JS in that case. + switch selected.instance->RescriptCompilerApi.Compiler.resCompile(runtimeCode) { + | Success({jsCode}) => jsCode + | Fail(_) | UnexpectedError(_) | Unknown(_, _) => jsCode + } + | None => jsCode + } + + switch runtimeJsCode->GuideRuntimeTransform.transform( + ~resultBindingName=GuideRuntimeSource.resultBindingName, + ) { + | Some({code: runtimeCode, imports}) => + isWaitingForRuntimeOutput.current = true + let imports = + imports->Dict.mapValues(path => + path->GuideRuntimeImport.url(~bundleBaseUrl, ~compilerVersion=selected.id) + ) + let timer = setTimeout( + ~handler=() => EvalIFrame.sendOutput(runtimeCode, imports), + ~timeout=50, + ) + Some( + () => { + isWaitingForRuntimeOutput.current = false + clearTimeout(timer) + }, + ) + | None => None + } + | Compiling(_) => + lastExecutedJsCode.current = "" + None + | _ => None + } + }, (compilerState, bundleBaseUrl)) +} diff --git a/apps/guide/app/GuideCompilerData.res b/apps/guide/app/GuideCompilerData.res new file mode 100644 index 000000000..d4a32be91 --- /dev/null +++ b/apps/guide/app/GuideCompilerData.res @@ -0,0 +1,43 @@ +type t = { + bundleBaseUrl: string, + versions: array, +} + +module Env = { + @scope(("process", "env")) external nodeEnv: string = "NODE_ENV" + @scope(("process", "env")) + external playgroundBundleEndpoint: option = "PLAYGROUND_BUNDLE_ENDPOINT" +} + +let fetchVersions = async versionsBaseUrl => { + let response = await fetch(versionsBaseUrl ++ "/playground-bundles/versions.json") + let json = await WebAPI.Response.json(response) + json + ->JSON.Decode.array + ->Option.getOrThrow + ->Array.map(json => json->JSON.Decode.string->Option.getOrThrow) +} + +let load = async () => { + let (bundleBaseUrl, versionsBaseUrl) = switch (Env.playgroundBundleEndpoint, Env.nodeEnv) { + | (Some(baseUrl), _) => (baseUrl, baseUrl) + | (None, "development") => + let baseUrl = "https://cdn.rescript-lang.org" + (baseUrl, baseUrl) + | (None, _) => + let baseUrl = "https://cdn.rescript-lang.org" + (baseUrl, baseUrl) + } + + try { + let versions = await fetchVersions(versionsBaseUrl) + Some({ + bundleBaseUrl, + versions, + }) + } catch { + | JsExn(error) => + Console.error2("error while fetching guide compiler versions", error) + None + } +} diff --git a/apps/guide/app/GuideCompilerFeedback.res b/apps/guide/app/GuideCompilerFeedback.res new file mode 100644 index 000000000..2fd329729 --- /dev/null +++ b/apps/guide/app/GuideCompilerFeedback.res @@ -0,0 +1,150 @@ +module Api = RescriptCompilerApi + +module Output = { + type level = [ + | #log + | #warn + | #error + ] + type runtimeLog = {level: level, content: array} + + type t = { + status: string, + diagnostics: array, + typeHints: array, + runtimeLogs: array, + } + + let make = (~status, ~diagnostics=[], ~typeHints=[], ~runtimeLogs=[]) => { + status, + diagnostics, + typeHints, + runtimeLogs, + } + + let initial = make(~status="Output", ~runtimeLogs=[{level: #log, content: ["hello, world!"]}]) + + let withRuntimeLog = (output, runtimeLog) => { + ...output, + status: "Output", + runtimeLogs: output.runtimeLogs->Array.concat([runtimeLog]), + } + + let fromRuntimeLog = runtimeLog => make(~status="Output", ~runtimeLogs=[runtimeLog]) +} + +type editorFeedback = { + errors: array, + hoverHints: array, +} + +let emptyEditorFeedback = {errors: [], hoverHints: []} + +let locMsgToEditorError = (~kind: CodeMirror.Error.kind, locMsg: Api.LocMsg.t) => { + let {Api.LocMsg.row: row, column, endColumn, endRow, shortMsg} = locMsg + { + CodeMirror.Error.row, + column, + endColumn, + endRow, + text: shortMsg, + kind, + } +} + +let warningToEditorError = (warning: Api.Warning.t) => + switch warning { + | Warn({details}) | WarnErr({details}) => locMsgToEditorError(~kind=#Warning, details) + } + +let plainText = text => text->Ansi.parse->Ansi.Printer.plainString + +let typeHintData = (typeHint: Api.TypeHint.t) => + switch typeHint { + | TypeDeclaration(data) | Expression(data) | Binding(data) | CoreType(data) => data + } + +let typeHintsToHoverHints = typeHints => + typeHints->Array.map(typeHint => { + let {Api.TypeHint.start: start, end, hint} = typeHint->typeHintData + { + CodeMirror.HoverHint.start: { + line: start.line, + col: start.col, + }, + end: { + line: end.line, + col: end.col, + }, + hint, + } + }) + +let compileFailToEditorErrors = (fail: Api.CompileFail.t) => + switch fail { + | SyntaxErr(locMsgs) | TypecheckErr(locMsgs) | OtherErr(locMsgs) => + locMsgs->Array.map(locMsg => locMsgToEditorError(~kind=#Error, locMsg)) + | WarningErr(warnings) => warnings->Array.map(warningToEditorError) + | WarningFlagErr({msg}) => [ + { + CodeMirror.Error.row: 1, + column: 0, + endRow: 1, + endColumn: 0, + text: msg, + kind: #Error, + }, + ] + } + +let compileFailToOutputLines = (fail: Api.CompileFail.t) => + switch fail { + | SyntaxErr(locMsgs) | TypecheckErr(locMsgs) | OtherErr(locMsgs) => + locMsgs->Array.map(locMsg => locMsg->Api.LocMsg.toCompactErrorLine(~prefix=#E)->plainText) + | WarningErr(warnings) => + warnings->Array.map(warning => warning->Api.Warning.toCompactErrorLine->plainText) + | WarningFlagErr({msg}) => [msg] + } + +let compilationResultToEditorFeedback = (result: Api.CompilationResult.t) => + switch result { + | Success({warnings, typeHints}) => { + errors: warnings->Array.map(warningToEditorError), + hoverHints: typeHints->typeHintsToHoverHints, + } + | Fail(fail) => { + errors: fail->compileFailToEditorErrors, + hoverHints: [], + } + | UnexpectedError(_) | Unknown(_, _) => emptyEditorFeedback + } + +let compilationResultToOutputUpdate = (result: Api.CompilationResult.t) => + switch result { + | Success(_) => None + | Fail(fail) => + Some(Output.make(~status="Compiler error", ~diagnostics=fail->compileFailToOutputLines)) + | UnexpectedError(message) => Some(Output.make(~status="Compiler error", ~diagnostics=[message])) + | Unknown(message, _) => + Some(Output.make(~status="Unknown compiler result", ~diagnostics=[message])) + } + +let editorFeedbackFromState = (state: CompilerManagerHook.state) => + switch state { + | Ready({result: Comp(result)}) | Compiling({state: {result: Comp(result)}}) => + result->compilationResultToEditorFeedback + | _ => emptyEditorFeedback + } + +let outputUpdateFromState = (state: CompilerManagerHook.state) => + switch state { + | SetupFailed(message) => + Some(Output.make(~status="Compiler setup failed", ~diagnostics=[message])) + | Ready({result: Comp(result)}) => result->compilationResultToOutputUpdate + | Init + | SwitchingCompiler(_, _) + | Compiling(_) + | Executing(_) + | Ready({result: Nothing | Conv(_)}) => + None + } diff --git a/apps/guide/app/GuideCompilerSettings.res b/apps/guide/app/GuideCompilerSettings.res new file mode 100644 index 000000000..843d36835 --- /dev/null +++ b/apps/guide/app/GuideCompilerSettings.res @@ -0,0 +1,47 @@ +let moduleSystem = "esmodule" +let warnFlags = "+a-4-9-20-40-41-42-50-61-102-109" + +let isSupportedVersion = (version: Semver.t) => + switch version.major { + | 8 | 9 => false + | 10 => version.minor >= 1 + | 11 => version.minor >= 1 && version.preRelease->Option.isNone + | 12 => + switch version.preRelease { + | None => true + | Some(_) => version.minor > 1 + } + | _ => true + } + +let compareIntDescending = (a, b) => { + if a > b { + -1.0 + } else if a < b { + 1.0 + } else { + 0.0 + } +} + +let compareVersionDescending = (a: Semver.t, b: Semver.t) => { + switch compareIntDescending(a.major, b.major) { + | 0.0 => + switch compareIntDescending(a.minor, b.minor) { + | 0.0 => compareIntDescending(a.patch, b.patch) + | result => result + } + | result => result + } +} + +let supportedVersions = versions => + versions + ->Array.filterMap(Semver.parse) + ->Array.filter(isSupportedVersion) + ->Array.toSorted(compareVersionDescending) + +let latestStableParsedVersion = (versions: array) => + versions->Array.find(version => version.preRelease->Option.isNone) + +let latestStableVersion = versions => versions->supportedVersions->latestStableParsedVersion diff --git a/apps/guide/app/GuideDom.res b/apps/guide/app/GuideDom.res new file mode 100644 index 000000000..406bf1246 --- /dev/null +++ b/apps/guide/app/GuideDom.res @@ -0,0 +1,3 @@ +external domElementToWebElement: Dom.element => WebAPI.DOMAPI.element = "%identity" + +let toWebElement = domElementToWebElement diff --git a/apps/guide/app/GuideEditorHook.res b/apps/guide/app/GuideEditorHook.res new file mode 100644 index 000000000..ef4a4d58f --- /dev/null +++ b/apps/guide/app/GuideEditorHook.res @@ -0,0 +1,57 @@ +type t = { + code: string, + containerRef: React.ref>, + editorRef: React.ref>, +} + +let useEditor = (~exercise: GuideLesson.exercise, ~theme): t => { + let containerRef: React.ref> = React.useRef(Nullable.null) + let editorRef: React.ref> = React.useRef(None) + let (code, setCode) = React.useState(() => exercise.initialCode) + + React.useEffect(() => { + editorRef.current->Option.forEach(editor => + CodeMirror.editorSetTheme(editor, theme->GuideLayout.themeToCodeMirror) + ) + None + }, [theme]) + + React.useEffect(() => { + switch containerRef.current { + | Value(parent) => + let initialCode = + GuideLayout.loadExerciseCode(exercise.id)->Option.getOr(exercise.initialCode) + setCode(_ => initialCode) + + // Recreate CodeMirror on lesson changes so persisted drafts replace the editor doc and history. + let config: CodeMirror.editorConfig = { + parent: parent->GuideDom.toWebElement, + initialValue: initialCode, + mode: "rescript", + readOnly: false, + lineNumbers: true, + lineWrapping: false, + theme: theme->GuideLayout.themeToCodeMirror, + keyMap: CodeMirror.KeyMap.Default, + onChange: value => { + GuideLayout.saveExerciseCode(~exerciseId=exercise.id, ~code=value) + setCode(_ => value) + }, + errors: [], + hoverHints: [], + minHeight: "100%", + } + let editor = CodeMirror.createEditor(config) + editorRef.current = Some(editor) + Some( + () => { + editorRef.current = None + CodeMirror.editorDestroy(editor) + }, + ) + | Null | Undefined => None + } + }, (exercise.id, exercise.initialCode)) + + {code, containerRef, editorRef} +} diff --git a/apps/guide/app/GuideHome.res b/apps/guide/app/GuideHome.res new file mode 100644 index 000000000..d6fad32f1 --- /dev/null +++ b/apps/guide/app/GuideHome.res @@ -0,0 +1,131 @@ +let navigateToDocsIntro = url => window.location->WebAPI.Location.assign(url) + +@react.component +let make = ( + ~lessons: array, + ~compilerData: option=?, + ~goToDocsIntro=navigateToDocsIntro, +) => { + let layout = GuideLayoutHook.useLayout() + let navigation = GuideLessonNavigationHook.useLessonNavigation(~lessons, ~goToDocsIntro) + let lesson = navigation.lesson + let exercise = lesson.exercise + let editor = GuideEditorHook.useEditor(~exercise, ~theme=layout.theme) + + <> +
+

{React.string("This guide needs a wider screen.")}

+

{React.string("Use a desktop browser or resize this window to continue.")}

+
+
GuideLayout.themeClass} + dataTestId="guide-mvp" + ref={ReactDOM.Ref.domRef(layout.shellRef)} + > +
+
+
+

{React.string(lesson.missionLabel)}

+ +
+

{React.string(lesson.title)}

+ lesson.content +
+ {React.string("Checkpoint")} + + {React.string( + if navigation.checkpointComplete { + "Checkpoint complete" + } else { + "Waiting for matching output" + }, + )} + +
+
+
+ + +
+
+
+
+
+
{React.string("Editor")}
+
+
+
+
+
{React.string("Output")}
+
+ +
+
+
+ {switch compilerData { + | Some({bundleBaseUrl, versions}) => + + | None => React.null + }} +
+ +} diff --git a/apps/guide/app/GuideHomeRoute.res b/apps/guide/app/GuideHomeRoute.res new file mode 100644 index 000000000..203547e4c --- /dev/null +++ b/apps/guide/app/GuideHomeRoute.res @@ -0,0 +1,17 @@ +type loaderData = { + compilerData: option, + lessons: array, +} + +let loader: ReactRouter.Loader.t = async _ => { + let compilerData = await GuideCompilerData.load() + let lessons = GuideLessonContent.load() + + {compilerData, lessons} +} + +@react.component +let default = () => { + let {compilerData, lessons}: loaderData = ReactRouter.useLoaderData() + +} diff --git a/apps/guide/app/GuideHomeRoute.resi b/apps/guide/app/GuideHomeRoute.resi new file mode 100644 index 000000000..380c80ddc --- /dev/null +++ b/apps/guide/app/GuideHomeRoute.resi @@ -0,0 +1,9 @@ +type loaderData = { + compilerData: option, + lessons: array, +} + +let loader: ReactRouter.Loader.t + +@react.component +let default: unit => React.element diff --git a/apps/guide/app/GuideLayout.res b/apps/guide/app/GuideLayout.res new file mode 100644 index 000000000..254d60598 --- /dev/null +++ b/apps/guide/app/GuideLayout.res @@ -0,0 +1,215 @@ +type theme = + | Light + | Dark + +type paneSizes = { + instructionsWidth: option, + outputHeight: float, +} + +let minInstructionsWidth = 320.0 +let minWorkspaceWidth = 480.0 +let minEditorHeight = 240.0 +let minOutputHeight = 160.0 +let defaultOutputHeight = 220.0 +let storageKey = key => `rescript-guide:v1:${key}` +let themeStorageKey = storageKey("theme") +let instructionsWidthStorageKey = storageKey("pane:instructionsWidth") +let outputHeightStorageKey = storageKey("pane:outputHeight") +let progressStorageKey = storageKey("progress") +let exerciseCodeStorageKey = exerciseId => storageKey(`exercise:${exerciseId}`) + +let defaultPaneSizes = { + instructionsWidth: None, + outputHeight: defaultOutputHeight, +} + +let clampInstructionsWidth = (~viewportWidth, ~pointerX) => + pointerX->Float.clamp(~min=minInstructionsWidth, ~max=viewportWidth -. minWorkspaceWidth) + +let clampOutputHeight = (~viewportHeight, ~pointerY) => + (viewportHeight -. pointerY) + ->Float.clamp(~min=minOutputHeight, ~max=viewportHeight -. minEditorHeight) + +let clampPaneSizes = (~viewportWidth, ~viewportHeight, paneSizes) => { + let instructionsWidth = + paneSizes.instructionsWidth->Option.map(width => + clampInstructionsWidth(~viewportWidth, ~pointerX=width) + ) + + { + instructionsWidth, + outputHeight: paneSizes.outputHeight->Float.clamp( + ~min=minOutputHeight, + ~max=viewportHeight -. minEditorHeight, + ), + } +} + +let paneSizesStyle = paneSizes => { + let instructionsWidth = switch paneSizes.instructionsWidth { + | Some(width) => `${width->Float.toString}px` + | None => "50%" + } + + `--guide-instructions-width: ${instructionsWidth}; --guide-output-height: ${paneSizes.outputHeight->Float.toString}px;` +} + +let themeClass = theme => + switch theme { + | Light => "guide-theme-light" + | Dark => "guide-theme-dark" + } + +let toggleTheme = theme => + switch theme { + | Light => Dark + | Dark => Light + } + +let themeToCodeMirror = theme => + switch theme { + | Light => CodeMirror.Theme.Light + | Dark => CodeMirror.Theme.Dark + } + +let themeToString = theme => + switch theme { + | Light => "light" + | Dark => "dark" + } + +let themeFromString = value => + switch value { + | "dark" => Dark + | _ => Light + } + +let themeToggleLabel = theme => + switch theme { + | Light => "Switch to dark mode" + | Dark => "Switch to light mode" + } + +let themeToggleText = theme => + switch theme { + | Light => "Dark" + | Dark => "Light" + } + +// localStorage can throw in restricted browser contexts. Persistence is optional +// for the guide, so storage failures fall back to the current UI state. +let getLocalStorageItem = key => { + try { + WebAPI.Storage.getItem(window.localStorage, key)->Null.toOption + } catch { + | JsExn(_) => None + } +} + +let setLocalStorageItem = (~key, ~value) => { + try { + WebAPI.Storage.setItem(window.localStorage, ~key, ~value) + } catch { + | JsExn(_) => () + } +} + +let removeLocalStorageItem = key => { + try { + WebAPI.Storage.removeItem(window.localStorage, key) + } catch { + | JsExn(_) => () + } +} + +let loadTheme = () => + getLocalStorageItem(themeStorageKey) + ->Option.map(themeFromString) + ->Option.getOr(Light) + +let saveTheme = theme => setLocalStorageItem(~key=themeStorageKey, ~value=theme->themeToString) + +let parseStoredFloat = value => + switch value->Float.fromString { + | Some(value) if value > 0.0 => Some(value) + | _ => None + } + +let getStoredFloat = key => getLocalStorageItem(key)->Option.flatMap(parseStoredFloat) + +let loadPaneSizes = () => { + instructionsWidth: getStoredFloat(instructionsWidthStorageKey), + outputHeight: getStoredFloat(outputHeightStorageKey)->Option.getOr(defaultOutputHeight), +} + +let savePaneSizes = paneSizes => { + switch paneSizes.instructionsWidth { + | Some(width) => + setLocalStorageItem(~key=instructionsWidthStorageKey, ~value=width->Float.toString) + | None => removeLocalStorageItem(instructionsWidthStorageKey) + } + setLocalStorageItem(~key=outputHeightStorageKey, ~value=paneSizes.outputHeight->Float.toString) +} + +let clearPaneSizes = () => { + removeLocalStorageItem(instructionsWidthStorageKey) + removeLocalStorageItem(outputHeightStorageKey) +} + +let parseCompletedExerciseIds = value => { + open JSON + + try { + switch value->JSON.parseOrThrow { + | Object(dict{"completedExerciseIds": Array(ids)}) => + ids->Array.filterMap(id => + switch id { + | String(id) => Some(id) + | _ => None + } + ) + | _ => [] + } + } catch { + | JsExn(_) => [] + } +} + +let stringifyCompletedExerciseIds = completedExerciseIds => { + let dict = Dict.make() + dict->Dict.set( + "completedExerciseIds", + JSON.Array(completedExerciseIds->Array.map(id => JSON.String(id))), + ) + JSON.Object(dict)->JSON.stringify +} + +let loadCompletedExerciseIds = () => + getLocalStorageItem(progressStorageKey) + ->Option.map(parseCompletedExerciseIds) + ->Option.getOr([]) + +let saveCompletedExerciseIds = completedExerciseIds => + setLocalStorageItem( + ~key=progressStorageKey, + ~value=completedExerciseIds->stringifyCompletedExerciseIds, + ) + +let saveCompletedExercise = exerciseId => { + let completedExerciseIds = loadCompletedExerciseIds() + if !(completedExerciseIds->Array.includes(exerciseId)) { + saveCompletedExerciseIds(completedExerciseIds->Array.concat([exerciseId])) + } +} + +let isExerciseCompleted = exerciseId => loadCompletedExerciseIds()->Array.includes(exerciseId) + +let clearCompletedExercises = () => removeLocalStorageItem(progressStorageKey) + +let loadExerciseCode = exerciseId => getLocalStorageItem(exerciseId->exerciseCodeStorageKey) + +let saveExerciseCode = (~exerciseId, ~code) => + setLocalStorageItem(~key=exerciseId->exerciseCodeStorageKey, ~value=code) + +let clearExerciseCode = exerciseId => removeLocalStorageItem(exerciseId->exerciseCodeStorageKey) diff --git a/apps/guide/app/GuideLayoutHook.res b/apps/guide/app/GuideLayoutHook.res new file mode 100644 index 000000000..4ee7df26c --- /dev/null +++ b/apps/guide/app/GuideLayoutHook.res @@ -0,0 +1,104 @@ +type dragTarget = + | NotDragging + | ResizingColumns + | ResizingRows + +type t = { + shellRef: React.ref>, + theme: GuideLayout.theme, + toggleTheme: ReactEvent.Mouse.t => unit, + startColumnResize: ReactEvent.Mouse.t => unit, + startRowResize: ReactEvent.Mouse.t => unit, +} + +let useLayout = (): t => { + let shellRef: React.ref> = React.useRef(Nullable.null) + let (paneSizes, setPaneSizes) = React.useState(() => GuideLayout.defaultPaneSizes) + let (theme, setTheme) = React.useState(() => GuideLayout.Light) + let (themeLoaded, setThemeLoaded) = React.useState(() => false) + let (paneSizesLoaded, setPaneSizesLoaded) = React.useState(() => false) + let dragTarget = React.useRef(NotDragging) + + React.useEffect(() => { + setTheme(_ => GuideLayout.loadTheme()) + setPaneSizes(_ => + GuideLayout.loadPaneSizes()->GuideLayout.clampPaneSizes( + ~viewportWidth=window.innerWidth->Int.toFloat, + ~viewportHeight=window.innerHeight->Int.toFloat, + ) + ) + setThemeLoaded(_ => true) + setPaneSizesLoaded(_ => true) + None + }, []) + + React.useEffect(() => { + if themeLoaded { + theme->GuideLayout.saveTheme + } + None + }, (theme, themeLoaded)) + + React.useEffect(() => { + if paneSizesLoaded { + paneSizes->GuideLayout.savePaneSizes + } + None + }, (paneSizes, paneSizesLoaded)) + + React.useEffect(() => { + switch shellRef.current { + | Value(element) => + // CSS variables keep the two resizable axes in one place for layout and tests. + WebAPI.Element.setAttribute( + element->GuideDom.toWebElement, + ~qualifiedName="style", + ~value=paneSizes->GuideLayout.paneSizesStyle, + ) + | Null | Undefined => () + } + None + }, [paneSizes]) + + React.useEffect(() => { + let stopDragging = _event => dragTarget.current = NotDragging + + let onMouseMove = event => + switch dragTarget.current { + | ResizingColumns => + let pointerX = event->ReactEvent.Mouse.clientX->Int.toFloat + let viewportWidth = window.innerWidth->Int.toFloat + let instructionsWidth = GuideLayout.clampInstructionsWidth(~viewportWidth, ~pointerX) + setPaneSizes(previous => {...previous, instructionsWidth: Some(instructionsWidth)}) + | ResizingRows => + let pointerY = event->ReactEvent.Mouse.clientY->Int.toFloat + let viewportHeight = window.innerHeight->Int.toFloat + let outputHeight = GuideLayout.clampOutputHeight(~viewportHeight, ~pointerY) + setPaneSizes(previous => {...previous, outputHeight}) + | NotDragging => () + } + + WebAPI.Window.addEventListener(window, Mousemove, onMouseMove) + WebAPI.Window.addEventListener(window, Mouseup, stopDragging) + + Some( + () => { + WebAPI.Window.removeEventListener(window, Mousemove, onMouseMove) + WebAPI.Window.removeEventListener(window, Mouseup, stopDragging) + }, + ) + }, []) + + let startDragging = (target, event) => { + ReactEvent.Mouse.preventDefault(event) + dragTarget.current = target + } + + { + shellRef, + theme, + toggleTheme: _event => setTheme(previous => previous->GuideLayout.toggleTheme), + startColumnResize: event => startDragging(ResizingColumns, event), + startRowResize: event => startDragging(ResizingRows, event), + } +} diff --git a/apps/guide/app/GuideLesson.res b/apps/guide/app/GuideLesson.res new file mode 100644 index 000000000..e6630824e --- /dev/null +++ b/apps/guide/app/GuideLesson.res @@ -0,0 +1,63 @@ +type exerciseCheck = + | ExpectedOutput(string) + | Manual + +type exercise = { + id: string, + title: string, + initialCode: string, + check: exerciseCheck, +} + +type t = { + id: string, + position: int, + sourcePath: string, + missionLabel: string, + title: string, + description: string, + content: string, + exercise: exercise, +} + +let sort = lessons => + lessons->Array.toSorted((a, b) => + switch Int.compare(a.position, b.position) { + | 0. => String.compare(a.id, b.id) + | result => result + } + ) + +let firstLesson = lessons => lessons->Array.get(0)->Option.getOrThrow + +let lessonAt = (~lessons, index) => lessons->Array.get(index)->Option.getOr(lessons->firstLesson) + +let hashForLesson = (lesson: t) => "#" ++ lesson.id + +let lessonIdFromHash = hash => + if hash->String.startsWith("#") { + hash->String.slice(~start=1) + } else { + hash + } + +let indexForId = (~lessons, lessonId) => { + let index = lessons->Array.findIndex(lesson => lesson.id === lessonId) + index < 0 ? 0 : index +} + +let indexForHash = (~lessons, hash) => hash->lessonIdFromHash->indexForId(~lessons) + +let hasPreviousLesson = index => index > 0 + +let hasNextLesson = (~lessons, index) => index < lessons->Array.length - 1 + +let runtimeLogText = (runtimeLog: GuideCompilerFeedback.Output.runtimeLog) => + runtimeLog.content->Array.join(" ") + +let isExerciseComplete = (~exercise, ~output: GuideCompilerFeedback.Output.t) => + switch exercise.check { + | ExpectedOutput(expected) => + output.runtimeLogs->Array.some(runtimeLog => runtimeLog->runtimeLogText === expected) + | Manual => false + } diff --git a/apps/guide/app/GuideLessonContent.res b/apps/guide/app/GuideLessonContent.res new file mode 100644 index 000000000..1e5d60e7c --- /dev/null +++ b/apps/guide/app/GuideLessonContent.res @@ -0,0 +1,83 @@ +let fail = message => JsExn.throw(Error(message)) + +let fieldLabel = (~sourcePath, ~key) => `Guide lesson ${sourcePath} frontmatter "${key}"` + +let readString = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.String(value)) if value->String.trim !== "" => value + | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be a non-empty string.`) + } + +let readOptionalString = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.String(value)) => Some(value) + | Some(_) => fail(`${fieldLabel(~sourcePath, ~key)} must be a string when present.`) + | None => None + } + +let readInt = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.Number(value)) => value->Float.toInt + | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be a number.`) + } + +let readObject = (~dict, ~sourcePath, ~key) => + switch dict->Dict.get(key) { + | Some(JSON.Object(value)) => value + | _ => fail(`${fieldLabel(~sourcePath, ~key)} must be an object.`) + } + +let frontmatterObject = (~frontmatter, ~sourcePath) => + switch frontmatter { + | JSON.Object(dict) => dict + | _ => fail(`Guide lesson ${sourcePath} must use object frontmatter.`) + } + +let exerciseFromFrontmatter = (~dict, ~sourcePath): GuideLesson.exercise => { + let check = switch readOptionalString(~dict, ~sourcePath, ~key="expectedOutput") { + | Some(expectedOutput) => GuideLesson.ExpectedOutput(expectedOutput) + | None => GuideLesson.Manual + } + + { + id: readString(~dict, ~sourcePath, ~key="id"), + title: readString(~dict, ~sourcePath, ~key="title"), + initialCode: readString(~dict, ~sourcePath, ~key="initialCode")->String.trimEnd, + check, + } +} + +let lessonFromFile = sourcePath => { + let raw = Node.Fs.readFileSync(sourcePath) + let {frontmatter, content}: MarkdownParser.result = MarkdownParser.parseSync(raw) + let dict = frontmatterObject(~frontmatter, ~sourcePath) + let exerciseDict = readObject(~dict, ~sourcePath, ~key="exercise") + + { + GuideLesson.id: readString(~dict, ~sourcePath, ~key="id"), + position: readInt(~dict, ~sourcePath, ~key="position"), + sourcePath, + missionLabel: readString(~dict, ~sourcePath, ~key="missionLabel"), + title: readString(~dict, ~sourcePath, ~key="title"), + description: readString(~dict, ~sourcePath, ~key="description"), + content: content->String.trim, + exercise: exerciseFromFrontmatter(~dict=exerciseDict, ~sourcePath), + } +} + +let rec scanDir = currentDir => + Node.Fs.readdirSync(currentDir)->Array.flatMap(entry => { + let fullPath = Node.Path.join2(currentDir, entry) + + if Node.Fs.statSync(fullPath)["isDirectory"]() { + scanDir(fullPath) + } else if Node.Path.extname(entry) === ".mdx" { + [fullPath] + } else { + [] + } + }) + +let lessonsDir = () => Node.Path.join2(Node.Process.cwd(), "app/lessons") + +let load = (~dir=lessonsDir()) => scanDir(dir)->Array.map(lessonFromFile)->GuideLesson.sort diff --git a/apps/guide/app/GuideLessonNavigationHook.res b/apps/guide/app/GuideLessonNavigationHook.res new file mode 100644 index 000000000..b3ee6447f --- /dev/null +++ b/apps/guide/app/GuideLessonNavigationHook.res @@ -0,0 +1,97 @@ +type t = { + lesson: GuideLesson.t, + output: GuideCompilerFeedback.Output.t, + setOutput: (GuideCompilerFeedback.Output.t => GuideCompilerFeedback.Output.t) => unit, + hasPreviousLesson: bool, + hasNextLesson: bool, + checkpointComplete: bool, + forwardActionEnabled: bool, + goToPreviousLesson: ReactEvent.Mouse.t => unit, + goToNextLesson: ReactEvent.Mouse.t => unit, +} + +let emptyOutput = () => GuideCompilerFeedback.Output.make(~status="Output") + +let outputForLessonIndex = index => + if index === 0 { + GuideCompilerFeedback.Output.initial + } else { + emptyOutput() + } + +let docsIntroUrl = "https://rescript-lang.org/docs/manual/introduction" + +let navigateToLesson = (~goToHash, ~setLessonIndex, ~setOutput, ~lessons, index) => { + let lesson = GuideLesson.lessonAt(~lessons, index) + goToHash(lesson->GuideLesson.hashForLesson) + setLessonIndex(_ => index) + setOutput(_ => index->outputForLessonIndex) +} + +let useLessonNavigation = (~lessons, ~goToDocsIntro): t => { + let location = ReactRouter.useLocation() + let navigate = ReactRouter.useNavigate() + let goToHash = hash => navigate(hash) + let (lessonIndex, setLessonIndex) = React.useState(() => 0) + let (output, setOutput) = React.useState(() => GuideCompilerFeedback.Output.initial) + + React.useEffect(() => { + let currentHash = location.hash->Option.getOr("") + let nextLessonIndex = GuideLesson.indexForHash(~lessons, currentHash) + let nextLesson = GuideLesson.lessonAt(~lessons, nextLessonIndex) + let nextLessonHash = nextLesson->GuideLesson.hashForLesson + + setLessonIndex(_ => nextLessonIndex) + setOutput(_ => nextLessonIndex->outputForLessonIndex) + + // Keep the hash canonical so direct links, browser back, and MemoryRouter tests share one path. + if currentHash !== nextLessonHash { + navigate(nextLessonHash, ~options={replace: true}) + } + + None + }, (location.hash, navigate, lessons)) + + let lesson = GuideLesson.lessonAt(~lessons, lessonIndex) + let exercise = lesson.exercise + let hasPreviousLesson = GuideLesson.hasPreviousLesson(lessonIndex) + let hasNextLesson = GuideLesson.hasNextLesson(~lessons, lessonIndex) + let exercisePassed = GuideLesson.isExerciseComplete(~exercise, ~output) + let checkpointComplete = exercisePassed || GuideLayout.isExerciseCompleted(exercise.id) + let forwardActionEnabled = hasNextLesson || checkpointComplete + + React.useEffect(() => { + if exercisePassed { + GuideLayout.saveCompletedExercise(exercise.id) + } + None + }, (exercisePassed, exercise.id)) + + let goToPreviousLesson = _event => { + if hasPreviousLesson { + let previousLessonIndex = lessonIndex - 1 + navigateToLesson(~goToHash, ~setLessonIndex, ~setOutput, ~lessons, previousLessonIndex) + } + } + + let goToNextLesson = _event => { + if hasNextLesson { + let nextLessonIndex = lessonIndex + 1 + navigateToLesson(~goToHash, ~setLessonIndex, ~setOutput, ~lessons, nextLessonIndex) + } else if checkpointComplete { + goToDocsIntro(docsIntroUrl) + } + } + + { + lesson, + output, + setOutput, + hasPreviousLesson, + hasNextLesson, + checkpointComplete, + forwardActionEnabled, + goToPreviousLesson, + goToNextLesson, + } +} diff --git a/apps/guide/app/GuideMarkdown.res b/apps/guide/app/GuideMarkdown.res new file mode 100644 index 000000000..39e148af4 --- /dev/null +++ b/apps/guide/app/GuideMarkdown.res @@ -0,0 +1,2 @@ +@module("react-markdown") @react.component +external make: (~children: string) => React.element = "default" diff --git a/apps/guide/app/GuideOutputPanel.res b/apps/guide/app/GuideOutputPanel.res new file mode 100644 index 000000000..7197f2a11 --- /dev/null +++ b/apps/guide/app/GuideOutputPanel.res @@ -0,0 +1,44 @@ +@react.component +let make = (~output: GuideCompilerFeedback.Output.t) => { +
+ {switch output.status { + | "Output" => React.null + | status =>
{React.string(status)}
+ }} + {switch output.diagnostics { + | [] => React.null + | diagnostics => +
+
{React.string("Diagnostics")}
+ {diagnostics + ->Array.mapWithIndex((diagnostic, index) => +
Int.toString} className="guide-output-line guide-output-line-error">
+            {React.string(diagnostic)}
+          
+ ) + ->React.array} +
+ }} + {switch output.runtimeLogs { + | [] => React.null + | runtimeLogs => +
+
{React.string("Result")}
+ {runtimeLogs + ->Array.mapWithIndex(({level, content}, index) => +
Int.toString}
+            className={switch level {
+            | #log => "guide-output-line"
+            | #warn => "guide-output-line guide-output-line-warning"
+            | #error => "guide-output-line guide-output-line-error"
+            }}
+          >
+            {React.string(content->Array.join(" "))}
+          
+ ) + ->React.array} +
+ }} +
+} diff --git a/apps/guide/app/GuideRuntimeImport.res b/apps/guide/app/GuideRuntimeImport.res new file mode 100644 index 000000000..dac45a148 --- /dev/null +++ b/apps/guide/app/GuideRuntimeImport.res @@ -0,0 +1,45 @@ +let capitalizeFirstLetter = string => { + let firstLetter = string->String.charAt(0)->String.toUpperCase + `${firstLetter}${string->String.slice(~start=1)}` +} + +let filenameForCompiler = (~compilerVersion: Semver.t, path) => { + let filename = path->String.slice(~start=9) + switch compilerVersion { + | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 8 => + let filename = if filename->String.startsWith("core__") { + filename->String.slice(~start=6) + } else { + filename + } + filename->capitalizeFirstLetter + | {major} if major < 12 && filename->String.startsWith("core__") => + filename->capitalizeFirstLetter + | _ => filename + } +} + +let compilerVersionForRuntimeImport = (compilerVersion: Semver.t) => + // Older compiler builds emitted stdlib import paths that no longer match the CDN layout. + switch compilerVersion { + | {major: 12, minor: 0, patch: 0, preRelease: Some(Alpha(alpha))} if alpha < 9 => { + Semver.major: 12, + minor: 0, + patch: 0, + preRelease: Some(Alpha(9)), + } + | {major, minor} if (major === 11 && minor < 2) || major < 11 => { + major: 11, + minor: 2, + patch: 0, + preRelease: Some(Beta(2)), + } + | version => version + } + +let url = (~bundleBaseUrl, ~compilerVersion: Semver.t, path) => { + let filename = path->filenameForCompiler(~compilerVersion) + let compilerVersion = compilerVersion->compilerVersionForRuntimeImport + + CompilerManagerHook.CdnMeta.getStdlibRuntimeUrl(bundleBaseUrl, compilerVersion, filename) +} diff --git a/apps/guide/app/GuideRuntimeSource.res b/apps/guide/app/GuideRuntimeSource.res new file mode 100644 index 000000000..5a6400bc6 --- /dev/null +++ b/apps/guide/app/GuideRuntimeSource.res @@ -0,0 +1,73 @@ +module Api = RescriptCompilerApi + +let resultBindingName = "__rescriptGuideOutput" + +let expressionData = (typeHint: Api.TypeHint.t) => + switch typeHint { + | Expression(data) => Some(data) + | TypeDeclaration(_) | Binding(_) | CoreType(_) => None + } + +let offsetFromPosition = (position: Api.TypeHint.position, code) => { + let lines = code->String.split("\n") + let offset = ref(0) + + if position.line > 1 { + for i in 0 to position.line - 2 { + switch lines->Array.get(i) { + | Some(line) => offset.contents = offset.contents + line->String.length + 1 + | None => () + } + } + } + + offset.contents + position.col +} + +let startsAtLineBoundary = (position: Api.TypeHint.position, code) => { + let lines = code->String.split("\n") + + switch lines->Array.get(position.line - 1) { + | Some(line) => line->String.slice(~start=0, ~end=position.col)->String.trim === "" + | None => false + } +} + +let finalExpressionData = (~code, typeHints) => { + let best: ref> = ref(None) + + typeHints->Array.forEach(typeHint => + switch typeHint->expressionData { + | Some(data) if startsAtLineBoundary(data.start, code) => + let startOffset = data.start->offsetFromPosition(code) + let endOffset = data.end->offsetFromPosition(code) + switch best.contents { + | Some((bestEndOffset, bestStartOffset, _)) + if bestEndOffset > endOffset || + (bestEndOffset === endOffset && bestStartOffset <= startOffset) => () + | _ => best.contents = Some((endOffset, startOffset, data)) + } + | Some(_) | None => () + } + ) + + best.contents->Option.map(((_, _, data)) => data) +} + +let instrument = (~code, ~typeHints) => { + switch typeHints->finalExpressionData(~code) { + | Some({start, end}) => + let startOffset = start->offsetFromPosition(code) + let endOffset = end->offsetFromPosition(code) + let expressionSource = code->String.slice(~start=startOffset, ~end=endOffset)->String.trim + + if expressionSource === "" { + None + } else { + let prefix = code->String.slice(~start=0, ~end=startOffset) + let suffix = code->String.slice(~start=endOffset) + Some(`${prefix}let ${resultBindingName} = (${expressionSource})${suffix}`) + } + | None => None + } +} diff --git a/apps/guide/app/GuideRuntimeTransform.res b/apps/guide/app/GuideRuntimeTransform.res new file mode 100644 index 000000000..3163f6b00 --- /dev/null +++ b/apps/guide/app/GuideRuntimeTransform.res @@ -0,0 +1,135 @@ +type t = { + code: string, + imports: Dict.t, +} + +let isModuleBoundary = statement => { + switch statement->Babel.Ast.nodeType { + | "ImportDeclaration" | "ExportNamedDeclaration" => true + | _ => false + } +} + +let collectRuntimeImport = (~imports, statement) => { + switch statement->Babel.Ast.nodeType { + | "ImportDeclaration" => + let sourceValue = statement->Babel.Ast.source->Babel.Ast.stringLiteralValue + if sourceValue->String.startsWith("./stdlib") { + switch statement->Babel.Ast.specifiers { + | [specifier] => + imports->Dict.set(specifier->Babel.Ast.specifierLocal->Babel.Ast.lvalName, sourceValue) + | _ => () + } + } + | _ => () + } +} + +let consoleLogStatement = expression => { + let consoleLog = Babel.Types.memberExpression( + Babel.Types.identifier("console"), + Babel.Types.identifier("log"), + ) + Babel.Types.expressionStatement(Babel.Types.callExpression(consoleLog, [expression])) +} + +let hasBinding = (~name, statement) => + switch statement->Babel.Ast.nodeType { + | "VariableDeclaration" => + statement + ->Babel.Ast.declarations + ->Array.some(declaration => + declaration->Babel.Ast.variableDeclaratorId->Babel.Ast.lvalName === name + ) + | _ => false + } + +let appendResultBindingLog = (~resultBindingName, body) => + switch resultBindingName { + | Some(name) if body->Array.some(statement => statement->hasBinding(~name)) => + Some(body->Array.concat([Babel.Types.identifier(name)->consoleLogStatement])) + | _ => None + } + +let variableDeclarationBindingName = statement => { + let declarations = statement->Babel.Ast.declarations + + switch declarations->Array.length { + | 0 => None + | length => + Some( + declarations + ->Array.getUnsafe(length - 1) + ->Babel.Ast.variableDeclaratorId + ->Babel.Ast.lvalName, + ) + } +} + +let bindingName = statement => + switch statement->Babel.Ast.nodeType { + | "VariableDeclaration" => statement->variableDeclarationBindingName + | "FunctionDeclaration" => Some(statement->Babel.Ast.statementId->Babel.Ast.lvalName) + | _ => None + } + +let lastBindingName = body => { + let lastFound = ref(None) + + body->Array.forEach(statement => + switch statement->bindingName { + | Some(name) => lastFound.contents = Some(name) + | None => () + } + ) + + lastFound.contents +} + +let appendLastBindingLog = body => + switch body->lastBindingName { + | Some(name) => Some(body->Array.concat([Babel.Types.identifier(name)->consoleLogStatement])) + | None => None + } + +let transform = (~resultBindingName=?, jsCode) => + try { + let ast = Babel.Parser.parse(jsCode, {sourceType: "module"}) + let imports = Dict.make() + ast.program.body->Array.forEach(statement => statement->collectRuntimeImport(~imports)) + + let executableBody = ast.program.body->Array.filter(statement => !isModuleBoundary(statement)) + + switch executableBody->Array.length { + | 0 => None + | length => + let lastIndex = length - 1 + let lastStatement = executableBody->Array.getUnsafe(lastIndex) + + switch lastStatement->Babel.Ast.nodeType { + | "ExpressionStatement" => + ast.program.body = + executableBody->Array.mapWithIndex((statement, index) => + index === lastIndex + ? lastStatement->Babel.Ast.expression->consoleLogStatement + : statement + ) + Some({code: Babel.Generator.generator(ast).code, imports}) + | _ => + switch executableBody->appendResultBindingLog(~resultBindingName) { + | Some(body) => + ast.program.body = body + Some({code: Babel.Generator.generator(ast).code, imports}) + | None => + switch executableBody->appendLastBindingLog { + | Some(body) => + ast.program.body = body + Some({code: Babel.Generator.generator(ast).code, imports}) + | None => None + } + } + } + } + } catch { + | _ => None + } diff --git a/apps/guide/app/lessons/01-first-contact.mdx b/apps/guide/app/lessons/01-first-contact.mdx new file mode 100644 index 000000000..3f85329e9 --- /dev/null +++ b/apps/guide/app/lessons/01-first-contact.mdx @@ -0,0 +1,17 @@ +--- +position: 1 +id: first-contact +missionLabel: Mission 01 +title: Learn ReScript Guide +description: Run a small ReScript program and inspect its output. +exercise: + id: first-contact/greeting + title: Send a greeting + initialCode: | + let greeting = "hello, world!" + expectedOutput: hello, world! +--- + +This interactive guide introduces ReScript through small examples, steady practice, and a live output log. + +The editor runs automatically. For this first checkpoint, the final value should print `hello, world!` in the output log. diff --git a/apps/guide/app/lessons/02-functions.mdx b/apps/guide/app/lessons/02-functions.mdx new file mode 100644 index 000000000..46504a93a --- /dev/null +++ b/apps/guide/app/lessons/02-functions.mdx @@ -0,0 +1,19 @@ +--- +position: 2 +id: functions +missionLabel: Mission 02 +title: Call A Function +description: Change a function call and inspect the result. +exercise: + id: functions/greet-spock + title: Greet Spock + initialCode: | + let greet = name => "Hello, " ++ name ++ "!" + + let greeting = greet("ReScript") + expectedOutput: Hello, Spock! +--- + +Functions take values as input and return a new value. + +Change the argument passed to `greet` from `ReScript` to `Spock`. diff --git a/apps/guide/app/root.jsx b/apps/guide/app/root.jsx new file mode 100644 index 000000000..ee50dca94 --- /dev/null +++ b/apps/guide/app/root.jsx @@ -0,0 +1,44 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as ReactRouter from "react-router"; +import * as JsxRuntime from "react/jsx-runtime"; +import MainCssurl from "../styles/main.css?url"; + +let mainCss = MainCssurl; + +function Root$default(props) { + return + + + + + + + + {"ReScript Guide"} + + + + + + + + ; +} + +let $$default = Root$default; + +export { + $$default as default, +} +/* mainCss Not a pure module */ diff --git a/apps/guide/app/root.res b/apps/guide/app/root.res new file mode 100644 index 000000000..6251b792c --- /dev/null +++ b/apps/guide/app/root.res @@ -0,0 +1,21 @@ +@module("../styles/main.css?url") +external mainCss: string = "default" + +@react.component +let default = () => { + + + + + + + + {React.string("ReScript Guide")} + + + + + + + +} diff --git a/apps/guide/app/root.resi b/apps/guide/app/root.resi new file mode 100644 index 000000000..47fa1ac2e --- /dev/null +++ b/apps/guide/app/root.resi @@ -0,0 +1,2 @@ +@react.component +let default: unit => React.element diff --git a/apps/guide/app/routes.jsx b/apps/guide/app/routes.jsx new file mode 100644 index 000000000..e793a07bf --- /dev/null +++ b/apps/guide/app/routes.jsx @@ -0,0 +1,10 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Routes from "@react-router/dev/routes"; + +let $$default = [Routes.index("./GuideHomeRoute.jsx")]; + +export { + $$default as default, +} +/* default Not a pure module */ diff --git a/apps/guide/app/routes.res b/apps/guide/app/routes.res new file mode 100644 index 000000000..9d0187936 --- /dev/null +++ b/apps/guide/app/routes.res @@ -0,0 +1,3 @@ +open ReactRouter.Routes + +let default = [index("./GuideHomeRoute.jsx")] diff --git a/apps/guide/app/routes.resi b/apps/guide/app/routes.resi new file mode 100644 index 000000000..b09c4fb75 --- /dev/null +++ b/apps/guide/app/routes.resi @@ -0,0 +1 @@ +let default: array diff --git a/apps/guide/package.json b/apps/guide/package.json new file mode 100644 index 000000000..45456a783 --- /dev/null +++ b/apps/guide/package.json @@ -0,0 +1,53 @@ +{ + "name": "@rescript-lang/guide", + "version": "1.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", + "build:vite": "react-router build", + "build": "yarn build:res && yarn build:vite", + "ci:test": "yarn build:res && yarn vitest --run --browser.headless", + "dev:vite": "yarn build:res && vite --host", + "vitest": "vitest" + }, + "dependencies": { + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "@react-router/node": "^7.14.0", + "@rescript-lang/playground": "workspace:*", + "@rescript-lang/shared": "workspace:*", + "@rescript/react": "^0.14.2", + "@rescript/webapi": "0.1.0-experimental-29db5f4", + "isbot": "^5", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", + "react-router": "^7.14.0", + "react-router-dom": "^7.14.0", + "remark-comment": "^1.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "rescript": "^12.2.0", + "unified": "^11.0.5", + "vfile-matter": "^5.0.1" + }, + "devDependencies": { + "@react-router/dev": "^7.14.0", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/browser-playwright": "^4.1.2", + "lightningcss": "^1.32.0", + "playwright": "^1.59.1", + "vite": "^8.0.3", + "vite-plugin-env-compatible": "^2.0.1", + "vitest": "^4.1.2", + "vitest-browser-react": "^2.2.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/apps/guide/react-router.config.mjs b/apps/guide/react-router.config.mjs new file mode 100644 index 000000000..df41a8ad8 --- /dev/null +++ b/apps/guide/react-router.config.mjs @@ -0,0 +1,9 @@ +import * as fs from "node:fs"; + +export default { + ssr: false, + prerender: ["/"], + buildEnd: async () => { + fs.cpSync("./build/client", "./out", { recursive: true }); + }, +}; diff --git a/apps/guide/rescript.json b/apps/guide/rescript.json new file mode 100644 index 000000000..3c89889a8 --- /dev/null +++ b/apps/guide/rescript.json @@ -0,0 +1,25 @@ +{ + "name": "@rescript-lang/guide", + "namespace": false, + "dependencies": [ + "@rescript-lang/playground", + "@rescript-lang/shared", + "@rescript/react", + "@rescript/webapi" + ], + "compiler-flags": ["-open WebAPI.Global"], + "sources": [ + { + "dir": "__tests__", + "subdirs": true, + "type": "dev" + }, + { + "dir": "app", + "subdirs": true + } + ], + "warnings": { + "error": "+8" + } +} diff --git a/apps/guide/styles/main.css b/apps/guide/styles/main.css new file mode 100644 index 000000000..c5aabace2 --- /dev/null +++ b/apps/guide/styles/main.css @@ -0,0 +1,381 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + background: #f6f4ef; + color: #1f2933; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +.guide-shell { + --guide-page-bg: #f6f4ef; + --guide-surface-bg: #ffffff; + --guide-panel-bg: #ffffff; + --guide-border: #d8d6cf; + --guide-border-soft: #e4e1d8; + --guide-heading: #102a43; + --guide-text: #1f2933; + --guide-muted: #52606d; + --guide-button-bg: #102a43; + --guide-button-border: #102a43; + --guide-button-hover-bg: #1f4e79; + --guide-button-hover-border: #1f4e79; + --guide-button-text: #ffffff; + --guide-toggle-bg: #ffffff; + --guide-toggle-text: #102a43; + --guide-toggle-border: #cbd5e1; + --guide-resize-bg: #edeae2; + --guide-resize-hover-bg: #93c5fd; + display: grid; + grid-template-columns: + minmax(320px, var(--guide-instructions-width, 50%)) + 8px minmax(480px, 1fr); + min-height: 100vh; + background: var(--guide-page-bg); + color: var(--guide-text); +} + +.guide-screen-size-message { + background: #0f172a; + color: #cbd5e1; + display: none; + min-height: 100vh; + padding: 32px; + place-content: center; + text-align: center; +} + +.guide-screen-size-message h1 { + color: #f8fafc; + font-size: 30px; + line-height: 1.2; + margin: 0 0 12px; +} + +.guide-screen-size-message p { + font-size: 16px; + line-height: 1.5; + margin: 0; +} + +.guide-theme-dark { + --guide-page-bg: #0f172a; + --guide-surface-bg: #101827; + --guide-panel-bg: #0f172a; + --guide-border: #334155; + --guide-border-soft: #1f2937; + --guide-heading: #e5e7eb; + --guide-text: #cbd5e1; + --guide-muted: #94a3b8; + --guide-button-bg: #38bdf8; + --guide-button-border: #38bdf8; + --guide-button-hover-bg: #0ea5e9; + --guide-button-hover-border: #0ea5e9; + --guide-button-text: #082f49; + --guide-toggle-bg: #111827; + --guide-toggle-text: #e5e7eb; + --guide-toggle-border: #475569; + --guide-resize-bg: #1f2937; + --guide-resize-hover-bg: #38bdf8; +} + +.guide-instructions { + background: var(--guide-page-bg); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 40px; + font-size: 18px; + line-height: 1.6; +} + +.guide-copy { + max-width: 620px; +} + +.guide-copy h1 { + color: var(--guide-heading); + font-size: 34px; + line-height: 1.15; + margin: 0 0 22px; +} + +.guide-copy p { + margin: 0 0 18px; +} + +.guide-check-status { + border-left: 3px solid var(--guide-border); + color: var(--guide-muted); + display: flex; + flex-direction: column; + font-size: 14px; + gap: 5px; + line-height: 1.35; + margin-top: 28px; + padding-left: 14px; +} + +.guide-check-status-complete { + border-color: #16a34a; + color: var(--guide-text); +} + +.guide-check-label { + color: var(--guide-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.guide-kicker { + color: var(--guide-muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + margin: 0; + text-transform: uppercase; +} + +.guide-topbar { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 22px; +} + +.guide-theme-toggle { + background: var(--guide-toggle-bg); + border: 1px solid var(--guide-toggle-border); + color: var(--guide-toggle-text); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 700; + line-height: 1; + padding: 8px 10px; +} + +.guide-theme-toggle:hover { + border-color: var(--guide-resize-hover-bg); +} + +.guide-lesson-actions { + align-self: flex-start; + display: flex; + gap: 12px; +} + +.guide-back-button, +.guide-next-button { + background: var(--guide-button-bg); + border: 1px solid var(--guide-button-border); + color: var(--guide-button-text); + cursor: pointer; + font: inherit; + font-size: 15px; + font-weight: 700; + line-height: 1; + padding: 12px 18px; +} + +.guide-back-button { + background: transparent; + color: var(--guide-text); +} + +.guide-back-button:hover:not(:disabled), +.guide-next-button:hover:not(:disabled) { + background: var(--guide-button-hover-bg); + border-color: var(--guide-button-hover-border); +} + +.guide-back-button:disabled, +.guide-next-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.guide-resize-handle { + background: var(--guide-resize-bg); + min-height: 0; + min-width: 0; + transition: background-color 120ms ease; +} + +.guide-resize-handle:hover { + background: var(--guide-resize-hover-bg); +} + +.guide-resize-handle-columns { + cursor: col-resize; +} + +.guide-resize-handle-rows { + cursor: row-resize; +} + +.guide-workspace { + display: grid; + grid-template-rows: minmax(180px, 1fr) 8px var(--guide-output-height, 220px); + min-height: 100vh; + background: var(--guide-panel-bg); +} + +.guide-editor-panel, +.guide-output-panel { + display: flex; + flex-direction: column; + min-height: 0; +} + +.guide-editor-panel { + background: var(--guide-surface-bg); +} + +.guide-label { + background: var(--guide-surface-bg); + border-bottom: 1px solid var(--guide-border-soft); + color: var(--guide-muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + padding: 10px 14px; + text-transform: uppercase; +} + +.guide-editor { + flex: 1; + font-family: "Roboto Mono", "SFMono-Regular", Consolas, monospace; + font-size: 15px; + line-height: 1.6; + min-height: 0; + --playground-editor-bg: #ffffff; + --playground-editor-text: #102a43; + --playground-editor-cursor: #1f4e79; + --playground-editor-active-line: rgba(31, 78, 121, 0.07); + --playground-editor-gutter-bg: #f8fafc; + --playground-editor-gutter-text: #829ab1; + --playground-editor-gutter-border: #d8d6cf; + --playground-editor-active-gutter-bg: #eef2f6; + --playground-editor-active-gutter-text: #102a43; + --playground-editor-selection: rgba(31, 78, 121, 0.2); + --playground-editor-selection-match: rgba(45, 125, 210, 0.16); + --playground-editor-syntax-keyword: #7b2cbf; + --playground-editor-syntax-variable: #102a43; + --playground-editor-syntax-type: #b45309; + --playground-editor-syntax-string: #0f766e; + --playground-editor-syntax-comment: #6b7280; + --playground-editor-syntax-namespace-def: #b45309; + --playground-editor-syntax-namespace: #2563eb; + --playground-editor-syntax-property: #1f4e79; + --playground-editor-syntax-attribute: #475569; +} + +.guide-theme-dark .guide-editor { + --playground-editor-bg: #101827; + --playground-editor-text: #dbeafe; + --playground-editor-cursor: #38bdf8; + --playground-editor-active-line: rgba(56, 189, 248, 0.12); + --playground-editor-gutter-bg: #0f172a; + --playground-editor-gutter-text: #64748b; + --playground-editor-gutter-border: #334155; + --playground-editor-active-gutter-bg: #172033; + --playground-editor-active-gutter-text: #e5e7eb; + --playground-editor-selection: rgba(56, 189, 248, 0.28); + --playground-editor-selection-match: rgba(56, 189, 248, 0.18); + --playground-editor-syntax-keyword: #c084fc; + --playground-editor-syntax-variable: #dbeafe; + --playground-editor-syntax-type: #fbbf24; + --playground-editor-syntax-string: #5eead4; + --playground-editor-syntax-comment: #94a3b8; + --playground-editor-syntax-namespace-def: #fbbf24; + --playground-editor-syntax-namespace: #7dd3fc; + --playground-editor-syntax-property: #93c5fd; + --playground-editor-syntax-attribute: #cbd5e1; +} + +.guide-editor .cm-editor { + height: 100%; +} + +.guide-editor .cm-scroller { + font-family: inherit; +} + +.guide-output-frame { + display: flex; + flex: 1; + min-height: 0; +} + +.guide-output { + background: #111827; + color: #d1fae5; + flex: 1; + font-family: "Roboto Mono", "SFMono-Regular", Consolas, monospace; + font-size: 15px; + line-height: 1.6; + margin: 0; + overflow: auto; + padding: 18px; +} + +.guide-output-status { + color: #e5e7eb; + margin-bottom: 14px; +} + +.guide-output-group { + margin-top: 18px; +} + +.guide-output-heading { + color: #93c5fd; + font-size: 12px; + font-weight: 700; + margin-bottom: 8px; + text-transform: uppercase; +} + +.guide-output-line { + color: inherit; + font: inherit; + margin: 0 0 8px; + white-space: pre-wrap; +} + +.guide-output-line-error { + color: #fca5a5; +} + +.guide-output-line-warning { + color: #fde68a; +} + +.guide-runtime-frame { + display: none; +} + +@media (max-width: 1023px) { + .guide-screen-size-message { + display: grid; + } + + .guide-shell { + display: none; + } +} diff --git a/apps/guide/vite.config.mjs b/apps/guide/vite.config.mjs new file mode 100644 index 000000000..b6e1594e1 --- /dev/null +++ b/apps/guide/vite.config.mjs @@ -0,0 +1,92 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { playwright } from "@vitest/browser-playwright"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import env from "vite-plugin-env-compatible"; + +const excludedFiles = ["lib/**", "**/*.res", "**/*.resi"]; +const sharedEditorDeps = [ + "@babel/generator", + "@babel/parser", + "@babel/traverse", + "@babel/types", + "@codemirror/commands", + "@codemirror/lang-javascript", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/highlight", + "@replit/codemirror-vim", + "@rescript/runtime/lib/es6/Belt_Array.js", + "@rescript/runtime/lib/es6/Primitive_int.js", + "@rescript/runtime/lib/es6/Primitive_object.js", + "@rescript/runtime/lib/es6/Primitive_option.js", + "@rescript/runtime/lib/es6/Primitive_string.js", + "@rescript/runtime/lib/es6/Stdlib_Array.js", + "@rescript/runtime/lib/es6/Stdlib_Dict.js", + "@rescript/runtime/lib/es6/Stdlib_Int.js", + "@rescript/runtime/lib/es6/Stdlib_JsExn.js", + "@rescript/runtime/lib/es6/Stdlib_List.js", + "@rescript/runtime/lib/es6/Stdlib_Option.js", + "@tsnobip/rescript-lezer", + "lz-string", + "react-markdown", + "react-router", + "vfile-matter", +]; + +export default defineConfig(({ mode }) => { + const isTest = mode === "test"; + + return { + envDir: "../..", + plugins: [ + env({ prefix: "PUBLIC_" }), + ...(isTest ? [] : [reactRouter()]), + isTest + ? react() + : react({ + include: ["**/*.mjs"], + exclude: excludedFiles, + }), + ], + server: { + watch: { + ignored: excludedFiles, + }, + }, + build: { + sourcemap: process.env.NODE_ENV !== "production", + }, + css: { + transformer: "lightningcss", + }, + optimizeDeps: { + include: sharedEditorDeps, + }, + legacy: { + inconsistentCjsInterop: true, + }, + test: { + include: ["__tests__/*_.test.jsx"], + setupFiles: ["./vitest.setup.mjs"], + browser: { + enabled: true, + provider: playwright({ + contextOptions: { + deviceScaleFactor: 1, + }, + }), + ui: false, + instances: [ + { + browser: "chromium", + viewport: { width: 1440, height: 900 }, + }, + ], + }, + }, + }; +}); diff --git a/apps/guide/vitest.setup.mjs b/apps/guide/vitest.setup.mjs new file mode 100644 index 000000000..9dc01a105 --- /dev/null +++ b/apps/guide/vitest.setup.mjs @@ -0,0 +1 @@ +import "./styles/main.css"; diff --git a/package.json b/package.json index e6c484677..8f92badd0 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,18 @@ "scripts": { "build:scripts": "yarn workspace @rescript-lang/docs build:scripts", "build:generate-llms": "yarn workspace @rescript-lang/docs build:generate-llms", + "build:guide": "yarn workspace @rescript-lang/guide build", "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", "build:sync-bundles": "yarn workspace @rescript-lang/docs build:sync-bundles", "build:update-index": "yarn workspace @rescript-lang/docs build:update-index", "build:vite": "yarn workspace @rescript-lang/docs build:vite", "build": "yarn workspace @rescript-lang/docs build", "ci:format": "oxfmt --check", - "ci:test": "yarn workspace @rescript-lang/docs ci:test", + "ci:test": "yarn workspace @rescript-lang/docs ci:test && yarn workspace @rescript-lang/guide ci:test", "clean:res": "rescript clean", "convert-images": "yarn workspace @rescript-lang/docs convert-images", "dev:res": "rescript watch", + "dev:guide": "yarn workspace @rescript-lang/guide dev:vite", "dev:vite": "yarn workspace @rescript-lang/docs dev:vite", "dev:wrangler": "yarn workspace @rescript-lang/docs dev:wrangler", "dev": "yarn workspace @rescript-lang/docs dev", @@ -34,6 +36,7 @@ "cy:run": "yarn workspace @rescript-lang/docs cy:run", "cy:e2e": "yarn workspace @rescript-lang/docs cy:e2e", "vitest": "yarn workspace @rescript-lang/docs vitest", + "vitest:guide": "yarn workspace @rescript-lang/guide vitest", "vitest:update": "yarn workspace @rescript-lang/docs vitest:update" }, "devDependencies": { diff --git a/packages/playground/package.json b/packages/playground/package.json index 23bb0d831..7ff7ebd3d 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "dependencies": { + "@babel/parser": "^7.29.2", "@rescript-lang/shared": "workspace:*", "@rescript/react": "^0.14.2", "@rescript/webapi": "0.1.0-experimental-29db5f4", diff --git a/packages/playground/src/CompilerManagerHook.res b/packages/playground/src/CompilerManagerHook.res index 8dd18c2a1..3e82102e8 100644 --- a/packages/playground/src/CompilerManagerHook.res +++ b/packages/playground/src/CompilerManagerHook.res @@ -244,14 +244,16 @@ let useCompilerManager = ( ~bundleBaseUrl: string, ~initialVersion: option=?, ~initialModuleSystem=defaultModuleSystem, + ~initialWarnFlags=?, ~initialJsxPreserveMode=false, ~initialExperimentalFeatures=[], ~initialLang: Lang.t=Res, ~onAction: option unit>=?, + ~syncUrl=true, ~versions: array, ) => { let (state, setState) = React.useState(_ => Init) - let {pathname} = PlaygroundReactRouter.useLocation() + let {pathname} = ReactRouter.useLocation() // Dispatch method for the public interface let dispatch = React.useCallback((action: action): unit => { @@ -444,9 +446,11 @@ let useCompilerManager = ( // should default to esmodule. So we override the config // and use the `setConfig` function to sync up the // internal compiler state with our playground state. + let defaultConfig = instance->Compiler.getConfig let config = { - ...instance->Compiler.getConfig, + ...defaultConfig, moduleSystem: initialModuleSystem, + warnFlags: initialWarnFlags->Option.getOr(defaultConfig.warnFlags), experimentalFeatures: initialExperimentalFeatures, jsxPreserveMode: initialJsxPreserveMode, ?openModules, @@ -507,9 +511,11 @@ let useCompilerManager = ( let apiVersion = apiVersion->Version.fromString let openModules = getOpenModules(~apiVersion, ~libraries) + let defaultConfig = instance->Compiler.getConfig let config = { - ...instance->Compiler.getConfig, + ...defaultConfig, moduleSystem: defaultModuleSystem, + warnFlags: initialWarnFlags->Option.getOr(defaultConfig.warnFlags), ?openModules, } @@ -619,9 +625,10 @@ let useCompilerManager = ( : EvalIFrame.sendOutput(code, imports) setState(_ => Ready({...state, logs: [], validReactCode: entryPointExists})) | SetupFailed(_) => () - | Ready(ready) => + | Ready(ready) if syncUrl => let url = createUrl((pathname :> string), ready) WebAPI.History.replaceState(history, ~data=JSON.Null, ~unused="", ~url) + | Ready(_) => () } } @@ -632,11 +639,13 @@ let useCompilerManager = ( dispatchError, initialVersion, initialModuleSystem, + initialWarnFlags, initialJsxPreserveMode, initialExperimentalFeatures, initialLang, versions, pathname, + syncUrl, )) (state, dispatch) diff --git a/packages/playground/src/CompilerManagerHook.resi b/packages/playground/src/CompilerManagerHook.resi index 7f2238b16..47c13ef66 100644 --- a/packages/playground/src/CompilerManagerHook.resi +++ b/packages/playground/src/CompilerManagerHook.resi @@ -64,10 +64,12 @@ let useCompilerManager: ( ~bundleBaseUrl: string, ~initialVersion: Semver.t=?, ~initialModuleSystem: string=?, + ~initialWarnFlags: string=?, ~initialJsxPreserveMode: bool=?, ~initialExperimentalFeatures: array=?, ~initialLang: Lang.t=?, ~onAction: action => unit=?, + ~syncUrl: bool=?, ~versions: array, ) => (state, action => unit) diff --git a/packages/playground/src/Playground.res b/packages/playground/src/Playground.res index ae1a2bb83..37f210e68 100644 --- a/packages/playground/src/Playground.res +++ b/packages/playground/src/Playground.res @@ -1644,7 +1644,7 @@ let initialReContent = `Js.log("Hello Reason 3.6!");` @react.component let make = (~bundleBaseUrl: string, ~versions: array) => { - let (searchParams, _) = PlaygroundReactRouter.useSearchParams() + let (searchParams, _) = ReactRouter.useSearchParams() let containerRef = React.useRef(Nullable.null) let editorRef: React.ref> = React.useRef(None) let (_, setScrollLock) = ScrollLockContext.useScrollLock() diff --git a/packages/playground/src/PlaygroundReactRouter.res b/packages/playground/src/PlaygroundReactRouter.res deleted file mode 100644 index 0b8fce77d..000000000 --- a/packages/playground/src/PlaygroundReactRouter.res +++ /dev/null @@ -1,11 +0,0 @@ -type location = { - pathname: string, - search?: string, - hash?: string, -} - -@module("react-router") -external useLocation: unit => location = "useLocation" - -@module("react-router") -external useSearchParams: unit => (WebAPI.URLAPI.urlSearchParams, {..} => unit) = "useSearchParams" diff --git a/packages/shared/package.json b/packages/shared/package.json index 1a7585dce..a74811bef 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,6 +7,7 @@ "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.2", "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@codemirror/commands": "^6.10.3", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.12.3", @@ -21,6 +22,13 @@ "@tsnobip/rescript-lezer": "^0.8.0", "highlight.js": "^11.11.1", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "remark-comment": "^1.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.5", + "vfile-matter": "^5.0.1" } } diff --git a/packages/shared/src/Babel.res b/packages/shared/src/Babel.res index 93f120809..561cd4eec 100644 --- a/packages/shared/src/Babel.res +++ b/packages/shared/src/Babel.res @@ -1,5 +1,7 @@ module Ast = { - type t + type statement + type program = {mutable body: array} + type t = {program: program} @tag("type") type lval = Identifier({name: string}) @@ -37,6 +39,16 @@ module Ast = { type t = ImportDeclaration({specifiers: array, source: StringLiteral.t}) } + module ExpressionStatement = { + @tag("type") + type t = ExpressionStatement({expression: expression}) + } + + module FunctionDeclaration = { + @tag("type") + type t = FunctionDeclaration({id: lval}) + } + module Identifier = { @tag("type") type t = Identifier({mutable name: string}) @@ -49,9 +61,40 @@ module Ast = { | ...VariableDeclarator.t | ...VariableDeclaration.t | ...ImportDeclaration.t + | ...ExpressionStatement.t + | ...FunctionDeclaration.t | ...Identifier.t type nodePath<'nodeType> = {node: 'nodeType} + + @get external nodeType: statement => string = "type" + @get external expression: statement => expression = "expression" + @get external source: statement => StringLiteral.t = "source" + @get external specifiers: statement => array = "specifiers" + @get external declarations: statement => array = "declarations" + @get external statementId: statement => lval = "id" + + let lvalName = (lval: lval) => + switch lval { + | Identifier({name}) => name + } + + let stringLiteralValue = (stringLiteral: StringLiteral.t) => + switch stringLiteral { + | StringLiteral({value}) => value + } + + let specifierLocal = (specifier: Specifier.t) => + switch specifier { + | ImportSpecifier({local}) + | ImportDefaultSpecifier({local}) + | ImportNamespaceSpecifier({local}) => local + } + + let variableDeclaratorId = (variableDeclarator: VariableDeclarator.t) => + switch variableDeclarator { + | VariableDeclarator({id}) => id + } } module Parser = { @@ -67,7 +110,21 @@ module Generator = { @send external remove: Ast.nodePath<'nodeType> => unit = "remove" type t = {code: string} - @module("@babel/generator") external generator: Ast.t => t = "default" + @module("@babel/generator") external generator: Ast.t => t = "generate" +} + +module Types = { + @module("@babel/types") external identifier: string => Ast.expression = "identifier" + + @module("@babel/types") + external memberExpression: (Ast.expression, Ast.expression) => Ast.expression = "memberExpression" + + @module("@babel/types") + external callExpression: (Ast.expression, array) => Ast.expression = + "callExpression" + + @module("@babel/types") + external expressionStatement: Ast.expression => Ast.statement = "expressionStatement" } module PlaygroundValidator = { diff --git a/apps/docs/src/markdown/MarkdownParser.res b/packages/shared/src/MarkdownParser.res similarity index 100% rename from apps/docs/src/markdown/MarkdownParser.res rename to packages/shared/src/MarkdownParser.res diff --git a/apps/docs/src/markdown/MarkdownParser.resi b/packages/shared/src/MarkdownParser.resi similarity index 100% rename from apps/docs/src/markdown/MarkdownParser.resi rename to packages/shared/src/MarkdownParser.resi diff --git a/apps/docs/src/bindings/Node.res b/packages/shared/src/Node.res similarity index 100% rename from apps/docs/src/bindings/Node.res rename to packages/shared/src/Node.res diff --git a/apps/docs/src/common/Path.res b/packages/shared/src/Path.res similarity index 100% rename from apps/docs/src/common/Path.res rename to packages/shared/src/Path.res diff --git a/apps/docs/src/bindings/ReactRouter.res b/packages/shared/src/ReactRouter.res similarity index 95% rename from apps/docs/src/bindings/ReactRouter.res rename to packages/shared/src/ReactRouter.res index 194d31343..a6d4ade12 100644 --- a/apps/docs/src/bindings/ReactRouter.res +++ b/packages/shared/src/ReactRouter.res @@ -1,11 +1,9 @@ type navigateOptions = {replace?: bool} - -@module("react-router-dom") -external navigate: (string, ~options: navigateOptions=?) => unit = "navigate" +type navigate = (string, ~options: navigateOptions=?) => unit // https://api.reactrouter.com/v7/functions/react_router.useNavigate.html @module("react-router") -external useNavigate: unit => string => unit = "useNavigate" +external useNavigate: unit => navigate = "useNavigate" @module("react-router") external useSearchParams: unit => (WebAPI.URLAPI.urlSearchParams, {..} => unit) = "useSearchParams" diff --git a/apps/docs/src/bindings/Vitest.res b/packages/shared/src/Vitest.res similarity index 81% rename from apps/docs/src/bindings/Vitest.res rename to packages/shared/src/Vitest.res index c2428c72d..18fb832e4 100644 --- a/apps/docs/src/bindings/Vitest.res +++ b/packages/shared/src/Vitest.res @@ -101,5 +101,26 @@ external toBeVisible: element => promise = "toBeVisible" @send @scope("not") external notToBeVisible: element => promise = "toBeVisible" +@send +external toBeDisabled: element => promise = "toBeDisabled" + +@send @scope("not") +external notToBeDisabled: element => promise = "toBeDisabled" + +@send +external toHaveValue: (element, string) => promise = "toHaveValue" + +@send +external toHaveTextContent: (element, string) => promise = "toHaveTextContent" + +@send +external toHaveClass: (element, string) => promise = "toHaveClass" + @send external toMatchScreenshot: (element, string) => promise = "toMatchScreenshot" + +@get +external container: element => Dom.element = "container" + +@get +external textContent: Dom.element => Nullable.t = "textContent" diff --git a/rescript.json b/rescript.json index 095144ecb..a7323f7d2 100644 --- a/rescript.json +++ b/rescript.json @@ -3,6 +3,7 @@ "dependencies": [ "@rescript-lang/shared", "@rescript-lang/playground", + "@rescript-lang/guide", "@rescript-lang/docs" ], "sources": [], diff --git a/yarn.lock b/yarn.lock index 584876d8c..b6ea162b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2453,10 +2453,49 @@ __metadata: languageName: unknown linkType: soft +"@rescript-lang/guide@workspace:apps/guide": + version: 0.0.0-use.local + resolution: "@rescript-lang/guide@workspace:apps/guide" + dependencies: + "@babel/generator": "npm:^7.29.1" + "@babel/parser": "npm:^7.29.2" + "@babel/types": "npm:^7.29.0" + "@react-router/dev": "npm:^7.14.0" + "@react-router/node": "npm:^7.14.0" + "@rescript-lang/playground": "workspace:*" + "@rescript-lang/shared": "workspace:*" + "@rescript/react": "npm:^0.14.2" + "@rescript/webapi": "npm:0.1.0-experimental-29db5f4" + "@vitejs/plugin-react": "npm:^6.0.1" + "@vitest/browser-playwright": "npm:^4.1.2" + isbot: "npm:^5" + lightningcss: "npm:^1.32.0" + playwright: "npm:^1.59.1" + react: "npm:^19.2.4" + react-dom: "npm:^19.2.4" + react-markdown: "npm:^10.1.0" + react-router: "npm:^7.14.0" + react-router-dom: "npm:^7.14.0" + remark-comment: "npm:^1.0.0" + remark-frontmatter: "npm:^5.0.0" + remark-gfm: "npm:^4.0.1" + remark-parse: "npm:^11.0.0" + remark-stringify: "npm:^11.0.0" + rescript: "npm:^12.2.0" + unified: "npm:^11.0.5" + vfile-matter: "npm:^5.0.1" + vite: "npm:^8.0.3" + vite-plugin-env-compatible: "npm:^2.0.1" + vitest: "npm:^4.1.2" + vitest-browser-react: "npm:^2.2.0" + languageName: unknown + linkType: soft + "@rescript-lang/playground@workspace:*, @rescript-lang/playground@workspace:packages/playground": version: 0.0.0-use.local resolution: "@rescript-lang/playground@workspace:packages/playground" dependencies: + "@babel/parser": "npm:^7.29.2" "@rescript-lang/shared": "workspace:*" "@rescript/react": "npm:^0.14.2" "@rescript/webapi": "npm:0.1.0-experimental-29db5f4" @@ -2474,6 +2513,7 @@ __metadata: "@babel/generator": "npm:^7.29.1" "@babel/parser": "npm:^7.29.2" "@babel/traverse": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" "@codemirror/commands": "npm:^6.10.3" "@codemirror/lang-javascript": "npm:^6.2.5" "@codemirror/language": "npm:^6.12.3" @@ -2489,6 +2529,13 @@ __metadata: highlight.js: "npm:^11.11.1" react: "npm:^19.2.4" react-dom: "npm:^19.2.4" + remark-comment: "npm:^1.0.0" + remark-frontmatter: "npm:^5.0.0" + remark-gfm: "npm:^4.0.1" + remark-parse: "npm:^11.0.0" + remark-stringify: "npm:^11.0.0" + unified: "npm:^11.0.5" + vfile-matter: "npm:^5.0.1" languageName: unknown linkType: soft @@ -6846,6 +6893,13 @@ __metadata: languageName: node linkType: hard +"isbot@npm:^5": + version: 5.1.39 + resolution: "isbot@npm:5.1.39" + checksum: 10c0/b6cfd4fa59662e7f660cefb7396629ab8d3142c2c4d4240fc4e8ecd08942f8c1c5f5b7b17861231466bdfb6e03ea924381470908b1d7aef5a9632fff9403e692 + languageName: node + linkType: hard + "isbot@npm:^5.1.11, isbot@npm:^5.1.37": version: 5.1.37 resolution: "isbot@npm:5.1.37"