diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43a4c47fc3..b1f8c702cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ env: jobs: build-compiler: + outputs: + api-docs-artifact-id: ${{ steps.upload-api-docs.outputs.artifact-id }} strategy: fail-fast: false matrix: @@ -36,6 +38,7 @@ jobs: upload_binaries: true # Build the playground compiler and run the benchmarks on the fastest runner build_playground: true + generate_api_docs: true benchmarks: true node-target: linux-arm64 rust-target: aarch64-unknown-linux-musl @@ -438,6 +441,18 @@ jobs: name: lib-ocaml path: lib/ocaml + - name: Generate API Docs + if: ${{ matrix.generate_api_docs }} + run: yarn apidocs:generate + + - name: "Upload artifacts: scripts/res/apiDocs" + id: upload-api-docs + if: ${{ matrix.generate_api_docs }} + uses: actions/upload-artifact@v4 + with: + name: api-docs + path: scripts/res/apiDocs/ + pkg-pr-new: needs: - build-compiler @@ -465,6 +480,48 @@ jobs: run: | yarn dlx pkg-pr-new publish "." "./packages/@rescript/*" + api-docs: + needs: + - build-compiler + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout rescript-lang.org + uses: actions/checkout@v4 + with: + repository: rescript-lang/rescript-lang.org + ssh-key: ${{ secrets.RESCRIPT_LANG_ORG_DEPLOY_KEY }} + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ needs.build-compiler.outputs.api-docs-artifact-id }} + path: data/api + + - name: Check if repo is clean + id: diffcheck + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "clean=true" >> $GITHUB_OUTPUT + else + echo "clean=false" >> $GITHUB_OUTPUT + fi + + - name: Build website + if: steps.diffcheck.outputs.clean == 'false' + run: | + npm ci + npx rescript + npm run build + + - name: Commit and push + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions@rescript-lang.org" + git add data/api + git commit -m "Update API docs for ${{ github.ref_name }}" + git push + test-integration: needs: - pkg-pr-new diff --git a/CHANGELOG.md b/CHANGELOG.md index a16a527114..99f48294cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ - Improve error messages around type mismatches for try/catch, if, for, while, and optional record fields + optional function arguments. https://github.com/rescript-lang/rescript/pull/7522 - sync Reanalyze with the new APIs around exception. https://github.com/rescript-lang/rescript/pull/7536 - Improve array pattern spread error message. https://github.com/rescript-lang/rescript/pull/7549 +- Sync API docs with rescript-lang.org on release. https://github.com/rescript-lang/rescript/pull/7555 #### :house: Internal @@ -63,6 +64,7 @@ - Add `-editor-mode` arg to `bsc` for doing special optimizations only relevant to the editor tooling. https://github.com/rescript-lang/rescript/pull/7541 #### :boom: Breaking Change + - `Iterator.forEach` now emits `Iterator.prototype.forEach` call. https://github.com/rescript-lang/rescript/pull/7506 # 12.0.0-alpha.13 diff --git a/package.json b/package.json index a246219042..c31e3b55c2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "check:all": "biome check .", "format": "biome check --changed --no-errors-on-unmatched . --fix", "coverage": "nyc --timeout=3000 --reporter=html mocha tests/tests/src/*_test.js && open ./coverage/index.html", - "typecheck": "tsc" + "typecheck": "tsc", + "apidocs:generate": "yarn workspace @utils/scripts apidocs:generate" }, "files": [ "CHANGELOG.md", @@ -96,8 +97,10 @@ "packages/@rescript/*", "tests/dependencies/**", "tests/analysis_tests/**", + "tests/docstring_tests", "tests/gentype_tests/**", - "tests/tools_tests" + "tests/tools_tests", + "scripts/res" ], "packageManager": "yarn@4.9.1", "preferUnplugged": true diff --git a/scripts/res/.gitignore b/scripts/res/.gitignore new file mode 100644 index 0000000000..c2cef0acba --- /dev/null +++ b/scripts/res/.gitignore @@ -0,0 +1,4 @@ +*.res.js +lib +apiDocs/**/* +!.gitkeep \ No newline at end of file diff --git a/scripts/res/GenApiDocs.res b/scripts/res/GenApiDocs.res new file mode 100644 index 0000000000..99e7184d47 --- /dev/null +++ b/scripts/res/GenApiDocs.res @@ -0,0 +1,264 @@ +/*** +Generate API docs from ReScript Compiler + +## Run + +```bash +node scripts/res/GenApiDocs.res.js +``` +*/ +open Node +module Docgen = RescriptTools.Docgen + +let packagePath = Path.join([Node.dirname, "..", "..", "package.json"]) +let version = switch Fs.readFileSync(packagePath, ~encoding="utf8")->JSON.parseOrThrow { + | Object(dict{"version": JSON.String(version)}) => version + | _ => JsError.panic("Invalid package.json format") +} +let version = Semver.parse(version)->Option.getExn +let version = Semver.toString({...version, preRelease: None}) // Remove pre-release identifiers for API docs +let dirVersion = Path.join([Node.dirname, "apiDocs", version]) +if !Fs.existsSync(dirVersion) { + Fs.mkdirSync(dirVersion) +} + + +let entryPointFiles = ["Belt.res", "Dom.res", "Js.res", "Stdlib.res"] + +let hiddenModules = ["Js.Internal", "Js.MapperRt"] + +type module_ = { + id: string, + docstrings: array, + name: string, + items: array, +} + +type section = { + name: string, + docstrings: array, + deprecated: option, + topLevelItems: array, + submodules: array, +} + +let env = Process.env + +let docsDecoded = entryPointFiles->Array.map(libFile => + try { + let entryPointFile = Path.join([Node.dirname, "..", "..", "runtime", libFile]) + + let rescriptToolsPath = Path.join([Node.dirname, "..", "..", "cli", "rescript-tools.js"]) + let output = ChildProcess.execSync( + `${rescriptToolsPath} doc ${entryPointFile}`, + ~options={ + maxBuffer: 30_000_000., + }, + )->Buffer.toString + + let docs = output + ->JSON.parseOrThrow + ->Docgen.decodeFromJson + Console.log(`Generated docs from ${libFile}`) + docs + } catch { + | JsExn(exn) => + Console.error( + `Error while generating docs from ${libFile}: ${exn + ->JsExn.message + ->Option.getOr("[no message]")}`, + ) + JsExn.throw(exn) + } +) + +let removeStdlibOrPrimitive = s => s->String.replaceAllRegExp(/Stdlib_|Primitive_js_extern\./g, "") + +let docs = docsDecoded->Array.map(doc => { + let topLevelItems = doc.items->Array.filterMap(item => + switch item { + | Value(_) as item | Type(_) as item => item->Some + | _ => None + } + ) + + let rec getModules = (lst: list, moduleNames: list) => + switch lst { + | list{ + Module({id, items, name, docstrings}) + | ModuleAlias({id, items, name, docstrings}) + | ModuleType({id, items, name, docstrings}), + ...rest, + } => + if Array.includes(hiddenModules, id) { + getModules(rest, moduleNames) + } else { + getModules( + list{...rest, ...List.fromArray(items)}, + list{{id, items, name, docstrings}, ...moduleNames}, + ) + } + | list{Type(_) | Value(_), ...rest} => getModules(rest, moduleNames) + | list{} => moduleNames + } + + let id = doc.name + + let top = {id, name: id, docstrings: doc.docstrings, items: topLevelItems} + let submodules = getModules(doc.items->List.fromArray, list{})->List.toArray + let result = [top]->Array.concat(submodules) + + (id, result) +}) + +let allModules = { + open JSON + let encodeItem = (docItem: Docgen.item) => { + switch docItem { + | Value({id, name, docstrings, signature, ?deprecated}) => { + let dict = Dict.fromArray( + [ + ("id", id->String), + ("kind", "value"->String), + ("name", name->String), + ( + "docstrings", + docstrings + ->Array.map(s => s->removeStdlibOrPrimitive->String) + ->Array, + ), + ( + "signature", + signature + ->removeStdlibOrPrimitive + ->String, + ), + ]->Array.concat( + switch deprecated { + | Some(v) => [("deprecated", v->String)] + | None => [] + }, + ), + ) + dict->Object->Some + } + + | Type({id, name, docstrings, signature, ?deprecated}) => + let dict = Dict.fromArray( + [ + ("id", id->String), + ("kind", "type"->String), + ("name", name->String), + ("docstrings", docstrings->Array.map(s => s->removeStdlibOrPrimitive->String)->Array), + ("signature", signature->removeStdlibOrPrimitive->String), + ]->Array.concat( + switch deprecated { + | Some(v) => [("deprecated", v->String)] + | None => [] + }, + ), + ) + Object(dict)->Some + + | _ => None + } + } + + docs->Array.map(((topLevelName, modules)) => { + let submodules = + modules + ->Array.map(mod => { + let items = + mod.items + ->Array.filterMap(item => encodeItem(item)) + ->Array + + let rest = Dict.fromArray([ + ("id", mod.id->String), + ("name", mod.name->String), + ("docstrings", mod.docstrings->Array.map(s => s->String)->Array), + ("items", items), + ]) + ( + mod.id + ->String.split(".") + ->Array.join("/") + ->String.toLowerCase, + rest->Object, + ) + }) + ->Dict.fromArray + + (topLevelName, submodules) + }) +} + +let () = { + allModules->Array.forEach(((topLevelName, mod)) => { + let json = JSON.Object(mod) + + Fs.writeFileSync( + Path.join([dirVersion, `${topLevelName->String.toLowerCase}.json`]), + json->JSON.stringify(~space=2), + ) + }) +} + +type rec node = { + name: string, + path: array, + children: array, +} + +// Generate TOC modules +let () = { + let joinPath = (~path: array, ~name: string) => { + Array.concat(path, [name])->Array.map(path => path->String.toLowerCase) + } + let rec getModules = (lst: list, moduleNames, path) => { + switch lst { + | list{ + Module({id, items, name}) | ModuleAlias({id, items, name}) | ModuleType({id, items, name}), + ...rest, + } => + if Array.includes(hiddenModules, id) { + getModules(rest, moduleNames, path) + } else { + let itemsList = items->List.fromArray + let children = getModules(itemsList, [], joinPath(~path, ~name)) + + getModules( + rest, + Array.concat([{name, path: joinPath(~path, ~name), children}], moduleNames), + path, + ) + } + | list{Type(_) | Value(_), ...rest} => getModules(rest, moduleNames, path) + | list{} => moduleNames + } + } + + let tocTree = docsDecoded->Array.map(({name, items}) => { + let path = name->String.toLowerCase + ( + path, + { + name, + path: [path], + children: items + ->List.fromArray + ->getModules([], [path]), + }, + ) + }) + + Fs.writeFileSync( + Path.join([dirVersion, "toc_tree.json"]), + tocTree + ->Dict.fromArray + ->JSON.stringifyAny + ->Option.getExn, + ) + Console.log("Generated toc_tree.json") + Console.log(`API docs generated successfully in ${dirVersion}`) +} diff --git a/scripts/res/Semver.res b/scripts/res/Semver.res new file mode 100644 index 0000000000..c0a6420f59 --- /dev/null +++ b/scripts/res/Semver.res @@ -0,0 +1,75 @@ +type preRelease = Alpha(int) | Beta(int) | Dev(int) | Rc(int) + +type t = {major: int, minor: int, patch: int, preRelease: option} + +/** + Takes a `version` string starting with a "v" and ending in major.minor.patch or + major.minor.patch-prerelease.identifier (e.g. "v10.1.0" or "v10.1.0-alpha.2") + */ +let parse = (versionStr: string) => { + let parsePreRelease = str => { + switch str->String.split("-") { + | [_, identifier] => + switch identifier->String.split(".") { + | [name, number] => + switch Int.fromString(number) { + | None => None + | Some(buildIdentifier) => + switch name { + | "dev" => buildIdentifier->Dev->Some + | "beta" => buildIdentifier->Beta->Some + | "alpha" => buildIdentifier->Alpha->Some + | "rc" => buildIdentifier->Rc->Some + | _ => None + } + } + | _ => None + } + | _ => None + } + } + + // Some version contain a suffix. Example: v11.0.0-alpha.5, v11.0.0-beta.1 + let isPrerelease = versionStr->String.search(/-/) != -1 + + // Get the first part i.e vX.Y.Z + let versionNumber = versionStr->String.split("-")->Array.get(0)->Option.getOr(versionStr) + + switch versionNumber->String.replace("v", "")->String.split(".") { + | [major, minor, patch] => + switch (major->Int.fromString, minor->Int.fromString, patch->Int.fromString) { + | (Some(major), Some(minor), Some(patch)) => + let preReleaseIdentifier = if isPrerelease { + parsePreRelease(versionStr) + } else { + None + } + Some({major, minor, patch, preRelease: preReleaseIdentifier}) + | _ => None + } + | _ => None + } +} + +let toString = ({major, minor, patch, preRelease}) => { + let mainVersion = `v${major->Int.toString}.${minor->Int.toString}.${patch->Int.toString}` + + switch preRelease { + | None => mainVersion + | Some(identifier) => + let identifier = switch identifier { + | Dev(number) => `dev.${number->Int.toString}` + | Alpha(number) => `alpha.${number->Int.toString}` + | Beta(number) => `beta.${number->Int.toString}` + | Rc(number) => `rc.${number->Int.toString}` + } + + `${mainVersion}-${identifier}` + } +} + +let tryGetMajorString = (versionStr: string) => + switch versionStr->parse { + | None => versionStr // fallback to given version if it cannot be parsed + | Some({major}) => "v" ++ major->Int.toString + } diff --git a/scripts/res/apiDocs/.gitkeep b/scripts/res/apiDocs/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/res/package.json b/scripts/res/package.json new file mode 100644 index 0000000000..92da8de2bc --- /dev/null +++ b/scripts/res/package.json @@ -0,0 +1,15 @@ +{ + "name": "@utils/scripts", + "type": "module", + "private": true, + "scripts": { + "build": "rescript", + "clean": "rescript clean -with-deps", + "dev": "rescript -w", + "apidocs:generate": "yarn build && node GenApiDocs.res.js" + }, + "dependencies": { + "@tests/docstring-tests": "workspace:^", + "rescript": "workspace:^" + } +} diff --git a/scripts/res/rescript.json b/scripts/res/rescript.json new file mode 100644 index 0000000000..895807e9d1 --- /dev/null +++ b/scripts/res/rescript.json @@ -0,0 +1,15 @@ +{ + "name": "@utils/scripts", + "namespace": true, + "sources": [ + { + "dir": "./" + } + ], + "bs-dependencies": ["@tests/docstring-tests"], + "package-specs": { + "module": "esmodule", + "in-source": true + }, + "suffix": ".res.js" +} diff --git a/tests/docstring_tests/Node.res b/tests/docstring_tests/Node.res index 95eb4241bc..276b4ed88b 100644 --- a/tests/docstring_tests/Node.res +++ b/tests/docstring_tests/Node.res @@ -1,4 +1,5 @@ module Path = { + @module("node:path") external join2: (string, string) => string = "join" @module("node:path") @variadic external join: array => string = "join" @module("node:path") external dirname: string => string = "dirname" } @@ -6,11 +7,18 @@ module Path = { module Process = { @scope("process") external cwd: unit => string = "cwd" @scope("process") @val external version: string = "version" + @scope("process") @val external argv: array = "argv" + @scope("process") external exit: int => unit = "exit" + @scope("process") external env: Dict.t = "env" } module Fs = { @module("node:fs") external readdirSync: string => array = "readdirSync" @module("node:fs/promises") external writeFile: (string, string) => promise = "writeFile" + @module("node:fs") external existsSync: string => bool = "existsSync" + @module("node:fs") external mkdirSync: string => unit = "mkdirSync" + @module("node:fs") external writeFileSync: (string, string) => unit = "writeFileSync" + @module("node:fs") external readFileSync: (string, ~encoding: string) => string = "readFileSync" } module Buffer = { @@ -21,14 +29,17 @@ module Buffer = { module ChildProcess = { type readable type spawnReturns = {stderr: readable, stdout: readable} - type options = {cwd?: string, env?: Dict.t, timeout?: int} + type spawnOptions = {cwd?: string, env?: Dict.t, timeout?: int} @module("node:child_process") - external spawn: (string, array, ~options: options=?) => spawnReturns = "spawn" + external spawn: (string, array, ~options: spawnOptions=?) => spawnReturns = "spawn" @send external on: (readable, string, Buffer.t => unit) => unit = "on" @send external once: (spawnReturns, string, (Js.Null.t, Js.Null.t) => unit) => unit = "once" + type execSyncOptions = {maxBuffer?: float} + @module("child_process") + external execSync: (string, ~options: execSyncOptions=?) => Buffer.t = "execSync" } module OS = { @@ -41,3 +52,4 @@ module URL = { } @val @scope(("import", "meta")) external url: string = "url" +@val @scope(("import", "meta")) external dirname: string = "dirname" diff --git a/tests/docstring_tests/package.json b/tests/docstring_tests/package.json new file mode 100644 index 0000000000..d898f6b96e --- /dev/null +++ b/tests/docstring_tests/package.json @@ -0,0 +1,13 @@ +{ + "name": "@tests/docstring-tests", + "private": true, + "scripts": { + "build": "rescript", + "clean": "rescript clean -with-deps", + "dev": "rescript -w" + }, + "dependencies": { + "@rescript/react": "link:../dependencies/rescript-react", + "rescript": "workspace:^" + } +} diff --git a/tests/docstring_tests/rescript.json b/tests/docstring_tests/rescript.json index d8b223e112..19bad773ee 100644 --- a/tests/docstring_tests/rescript.json +++ b/tests/docstring_tests/rescript.json @@ -1,5 +1,5 @@ { - "name": "doc-test-examples", + "name": "@tests/docstring-tests", "sources": { "dir": "." }, diff --git a/yarn.lock b/yarn.lock index a0ea540067..13a6d70b5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -424,6 +424,12 @@ __metadata: languageName: node linkType: soft +"@rescript/react@link:../dependencies/rescript-react::locator=%40tests%2Fdocstring-tests%40workspace%3Atests%2Fdocstring_tests": + version: 0.0.0-use.local + resolution: "@rescript/react@link:../dependencies/rescript-react::locator=%40tests%2Fdocstring-tests%40workspace%3Atests%2Fdocstring_tests" + languageName: node + linkType: soft + "@rescript/react@link:../dependencies/rescript-react::locator=%40tests%2Ftools%40workspace%3Atests%2Ftools_tests": version: 0.0.0-use.local resolution: "@rescript/react@link:../dependencies/rescript-react::locator=%40tests%2Ftools%40workspace%3Atests%2Ftools_tests" @@ -635,6 +641,15 @@ __metadata: languageName: unknown linkType: soft +"@tests/docstring-tests@workspace:^, @tests/docstring-tests@workspace:tests/docstring_tests": + version: 0.0.0-use.local + resolution: "@tests/docstring-tests@workspace:tests/docstring_tests" + dependencies: + "@rescript/react": "link:../dependencies/rescript-react" + rescript: "workspace:^" + languageName: unknown + linkType: soft + "@tests/generic-jsx-transform@workspace:tests/analysis_tests/tests-generic-jsx-transform": version: 0.0.0-use.local resolution: "@tests/generic-jsx-transform@workspace:tests/analysis_tests/tests-generic-jsx-transform" @@ -754,6 +769,15 @@ __metadata: languageName: node linkType: hard +"@utils/scripts@workspace:scripts/res": + version: 0.0.0-use.local + resolution: "@utils/scripts@workspace:scripts/res" + dependencies: + "@tests/docstring-tests": "workspace:^" + rescript: "workspace:^" + languageName: unknown + linkType: soft + "@yarnpkg/types@npm:^4.0.1": version: 4.0.1 resolution: "@yarnpkg/types@npm:4.0.1"