diff --git a/.gitignore b/.gitignore index 512668a86..0fce7f12c 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,8 @@ apps/docs/functions/**/*.mjs apps/docs/functions/**/*.jsx apps/docs/__tests__/**/*.mjs apps/docs/__tests__/**/*.jsx +apps/docs/test-utils/**/*.mjs +apps/docs/test-utils/**/*.jsx apps/guide/__tests__/**/*.mjs apps/guide/__tests__/**/*.jsx apps/docs/e2e/**/*.mjs diff --git a/apps/docs/__tests__/visual/BlogArticle_.test.res b/apps/docs/__tests__/visual/BlogArticle_.test.res index 59b3cd464..c3f6083a4 100644 --- a/apps/docs/__tests__/visual/BlogArticle_.test.res +++ b/apps/docs/__tests__/visual/BlogArticle_.test.res @@ -166,7 +166,7 @@ test("desktop blog article with article image shows image", async () => { await element(title)->toBeVisible let wrapper = await screen->getByTestId("blog-article-wrapper") - await waitForImages("[data-testid='blog-article-wrapper']") + await TestUtils.waitForImages("[data-testid='blog-article-wrapper']") await element(wrapper)->toMatchScreenshot("desktop-blog-article-with-image") }) diff --git a/apps/docs/__tests__/visual/LandingPage_.test.res b/apps/docs/__tests__/visual/LandingPage_.test.res index 96db60725..fd8d8aabc 100644 --- a/apps/docs/__tests__/visual/LandingPage_.test.res +++ b/apps/docs/__tests__/visual/LandingPage_.test.res @@ -1,9 +1,6 @@ open ReactRouter open Vitest -@module("vitest") -external testWithTimeout: (string, unit => promise, int) => unit = "test" - let snapshotSection = async (~width, ~height, ~sectionTestId, ~screenshotName) => { await viewport(width, height) @@ -20,6 +17,15 @@ let snapshotSection = async (~width, ~height, ~sectionTestId, ~screenshotName) = | Null => failwith(`expected to find section ${sectionTestId}`) } + if sectionTestId == "landing-other-selling-points" { + let sourceSelector = `[data-testid="${sectionTestId}"]` + await TestUtils.waitForImages(sourceSelector) + // Headless UI's appear transition mutates classes after first render. Since + // these tests snapshot a cloned outerHTML string, wait for the live section + // to settle so the clone does not preserve a transient opacity-0 state. + await TestUtils.sleep(1100) + } + let sandboxTestId = `${sectionTestId}-snapshot` let snapshotHtml = sourceSection.outerHTML await screen->unmount @@ -34,7 +40,7 @@ let snapshotSection = async (~width, ~height, ~sectionTestId, ~screenshotName) = let snapshotTarget = await snapshotScreen->getByTestId(sandboxTestId) await element(snapshotTarget)->toBeVisible - await waitForImages(`[data-testid="${sandboxTestId}"]`) + await TestUtils.waitForImages(`[data-testid="${sandboxTestId}"]`) await element(snapshotTarget)->toMatchScreenshot(screenshotName) await snapshotScreen->unmount } diff --git a/apps/docs/__tests__/visual/MarkdownComponents_.test.res b/apps/docs/__tests__/visual/MarkdownComponents_.test.res index 9433e8a07..7976d557f 100644 --- a/apps/docs/__tests__/visual/MarkdownComponents_.test.res +++ b/apps/docs/__tests__/visual/MarkdownComponents_.test.res @@ -214,7 +214,7 @@ test("renders Image with caption", async () => { await element(caption)->toBeVisible let wrapper = await screen->getByTestId("image-wrapper") - await waitForImages("[data-testid='image-wrapper']") + await TestUtils.waitForImages("[data-testid='image-wrapper']") await element(wrapper)->toMatchScreenshot("markdown-image") }) @@ -292,7 +292,7 @@ test("renders Image with small size", async () => { await element(caption)->toBeVisible let wrapper = await screen->getByTestId("image-small-wrapper") - await waitForImages("[data-testid='image-small-wrapper']") + await TestUtils.waitForImages("[data-testid='image-small-wrapper']") await element(wrapper)->toMatchScreenshot("markdown-image-small") }) diff --git a/apps/docs/__tests__/visual/NavbarSecondary_.test.res b/apps/docs/__tests__/visual/NavbarSecondary_.test.res index 796b30a99..c33eea709 100644 --- a/apps/docs/__tests__/visual/NavbarSecondary_.test.res +++ b/apps/docs/__tests__/visual/NavbarSecondary_.test.res @@ -14,6 +14,7 @@ test("desktop secondary navbar shows all doc section links", async () => { await element(await navbar->getByText("Language Manual"))->toBeVisible await element(await navbar->getByText("API"))->toBeVisible + await element(await navbar->getByText("Guides"))->toBeVisible await element(await navbar->getByText("Syntax Lookup"))->toBeVisible await element(await navbar->getByText("React"))->toBeVisible @@ -33,6 +34,7 @@ test("mobile secondary navbar shows all links", async () => { await element(await navbar->getByText("Language Manual"))->toBeVisible await element(await navbar->getByText("API"))->toBeVisible + await element(await navbar->getByText("Guides"))->toBeVisible await element(await navbar->getByText("Syntax Lookup"))->toBeVisible await element(await navbar->getByText("React"))->toBeVisible @@ -52,6 +54,7 @@ test("secondary navbar highlights active section", async () => { await element(await navbar->getByText("React"))->toBeVisible await element(await navbar->getByText("Language Manual"))->toBeVisible + await element(await navbar->getByText("Guides"))->toBeVisible await element(navbar)->toMatchScreenshot("desktop-navbar-secondary-react-active") }) diff --git a/apps/docs/app/DocsRoutes.res b/apps/docs/app/DocsRoutes.res index 4f4183d7e..2992c859f 100644 --- a/apps/docs/app/DocsRoutes.res +++ b/apps/docs/app/DocsRoutes.res @@ -57,11 +57,10 @@ let docsReactRoutes = route(path, "./routes/DocsReactRoute.jsx", ~options={id: path}) ) -let docsGuidelinesRoutes = - MdxFile.scanPaths( - ~dir="markdown-pages/docs/guidelines", - ~alias="docs/guidelines", - )->Array.map(path => route(path, "./routes/DocsGuidelinesRoute.jsx", ~options={id: path})) +let docsGuidesRoutes = + MdxFile.scanPaths(~dir="markdown-pages/docs/guides", ~alias="docs/guides")->Array.map(path => + route(path, "./routes/DocsGuidesRoute.jsx", ~options={id: path}) + ) let communityRoutes = MdxFile.scanPaths(~dir="markdown-pages/community", ~alias="community")->Array.map(path => @@ -95,8 +94,8 @@ let default = [ ...beltRoutes, ...domRoutes, ...docsManualRoutes, + ...docsGuidesRoutes, ...docsReactRoutes, - ...docsGuidelinesRoutes, route("syntax-lookup", "./routes/SyntaxLookupRoute.jsx", ~options={id: "syntax-lookup"}), ...syntaxLookupDetailRoutes, ], diff --git a/apps/docs/app/DocsRoutes.resi b/apps/docs/app/DocsRoutes.resi index ebb3514d4..51f6d36ee 100644 --- a/apps/docs/app/DocsRoutes.resi +++ b/apps/docs/app/DocsRoutes.resi @@ -7,7 +7,7 @@ let beltRoutes: array let blogArticleRoutes: array let docsManualRoutes: array let docsReactRoutes: array -let docsGuidelinesRoutes: array +let docsGuidesRoutes: array let communityRoutes: array let syntaxLookupDetailRoutes: array let default: array diff --git a/apps/docs/app/layouts/DocsLayoutRoute.res b/apps/docs/app/layouts/DocsLayoutRoute.res index 3cc313263..fb19c2034 100644 --- a/apps/docs/app/layouts/DocsLayoutRoute.res +++ b/apps/docs/app/layouts/DocsLayoutRoute.res @@ -1,7 +1,12 @@ @react.component let default = () => { + let location = ReactRouter.useLocation() + <> - + // This layout persists across docs route changes. Key the secondary nav by + // pathname so its scroll-direction state cannot keep the mobile nav hidden + // after client-side navigation. + string)} /> } diff --git a/apps/docs/app/routes/DocsGuidelinesRoute.res b/apps/docs/app/routes/DocsGuidesRoute.res similarity index 55% rename from apps/docs/app/routes/DocsGuidelinesRoute.res rename to apps/docs/app/routes/DocsGuidesRoute.res index d73721675..820e81d92 100644 --- a/apps/docs/app/routes/DocsGuidelinesRoute.res +++ b/apps/docs/app/routes/DocsGuidesRoute.res @@ -1,17 +1,30 @@ type loaderData = { compiledMdx: CompiledMdx.t, + categories: array, entries: array, title: string, description: string, filePath: string, } +let guidesTableOfContents = async () => { + let groups = + (await MdxFile.loadAllAttributes(~dir="markdown-pages/docs")) + ->Mdx.filterMdxPages("docs/guides") + ->Mdx.groupBySection + ->Dict.mapValues(values => + values->Mdx.sortSection->SidebarHelpers.convertToNavItems("/docs/guides") + ) + + SidebarHelpers.getAllGroups(groups, ["Overview", "Packages"]) +} + let loader: ReactRouter.Loader.t = async ({request}) => { let {pathname} = WebAPI.URL.make(~url=request.url) let filePath = MdxFile.resolveFilePath( (pathname :> string), - ~dir="markdown-pages/docs/guidelines", - ~alias="docs/guidelines", + ~dir="markdown-pages/docs/guides", + ~alias="docs/guides", ) let raw = await Node.Fs.readFile(filePath, "utf-8") @@ -20,37 +33,49 @@ let loader: ReactRouter.Loader.t = async ({request}) => { let description = FrontmatterUtils.getField(frontmatter, "description") let title = FrontmatterUtils.getField(frontmatter, "title") + let categories = await guidesTableOfContents() + let compiledMdx = await MdxFile.compileMdx(raw, ~filePath, ~remarkPlugins=Mdx.plugins) let entries = TocUtils.buildEntries(raw) { compiledMdx, + categories, entries, - title: `${title} | ReScript Guidelines`, + title: `${title} | ReScript Guides`, description, filePath, } } let default = () => { - let {compiledMdx, entries, title, description, filePath} = ReactRouter.useLoaderData() + let {compiledMdx, categories, entries, title, description, filePath} = ReactRouter.useLoaderData() + + let breadcrumbs = list{ + {Url.name: "Docs", href: "/docs/guides/overview"}, + { + Url.name: "Guides", + href: "/docs/guides/overview", + }, + } let docsAppRoot = "apps/docs" let editHref = `https://github.com/rescript-lang/rescript-lang.org/blob/master/${docsAppRoot}/${filePath}` - let categories: array = [] + let activeToc = {TableOfContents.title, entries} <> - + }> + {React.string("Edit")} - +
diff --git a/apps/docs/app/routes/DocsGuidelinesRoute.resi b/apps/docs/app/routes/DocsGuidesRoute.resi similarity index 84% rename from apps/docs/app/routes/DocsGuidelinesRoute.resi rename to apps/docs/app/routes/DocsGuidesRoute.resi index 307767dcb..6eea0299c 100644 --- a/apps/docs/app/routes/DocsGuidelinesRoute.resi +++ b/apps/docs/app/routes/DocsGuidesRoute.resi @@ -1,5 +1,6 @@ type loaderData = { compiledMdx: CompiledMdx.t, + categories: array, entries: array, title: string, description: string, diff --git a/apps/docs/app/routes/Packages.res b/apps/docs/app/routes/Packages.res index 3397540de..c8d2e416c 100644 --- a/apps/docs/app/routes/Packages.res +++ b/apps/docs/app/routes/Packages.res @@ -287,11 +287,11 @@ module InfoSidebar = {

{React.string("Guidelines")}

    - + {React.string("Publishing ReScript npm packages")} /*
  • */ - /* */ + /* */ /* {React.string("Writing Bindings & Libraries")} */ /* */ /*
  • */ diff --git a/apps/docs/e2e/Navigation.cy.res b/apps/docs/e2e/Navigation.cy.res index 5bb185a91..0b04f759a 100644 --- a/apps/docs/e2e/Navigation.cy.res +++ b/apps/docs/e2e/Navigation.cy.res @@ -105,6 +105,11 @@ describe("Desktop Navigation", () => { clickNavLink(~testId="navbar-secondary", ~text="API") url()->shouldInclude("/docs/manual/api")->ignore + // Guides + clickNavLink(~testId="navbar-secondary", ~text="Guides") + url()->shouldInclude("/docs/guides/overview")->ignore + get("h1")->shouldContainText("Guides")->ignore + // Syntax Lookup clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup") url()->shouldInclude("/syntax-lookup")->ignore @@ -198,6 +203,12 @@ describe("Mobile Navigation", () => { clickNavLink(~testId="navbar-secondary", ~text="API") url()->shouldInclude("/docs/manual/api")->ignore + // Guides + cyScrollTo("top") + clickNavLink(~testId="navbar-secondary", ~text="Guides") + url()->shouldInclude("/docs/guides/overview")->ignore + get("h1")->shouldContainText("Guides")->ignore + // Syntax Lookup cyScrollTo("top") clickNavLink(~testId="navbar-secondary", ~text="Syntax Lookup") diff --git a/apps/docs/markdown-pages/docs/guides/overview.mdx b/apps/docs/markdown-pages/docs/guides/overview.mdx new file mode 100644 index 000000000..977e7e2bf --- /dev/null +++ b/apps/docs/markdown-pages/docs/guides/overview.mdx @@ -0,0 +1,28 @@ +--- +title: "Guides" +description: "Task-oriented ReScript guides" +canonical: "/docs/guides/overview" +section: "Overview" +order: 1 +--- + +# Guides + +Task-oriented guides collect workflows that cut across the language manual, API reference, and framework-specific docs. + +## Start Here + +- [ReScript for JavaScript Developers](../manual/rescript-for-javascript-developers.mdx) +- [Converting from JS](../manual/converting-from-js.mdx) +- [Project Structure](../manual/project-structure.mdx) +- [Dead Code Analysis in ReScript](../manual/editor-code-analysis.mdx) + +## React + +- [Beyond JSX](../react/beyond-jsx.mdx) +- [Extensions of Props](../react/extensions-of-props.mdx) +- [Forwarding Refs](../react/forwarding-refs.mdx) + +## Packages + +- [Publishing ReScript packages](./publishing-packages.mdx) diff --git a/apps/docs/markdown-pages/docs/guidelines/publishing-packages.mdx b/apps/docs/markdown-pages/docs/guides/publishing-packages.mdx similarity index 91% rename from apps/docs/markdown-pages/docs/guidelines/publishing-packages.mdx rename to apps/docs/markdown-pages/docs/guides/publishing-packages.mdx index 7c0f9d6aa..b57e25fc8 100644 --- a/apps/docs/markdown-pages/docs/guidelines/publishing-packages.mdx +++ b/apps/docs/markdown-pages/docs/guides/publishing-packages.mdx @@ -1,7 +1,9 @@ --- title: "Publishing ReScript packages" description: "Guidelines on publishing ReScript bindings and libraries to our Package Index" -canonical: "/docs/guidelines/publishing-packages" +canonical: "/docs/guides/publishing-packages" +section: "Packages" +order: 1 --- [Back to packages](/packages) diff --git a/apps/docs/public/_redirects b/apps/docs/public/_redirects index e7ca207cc..c82a19b89 100644 --- a/apps/docs/public/_redirects +++ b/apps/docs/public/_redirects @@ -1,5 +1,6 @@ /community /community/overview 308 /bucklescript-rebranding /blog/bucklescript-is-rebranding 308 +/docs/guidelines/publishing-packages /docs/guides/publishing-packages 308 /docs/manual/v12.0.0/* /docs/manual/:splat 308 /docs/manual/next/* /docs/manual/:splat 308 diff --git a/apps/docs/rescript.json b/apps/docs/rescript.json index 927e59552..d77357b84 100644 --- a/apps/docs/rescript.json +++ b/apps/docs/rescript.json @@ -19,6 +19,11 @@ "subdirs": true, "type": "dev" }, + { + "dir": "test-utils", + "subdirs": true, + "type": "dev" + }, { "dir": "app", "subdirs": true diff --git a/apps/docs/scripts/test-redirects.mjs b/apps/docs/scripts/test-redirects.mjs index f1d907a43..6999dafed 100644 --- a/apps/docs/scripts/test-redirects.mjs +++ b/apps/docs/scripts/test-redirects.mjs @@ -69,6 +69,11 @@ let assertManualLlmsVersionRedirects = (sourceVersion, destinationVersion) => { }; assertRedirect("/llms/manual/llms.txt", "/llms.txt", "307"); +assertRedirect( + "/docs/guidelines/publishing-packages", + "/docs/guides/publishing-packages", + "308", +); const latestAlias = assertRedirect( "/llms/manual/latest/llms.txt", "/llms.txt", diff --git a/apps/docs/src/components/NavbarSecondary.res b/apps/docs/src/components/NavbarSecondary.res index 8e25b3434..a17d05b56 100644 --- a/apps/docs/src/components/NavbarSecondary.res +++ b/apps/docs/src/components/NavbarSecondary.res @@ -31,6 +31,13 @@ let make = () => { > {React.string("API")} + + {React.string("Guides")} + Vitest.getByTextWithOptions(element, text, {"exact": true}) + +let sleep = ms => + Promise.make((resolve, _) => { + let _timeoutId = setTimeout(~handler=() => { + resolve() + }, ~timeout=ms) + }) + +external imageFromNode: WebAPI.DOMAPI.node => WebAPI.DOMAPI.htmlImageElement = "%identity" + +let waitForImages = async (selector: string) => { + let root = switch document->WebAPI.Document.querySelector(selector) { + | Value(root) => root + | Null => failwith(`expected to find screenshot target ${selector}`) + } + + let images = root->WebAPI.Element.querySelectorAll("img") + + if images.length > 0 { + for i in 0 to images.length - 1 { + let image = images->WebAPI.NodeList.item(i)->imageFromNode + await image->WebAPI.HTMLImageElement.decode + } + } +} diff --git a/packages/shared/src/Path.res b/packages/shared/src/Path.res index 8c6ab183a..cd26075e6 100644 --- a/packages/shared/src/Path.res +++ b/packages/shared/src/Path.res @@ -310,7 +310,8 @@ type t = [ | #"/docs/manual/async-await" | #"/docs/manual/array-and-list" | #"/docs/manual/api" - | #"/docs/guidelines/publishing-packages" + | #"/docs/guides/overview" + | #"/docs/guides/publishing-packages" | #"/community/translations" | #"/community/roadmap" | #"/community/overview" diff --git a/packages/shared/src/Vitest.res b/packages/shared/src/Vitest.res index 18fb832e4..2555d707b 100644 --- a/packages/shared/src/Vitest.res +++ b/packages/shared/src/Vitest.res @@ -9,6 +9,9 @@ type mock @module("vitest") external test: (string, unit => promise) => unit = "test" +@module("vitest") +external testWithTimeout: (string, unit => promise, int) => unit = "test" + @module("vitest") @scope("vi") external fn: unit => 'a => 'b = "fn" @@ -45,8 +48,6 @@ external getByText: (element, string) => promise = "getByText" @send external getByTextWithOptions: (element, string, {"exact": bool}) => promise = "getByText" -let getByTextExact = (element, text) => getByTextWithOptions(element, text, {"exact": true}) - @send external getByLabelText: (element, string) => promise = "getByLabelText" @@ -56,24 +57,6 @@ external getAllByLabelText: (element, string) => promise> = "getA @send external getByRole: (element, [#button]) => promise = "getByRole" -external imageFromNode: WebAPI.DOMAPI.node => WebAPI.DOMAPI.htmlImageElement = "%identity" - -let waitForImages = async (selector: string) => { - let root = switch document->WebAPI.Document.querySelector(selector) { - | Value(root) => root - | Null => failwith(`expected to find screenshot target ${selector}`) - } - - let images = root->WebAPI.Element.querySelectorAll("img") - - if images.length > 0 { - for i in 0 to images.length - 1 { - let image = images->WebAPI.NodeList.item(i)->imageFromNode - await image->WebAPI.HTMLImageElement.decode - } - } -} - /** * Actions */