diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e12276..8137445d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Build documentation run: npm run build:docs + - name: Check documentation links + run: npm run check:docs-links + - name: Upload documentation uses: actions/upload-pages-artifact@v5 if: github.ref == 'refs/heads/main' && github.event_name == 'push' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf62a5d8..9b4b09c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,6 +60,9 @@ jobs: - name: Build documentation run: npm run build:docs + - name: Check documentation links + run: npm run check:docs-links + - name: Validate explicit alpha version and unpublished state run: npm run check:release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2be3ba0f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +This file records user-visible changes. Alpha releases may contain breaking changes, and +changes may occur between alpha versions. Version numbers and publication-channel instructions +are maintained by the release workflow; see the [alpha release readiness tracker](https://github.com/rescript-lang/experimental-rescript-webapi/issues/382). + +## 1.0.0-alpha.1 (candidate) + +Planned first release in the `1.0.0-alpha.N` series. ReScript `13.0.0-alpha.5` is the tested +compiler baseline. This alpha is not a stable API; review the migration notes below before +upgrading from earlier prereleases. The release workflow is the source of truth for actual +versioning and publication. + +### Migration notes + +- WebSocket sends and worker `Request` fetches now preserve their native receiver (`this`) rather + than calling receiver-bound methods unbound. +- `WebAPI.Request.bytes`, `WebAPI.Response.bytes`, `WebAPI.Blob.bytes`, + `WebAPI.Blob.stream`, and `WebAPI.SafeFetch.bytes` expose + native `Uint8Array.t` values or chunks instead of `array`. Use the typed-array API + directly; the bytes are not copied into a ReScript integer array. +- `ResizeObserver` callbacks now receive `(entries, observer)` as two arguments rather than a + curried callback. +- Web Audio analyser float buffers, `AudioBuffer` channel and copy APIs, and Biquad/IIR filter + frequency-response buffers use `Float32Array.t`; analyser byte buffers use `Uint8Array.t`. + `DOMMatrix` and `DOMMatrixReadOnly` Float32 conversions also use `Float32Array.t`. Use native + typed-array values rather than ordinary ReScript arrays for these calls. +- `Element.closest` returns `Null.t` and is no longer polymorphic. Narrow the + result with the relevant public classifier when a more specific HTML interface is required. +- WebCrypto signatures now reflect native results: `sign` and `digest` return `ArrayBuffer.t`, + `verify` returns `bool`, and `deriveKey` returns `WebAPI.SubtleCrypto.cryptoKey`. Key and + algorithm support types are available through `WebAPI.SubtleCrypto`. +- Generic `SubtleCrypto.generateKey` and `generateKey2` now return `unknown` because the result + depends on the algorithm. Use `generateKeySymmetric` for HMAC keys + (`SubtleCrypto.cryptoKey`) or `generateEcdsaKeyPair` for ECDSA pairs + (`SubtleCrypto.cryptoKeyPair`). Generic `exportKey` also returns `unknown`; use + `exportKeyBinary` for `Raw`, `Pkcs8`, or `Spki` formats (`ArrayBuffer.t`), and + `exportKeyJwk` for `Jwk` format (`JSON.t`). +- `DOMTree.element` no longer exposes a generic `className`. Specific HTML interfaces retain a + string `className`; `SVGElement.t.className` is `SVGElement.svgAnimatedString` with + `baseVal` and `animVal`. +- `CustomElementRegistry.define` takes a `DOM.customElementConstructor`; it does not take an + element instance. +- Request destination, referrer-policy, and media `canPlayType` result types now include `Empty` + for native empty-string values. `HTMLCanvasElement.toBlob` callbacks receive a + `Null.t` because the browser can return null (including for a zero-sized + canvas). An `ifAvailable` Web Locks callback receives a nullable lock when the lock is + unavailable. `IDBFactory.deleteDatabase` has an operation-specific `unit` result because the + browser's successful result is `undefined`, not a database. +- `WebAPI.Notification.t` no longer contains a static permission field. Read that value through + `WebAPI.Notification.permission`. + +For the intended installation, compiler range, runtime/browser checks, and known limitations, +see the [project status](https://rescript-lang.github.io/experimental-rescript-webapi/project-status/) +and [API surface guide](https://rescript-lang.github.io/experimental-rescript-webapi/api-surface/). diff --git a/README.md b/README.md index d19fa525..cac5bd73 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,32 @@ Experimental successor to [rescript-webapi](https://github.com/TheSpyder/rescrip ## Getting started -Install the package using your favorite package manager: +ReScript `13.0.0-alpha.5` is the tested compiler baseline. Library alpha releases may contain +breaking changes. See the [project status](https://rescript-lang.github.io/experimental-rescript-webapi/project-status/) +for the accepted peer range and its testing limits. + +Install the library and compiler in your application: ```shell -npm i @rescript/webapi@alpha +npm install @rescript/webapi@alpha +npm install --save-dev rescript@13.0.0-alpha.5 ``` -and add `@rescript/webapi` to your `rescript.json`: +The `@alpha` command targets the published alpha dist-tag. The planned `1.0.0-alpha.1` candidate +is not published yet; see [project status](https://rescript-lang.github.io/experimental-rescript-webapi/project-status/) +for the current tag and note that candidate installation is verified from its packed tarball. + +Add a source directory and the package dependency to `rescript.json`: ```json { - "dependencies": [ - "@rescript/webapi" - ] + "name": "webapi-consumer", + "sources": [{ "dir": "src" }], + "dependencies": ["@rescript/webapi"] } ``` -## Usage +In `src/Main.res`: ```rescript let location = WebAPI.DOM.window->WebAPI.Window.location @@ -30,6 +39,16 @@ let href = location.href location->WebAPI.Location.reload ``` +Compile the consumer with: + +```shell +npx rescript build +``` + +The namespace is flat under `WebAPI`; internal implementation modules are not public API. + ## Documentation -More information can be found on https://rescript-lang.github.io/experimental-rescript-webapi/ +More information can be found on https://rescript-lang.github.io/experimental-rescript-webapi/. +See the [alpha release checklist](https://github.com/rescript-lang/experimental-rescript-webapi/issues/382) +and [migration notes](CHANGELOG.md) for known limitations and breaking changes. diff --git a/astro.config.mjs b/astro.config.mjs index 55d8d85b..e1cab123 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,10 +2,9 @@ import { readFileSync } from "node:fs"; import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; import { apiModules } from "./docs/utils"; +import { docsBase, docsSite } from "./docs/siteConfig.js"; -const rescriptTM = JSON.parse( - readFileSync("./docs/assets/rescript.tmLanguage.json", "utf-8"), -); +const rescriptTM = JSON.parse(readFileSync("./docs/assets/rescript.tmLanguage.json", "utf-8")); const apiSidebarItems = apiModules.map(({ moduleName, link }) => ({ label: moduleName, @@ -15,8 +14,8 @@ const apiSidebarItems = apiModules.map(({ moduleName, link }) => ({ export default defineConfig({ srcDir: "docs", publicDir: "docs/public", - site: "https://rescript-lang.github.io", - base: "experimental-rescript-webapi", + site: docsSite, + base: docsBase, integrations: [ starlight({ title: "Experimental WebAPI", @@ -31,8 +30,7 @@ export default defineConfig({ }, ], editLink: { - baseUrl: - "https://github.com/rescript-lang/experimental-rescript-webapi/edit/main/", + baseUrl: "https://github.com/rescript-lang/experimental-rescript-webapi/edit/main/", }, sidebar: [ { @@ -44,6 +42,9 @@ export default defineConfig({ { slug: "api-surface", }, + { + slug: "font-attribution", + }, { label: "Examples", link: "examples", @@ -75,7 +76,7 @@ export default defineConfig({ }, }, ], - customCss: ["./docs/styles/fonts.css", "./docs/styles/theme.css"], + customCss: ["./docs/styles/theme.css"], expressiveCode: { shiki: { langs: [rescriptTM], diff --git a/docs/components/signatureItem.astro b/docs/components/signatureItem.astro index 1dc31574..9c020baa 100644 --- a/docs/components/signatureItem.astro +++ b/docs/components/signatureItem.astro @@ -1,5 +1,5 @@ --- -import { createAPIModuleLink } from "../utils.js"; +import { apiModules, createAPIModuleLink, publicTypeTargets } from "../utils.js"; const { item, typesInOwnModule } = Astro.props; let link; @@ -9,8 +9,18 @@ if (typesInOwnModule && typesInOwnModule.has(item.path)) { if (item.path.startsWith("WebAPI.")) { const paths = item.path.split("."); - if (paths.length === 3) { - link = `${import.meta.env.BASE_URL}/${createAPIModuleLink(paths[1])}#${paths[2]}`; + const moduleName = paths[1]; + if ( + paths.length === 3 && + moduleName && + apiModules.some((apiModule) => apiModule.moduleName === moduleName) + ) { + link = `${import.meta.env.BASE_URL}/${createAPIModuleLink(moduleName)}#${paths[2]}`; + } else if (paths.length === 3 && moduleName && paths[2]) { + const publicType = publicTypeTargets.get(`${moduleName}.${paths[2]}`); + if (publicType) { + link = `${import.meta.env.BASE_URL}/${createAPIModuleLink(publicType.moduleName)}#${publicType.typeName}`; + } } } diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index 7297f6aa..a43cccb1 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -4,11 +4,19 @@ description: A practical summary of how to use the public @rescript/webapi modul slug: "api-surface" --- -The package exposes browser APIs under the `WebAPI` namespace. In normal application code, -add the package to `rescript.json`: +The package exposes browser APIs under the `WebAPI` namespace. ReScript `13.0.0-alpha.5` is +the tested compiler baseline, and alpha releases may contain breaking changes. See +[project status](../project-status/) for the accepted peer range and its testing limits, and +the [migration notes](https://github.com/rescript-lang/experimental-rescript-webapi/blob/main/CHANGELOG.md) +for API changes. + +Install `@rescript/webapi@alpha` and `rescript@13.0.0-alpha.5`, then add the package dependency. +Feature bundles are optional when consumers need to restrict compiled modules: ```json { + "name": "webapi-consumer", + "sources": [{ "dir": "src" }], "dependencies": ["@rescript/webapi"] } ``` @@ -24,6 +32,9 @@ let href = location.href location->WebAPI.Location.reload ``` +For the compiler range, alpha compatibility contract, and breaking changes to byte, observer, +DOM, and crypto types, read the [alpha migration notes](https://github.com/rescript-lang/experimental-rescript-webapi/blob/main/CHANGELOG.md). + ## Public module shape The public API is flat under `WebAPI`. Use owner modules directly rather than generated diff --git a/docs/content/docs/contributing/api-modelling.mdx b/docs/content/docs/contributing/api-modelling.mdx index 430cb2b0..f5119614 100644 --- a/docs/content/docs/contributing/api-modelling.mdx +++ b/docs/content/docs/contributing/api-modelling.mdx @@ -1,175 +1,65 @@ --- title: API Modelling -description: Learn more about the API modelling process of @rescript/webapi. +description: How to model Web API operations in the flat public ReScript API. slug: "05-api-modelling" --- -import { Aside, Code, Icon } from "@astrojs/starlight/components"; +Bindings follow the browser API where that gives consumers a useful, type-safe ReScript +interface. Source files are grouped by API family, but applications use flat public owner +modules such as `WebAPI.Fetch`, `WebAPI.Request`, and `WebAPI.Element`. Internal `*Types` +modules are not application-facing namespaces. -One of this projects goals is to provide a consistent and idiomatic API for the Web APIs. -The interopt story of ReScript is quite good, but it it has limitations. -JavaScript is a dynamic language and has a lot of flexibility. -ReScript is a statically typed language and has to model the dynamic parts of JavaScript in a static way. +## Method overloads -## Dynamic parameters and properties - -Some Web APIs have a parameter or property that can be multiple things. -In ReScript, you would model this as a variant type. This is an example wrapper around values with a key property to discriminate them. - -In JavaScript, this strictness is not enforced and you can pass a string where a number is expected. -There are multiple strategies to model this in ReScript and it depends on the specific API which one is the best. - -### Overloads - -One example is [addEventListener](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener). -This can access either a boolean or an object as the third parameter. - -```js -addEventListener(type, listener); -addEventListener(type, listener, options); -addEventListener(type, listener, useCapture); -``` - -Because, this is a method, we can model this as an overloaded function in ReScript. -The first two overloads are the same, so we can merge them into one with an optional options parameter. +JavaScript can expose several signatures for one operation. ReScript cannot overload one value +name, so give variants distinct names that describe their input rather than appending numbers. +For example, `fetch` takes a URL and `fetchWithRequest` takes a `Request`: ```ReScript -@send -external addEventListener: ( - htmlButtonElement, - eventType, - eventListener<'event>, - ~options: addEventListenerOptions=?, -) => unit = "addEventListener" -``` +let response = await WebAPI.Fetch.fetch("https://example.com") -The third overload takes a boolean and is worth using when you want to change the default of the `useCapture` boolean parameter. -We can use [a fixed argument](https://rescript-lang.org/docs/manual/latest/bind-to-js-function#fixed-arguments) to model this. - -```ReScript -@send -external addEventListenerWithCapture: ( - htmlButtonElement, - ~type_: eventType, - ~callback: eventListener<'event>, - @as(json`true`) _, -) => unit = "addEventListener" +let request = WebAPI.Request.fromURL("https://example.com") +let responseFromRequest = await WebAPI.Fetch.fetchWithRequest(request) ``` -When naming an overloaded function, we can use the `With` suffix to indicate that it is an overloaded function. +When several signatures differ only by optional arguments, keep a single binding with an +optional labeled argument when the browser API treats it as one operation. -### Constructor overloads +## Constructor overloads -Constructors follow a different naming rule than methods. +Constructors use a distinct naming convention: -- Keep singleton constructors named `make`. -- Keep true no-argument default constructors named `make`, even when the constructor family also has typed overloads. -- When a constructor family does not have a default constructor, use `from*` names for every overload, including the first one. -- Base `from*` names on the source input type: `fromString`, `fromArrayBuffer`, `fromMediaStream`, `fromURLWithProtocols`, and so on. -- If an optional labeled argument hides a real default constructor, split that binding into `make()` plus typed `from*` overloads instead of keeping the optional argument on `make`. +- Keep singleton constructors and true default constructors named `make`. +- Name typed constructors `from*` after the source type, such as `fromString`, + `fromArrayBuffer`, or `fromURLWithProtocols`. +- Split a hidden default constructor into `make()` plus typed `from*` constructors rather than + leaving optional input on `make`. -For example, these constructor families are easier to understand than numbered overloads: +For example, a `DOMMatrix` has a true default constructor alongside string and array forms: ```ReScript -@new -external fromString: (~family: string, ~source: string) => fontFace = "FontFace" - -@new -external fromDataView: (~family: string, ~source: DataView.t) => fontFace = "FontFace" - -@new -external fromArrayBuffer: (~family: string, ~source: ArrayBuffer.t) => fontFace = "FontFace" +let identity = WebAPI.DOMMatrix.make() +let fromTransform = WebAPI.DOMMatrix.fromString("matrix(1, 0, 0, 1, 0, 0)") +let fromComponents = WebAPI.DOMMatrix.fromArray([1., 0., 0., 1., 0., 0.]) ``` -And if a constructor really does have a default form, split it instead of hiding it: - -```ReScript -@new -external make: unit => domMatrix = "DOMMatrix" - -@new -external fromString: string => domMatrix = "DOMMatrix" - -@new -external fromArray: array => domMatrix = "DOMMatrix" -``` - -Single-source constructor variants should take the source value directly without a label. - -Keep `makeWith*` names for non-default convenience constructors that are not part of a source-type overload family. +Keep comments on each binding specific to the input shape and include its source type, a +consumer example, and links to the relevant MDN or ReScript type documentation. See the +[documentation guide](../07-documentation/) for the comment format. -Constructor naming is currently verified with compile-coverage tests. Runtime verification for these constructor families will be added later when the Vitest and happy-dom harness lands. +## Dynamic browser values -### Decoded variants +Some browser properties are untagged unions in JavaScript. Model these as a small public +conversion/decoding API when consumers need to distinguish their variants; do not expose the +underlying implementation type just to make it nameable. For example, the public +`WebAPI.FillStyle` module provides conversion helpers and a decoder for Canvas fill styles. -We can be pragmatic with overloaded functions and use model them in various creative ways. -For properties, we **cannot do this unfortunately**. A propery can only be defined once and have a single type. +Keep null and empty states explicit when they are part of the browser contract. Use `Null.t` +for nullable results and variants for meaningful empty values; do not replace native results +with invented defaults or unchecked casts. -The strategy here is to use a decoded variant. - -Example for the [fillStyle](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillStyle) property of the `CanvasRenderingContext2D` interface can be either a: - -- `string` -- `CanvasGradient` -- `CanvasPattern` - -These types are not all primitives and thus we cannot define it as [untagged variants](https://rescript-lang.org/docs/manual/latest/variant#untagged-variants). -What we can do instead is represent the type as an empty type and use a helper module to interact with this. - -export const fillStyleDef = ` -type fillStyle - -type canvasRenderingContext2D = { -// ... other propeties -mutable fillStyle: fillStyle -} -`; - - - -When we wish to read and write the `fillStyle` property, we can use a helper module to lift the type to an actual ReScript variant: - -export const fillStyleModule = ` -open WebAPI - -external fromString: string => fillStyle = "%identity" -external fromCanvasGradient: canvasGradient => fillStyle = "%identity" -external fromCanvasPattern: canvasGradient => fillStyle = "%identity" - -type decoded = -| String(string) -| CanvasGradient(canvasGradient) -| CanvasPattern(canvasPattern) - -let decode = (t: fillStyle): decoded => { -if CanvasGradient.isInstanceOf(t) { -CanvasGradient(unsafeConversation(t)) -} else if CanvasPattern.isInstanceOf(t) { -CanvasPattern(unsafeConversation(t)) -} else { -String(unsafeConversation(t)) -} -} -` - - - -We can now use `FillStyle.decode` to get the actual value of the `fillStyle` property. -And use `FillStyle.fromString`, `FillStyle.fromCanvasGradient`, and `FillStyle.fromCanvasPattern` to set the value. - -```ReScript -let ctx = myCanvas->HTMLCanvasElement.getContext_2D - -// Write -ctx.fillStyle = FillStyle.fromString("red") - -// Read -switch ctx.fillStyle->FillStyle.decode { -| FillStyle.String(color) => Console.log(`Color: ${color}`) -| FillStyle.CanvasGradient(_) => Console.log("CanvasGradient") -| FillStyle.CanvasPattern(_) => Console.log("CanvasPattern") -} -``` +## Verification - -Try and use `decoded` and `decode` as conventions for the type and function names. +Add compile-only, Node runtime, or browser runtime fixtures according to the API being modeled. +The [testing guide](../06-testing/) explains `tests/fixtures.json`, `npm test`, +`npm run test:browser`, and packed consumer checks. diff --git a/docs/content/docs/contributing/api-module-structure.mdx b/docs/content/docs/contributing/api-module-structure.mdx index 45781e8c..92a669f5 100644 --- a/docs/content/docs/contributing/api-module-structure.mdx +++ b/docs/content/docs/contributing/api-module-structure.mdx @@ -1,87 +1,48 @@ --- title: API Module Structure -description: Learn more about the API module structure of @rescript/webapi. +description: How the public API is organized and how source files map to it. slug: "02-api-module-structure" --- -import { Aside } from "@astrojs/starlight/components"; import { FileTree } from "@astrojs/starlight/components"; -The bindings are organized by the Web API they represent. Each API has its interfaces and auxiliary types in a module named after the API, suffixed with `API` to prevent collisions with the type module. +The package exposes a flat `WebAPI` namespace. Source files are grouped by API family in +lowercase directories, while ReScript module names are derived from their filenames. -- package.json - src - - DOMAPI.res - - DOMAPI - - HTMLElement.res + - fetch + - Request.res + - Response.res + - FetchTypes.res -Within the API module, the structure is roughly as follows: +Application code uses public modules such as `WebAPI.Request` and `WebAPI.Response`; it does +not use the internal `FetchTypes` module. The supported public module list and feature bundles +are described in the [API surface guide](../api-surface/). -- Enum Types -- Interfaces -- Auxiliary Types +## Interface values and operations -## Enum types - -Enum types are used to represent constants in the Web API. They are typically used as arguments to methods or properties. -In ReScript terms these are variants: - -```ReScript -type scrollBehavior = - | @as("auto") Auto - | @as("instant") Instant - | @as("smooth") Smooth -``` - -Enums come first as they are always self-contained and do not depend on other types. - -## Interfaces - -[Interfaces](https://developer.mozilla.org/en-US/docs/Web/API#interfaces) are modeled as record types and are the core of the bindings. -They represent the properties and methods of the Web API. If an interface inherits from another interface, the base interface is spread into the inheriting interface. +Concrete interface modules commonly expose their value type as `t`; helper or operation-only +modules may not have a standalone value type. Operations live on their public owner module, so +an interface value can be piped into an operation: ```ReScript -type htmlSpanElement = { - ...htmlElement, -} +let request = WebAPI.Request.fromURL("https://example.com") +let copiedRequest = request->WebAPI.Request.clone ``` - +Auxiliary public shapes are exposed by their owner module when consumers need to construct or +name them. Internal `*Types` modules hold implementation details and are not stable public +targets. Prefer public aliases or inferred types; do not add internal modules to the API just to +make an internal reference visible. -```ReScript -type rec node = { - nodeName: string - // ... more properties -} +## Platform interfaces -and element = { - // duplicated property from node - nodeName: string - // ... more properties -} -``` - -## Auxiliary Types - -Auxiliary types are used to represent types that are not directly related to the Web API but are used in the bindings. -These can occur both before interfaces and after interfaces, depending on the context. - -```ReScript -type eventListenerOptions = {mutable capture?: bool} - -// Model after the documentation of -// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options -type addEventListenerOptions = { - ...eventListenerOptions, - mutable passive?: bool, - mutable once?: bool, - mutable signal?: abortSignal, -} -``` +Web APIs often inherit interface members. Bindings expose the operations needed by public +interface modules and use explicit conversion helpers where the source API can safely be viewed +as a broader interface. Runtime narrowing belongs in the destination type's checked +`classify` helper, rather than an unchecked cast in consumer code. See the [API surface guide's +DOM section](../api-surface/#classifying-dom-values) for an example. diff --git a/docs/content/docs/contributing/code-generation.mdx b/docs/content/docs/contributing/code-generation.mdx index 98d6d85a..22fad99c 100644 --- a/docs/content/docs/contributing/code-generation.mdx +++ b/docs/content/docs/contributing/code-generation.mdx @@ -1,83 +1,30 @@ --- title: Code Generation -description: Learn more about the code generation process for @rescript/webapi. +description: How generated material fits into the maintained WebAPI bindings. slug: "04-code-generation" --- -The original bindings were generated using a modified version of [TypeScript-DOM-lib-generator](https://github.com/microsoft/TypeScript-DOM-lib-generator). -These bindings were a great starting point, but they are not perfect. -It is more than likely that you will need to **tweak the generated bindings by hand** to make them more idiomatic to ReScript. +The repository contains a modified +[TypeScript-DOM-lib-generator](https://github.com/microsoft/TypeScript-DOM-lib-generator) under +`tools/TypeScript-DOM-lib-generator`. Generated output is a starting point, not the complete +source of truth: checked-in bindings are reviewed and maintained by hand. -For example the `window.fetch` function was generated as: +Source modules are grouped by API family, while consumers use the flat public `WebAPI` +namespace. For example, the public `WebAPI.Fetch` functions are maintained in +`src/fetch/Fetch.res`: ```ReScript -/** -[Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) -*/ -@send -external fetch: (window, ~input: request, ~init: requestInit=?) - => promise = "fetch" +external fetch: (string, ~init: Request.requestInit=?) => promise = "fetch" -/** -[Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) -*/ -@send -external fetch2: (window, ~input: string, ~init: requestInit=?) - => promise = "fetch" +external fetchWithRequest: (Request.t, ~init: Request.requestInit=?) => promise = + "fetch" ``` -While not that bad and usable, it can be improved: +The generator may produce numbered overloads or use generated type-holder names. Before +committing a binding, use the public owner modules, semantic overload names, accurate native +types, and ReScript-facing documentation. Do not hand-edit generated `docs/pages/apidocs` +output; the API pages are generated from the public module sources. -- Rename `fetch2` to `fetch` because it is the more common usage of the function. -- Rename `fetch` to `fetchWithRequest` for clarity that this is the "overload" with a `Request` object. -- Consider removing the named `~input` and `~init` arguments and use positional arguments instead. - Motivation: If the function does not have any parameters with the same type, it is more ergonomic to use positional arguments. - This heuristic is not set in stone and can be adjusted based on the specific function. -- The documentation can be improved. - -```ReScript -/** TODO: add better docs */ -@send -external fetch: (window, string, ~init: requestInit=?) - => promise = "fetch" - -/** TODO: add better docs */ -@send -external fetchWithRequest: (window, request, ~init: requestInit=?) - => promise = "fetch" -``` - -Once these changes are made, the bindings can be tested and then committed to the repository. -The generation does no longer happen automatically, so manual improvements will not be overwritten. - -## Sandboxed Code Generation - -Not every API was covered by the TypeScript-DOM-lib-generator. -Potentially, you want to add a new API to the bindings and star from code generation. - -In [emitter.ts](https://github.com/rescript-lang/experimental-rescript-webapi/blob/main/tools/TypeScript-DOM-lib-generator/src/build/emitter.ts), -you can override the `interfaceHierarchy` with any new interface or type you want to add. - -```typescript -interfaceHierarchy = [ - { - name: "Temp", - entries: [ - enums(["WebGLPowerPreference"]), - dictionaries(["ImageBitmapRenderingContextSettings", "WebGLContextAttributes"]), - ], - opens: [], - }, -]; -``` - -After running the generator (in `tools/TypeScript-DOM-lib-generator`): - -```shell -npm run build -``` - -All the generated files will be in the `tmp` folder. You can use this as inspiration for your own bindings. - -To figure out if you need `enums`, `dictionaries`, or `interfaces`, you can look at the [JSON dump](https://github.com/rescript-lang/experimental-rescript-webapi/blob/main/tools/TypeScript-DOM-lib-generator/dom-dump.json) file -and see how the TypeScript-DOM-lib-generator represents the shape you are after. +Not every hand-maintained change is reproducibly generated from the current tool. Treat the +checked-in source as canonical, and keep generated files and compile-coverage fixtures +consistent with the repository's documented test workflow. diff --git a/docs/content/docs/contributing/documentation.mdx b/docs/content/docs/contributing/documentation.mdx index 44333490..9b39655e 100644 --- a/docs/content/docs/contributing/documentation.mdx +++ b/docs/content/docs/contributing/documentation.mdx @@ -1,20 +1,12 @@ --- title: "Documentation" -description: Learn more about the relevance of adding documentation to @rescript/webapi. +description: Learn how to document public @rescript/webapi bindings. slug: "07-documentation" --- -After the bindings are generated, all you got was a link to the MDN documentation. -While again, not that bad, it can be improved by adding the core of the binding to the documentation. -And explain how it is supposed to be used. - -## Importance - -One could wonder how important documentation is in this case? -A link to MDN is enough, right? Well, not always. -When a method has multiple overloads, it can be hard to understand which one to use. -By adding an example usage, the user can see easily see which one to use. -It keeps the user inside the IDE and avoids context switching. +Bindings may begin as generated output, but the checked-in source and its ReScript comments are +maintained by hand. Generated API pages use those comments to explain how a public binding works +and link to the corresponding browser API. ## Structure @@ -22,10 +14,10 @@ The documentation for each binding should roughly follow this structure: - signature - short description focused on the ReScript side of the API -- example usage -- link to the MDN documentation +- example usage in the public `WebAPI` namespace +- links to MDN and any relevant source type -For example, the `window.fetch` function could be documented as: +For example, the public `WebAPI.Fetch.fetch` function can be documented in its source module: ````ReScript /* @@ -35,19 +27,16 @@ Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. ```res -window->Window.fetch("https://rescript-lang.org") +let response = await WebAPI.Fetch.fetch("https://rescript-lang.org") ``` [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ -@send -external fetch: (window, string, ~init: requestInit=?) - => promise = "fetch" +external fetch: (string, ~init: Request.requestInit=?) => promise = "fetch" ```` -Constructor overloads should use the same structure, but keep the description concise and let the signature and example show the input shape. -Use backticks for specific types and names. -If a constructor variant has a single source input, show it as a direct unlabeled argument in both the signature and example. +Keep overload examples specific to their argument shape. Constructor documentation uses +semantic names such as `make`, `fromString`, and `fromArray` rather than numbered overloads: ````ReScript /* @@ -56,13 +45,19 @@ If a constructor variant has a single source input, show it as a direct unlabele Creates a new `DOMMatrix` from an array of matrix component values. ```res -let matrix = DOMMatrix.fromArray([1., 0., 0., 1., 0., 0.]) +let matrix = WebAPI.DOMMatrix.fromArray([1., 0., 0., 1., 0., 0.]) ``` [Read more on MDN](https://developer.mozilla.org/docs/Web/API/DOMMatrix) */ @new -external fromArray: array => domMatrix = "DOMMatrix" +external fromArray: array => DOM.domMatrix = "DOMMatrix" ```` -For now, these examples stay compile-coverage oriented. Runtime verification of constructor behavior will be handled later via Vitest and happy-dom. +When an input is a public alias, link to its owner module. Do not link an internal type holder +unless it has a valid public owner target; prefer public aliases or inferred types. See the +[API module structure](../02-api-module-structure/) guide for the current module layout. + +Keep examples that are not runtime-safe as compile-only fixtures. Put browser-dependent behavior +checks in the `browserRuntime` fixture category and run them with the native Chromium suite. See +the [testing guide](../06-testing/) for fixture categories and commands. diff --git a/docs/content/docs/contributing/getting-started.mdx b/docs/content/docs/contributing/getting-started.mdx index bc7a11e6..1972f802 100644 --- a/docs/content/docs/contributing/getting-started.mdx +++ b/docs/content/docs/contributing/getting-started.mdx @@ -23,33 +23,28 @@ We recommend the following overall workflow when developing for this repository: - Always work in your fork - Always keep your fork up to date -Before updating your fork, run this command: +Add the upstream repository once, if it is not already configured: ```shell git remote add upstream https://github.com/rescript-lang/experimental-rescript-webapi.git ``` -This will make management of multiple forks and your own work easier over time. +Check `git remote -v` first to avoid adding the same remote twice. ### Updating your fork -We recommend the following commands to update your fork: +Update `main` without deleting local files or untracked work: ```shell git checkout main -git clean -xdf git fetch upstream -git rebase upstream/main +git merge --ff-only upstream/main git push ``` -Or more succinctly: - -```shell -git checkout main && git clean -xdf && git fetch upstream && git rebase upstream/main && git push -``` - -This will update your fork with the latest from `rescript-lang/experimental-rescript-webapi` on your machine and push those updates to your remote fork. +The fast-forward-only merge stops rather than rewriting history if your fork's `main` has +diverged. Keep work on feature branches; commit or stash changes before switching branches. +This updates your local `main` from upstream and pushes it to your fork's configured `origin`. ## Initial build diff --git a/docs/content/docs/contributing/module-type-structure.mdx b/docs/content/docs/contributing/module-type-structure.mdx index bb5b58fc..db920d59 100644 --- a/docs/content/docs/contributing/module-type-structure.mdx +++ b/docs/content/docs/contributing/module-type-structure.mdx @@ -1,77 +1,49 @@ --- title: Module Type Structure -description: Learn more about the module structure of @rescript/webapi. +description: How public interface modules represent values and operations. slug: "03-module-type-structure" --- -import { Aside, FileTree, Code } from "@astrojs/starlight/components"; +import { FileTree } from "@astrojs/starlight/components"; -Every interface in a Web API module can potentially contain methods. These methods are modeled in a separate module named after the interface. - -The primary reason for this separation is to handle method overloads. -As explained in the [Design Philosophy](../design-philosophy) section, ReScript does not permit records to define the same properties more than once. -Therefore, methods with overloads cannot be modeled within the same record type. - -## Bindings - -Another advantage of having a separate file is that these bindings can utilize all types defined in the API module. -Under normal circumstances, the type module only contains `@send` bindings where the type is the first parameter. +The public package is flat under `WebAPI`. Concrete interface modules commonly keep the +interface's `t` type and its operations together in one source file. For example, the +`HTMLButtonElement` binding lives in the HTML family directory: -- DOMAPI - - HTMLButtonElement.res +- src + - html + - HTMLButtonElement.res +An interface value is passed as the first argument to its operation. Consumer code uses the +public owner module: + ```ReScript -/** -Returns whether a form will validate when it is submitted, without having to submit it. -[Read more on MDN]( - https://developer.mozilla.org/docs/Web/API/HTMLButtonElement/checkValidity) -*/ -@send -external checkValidity: htmlButtonElement => bool = "checkValidity" +let button: WebAPI.HTMLButtonElement.t = getButton() +let isValid = button->WebAPI.HTMLButtonElement.checkValidity ``` -## Inheritance - -When an interface inherits from another interface, the base interface methods can be [included](https://rescript-lang.org/syntax-lookup#include) into the inheriting interface. -All methods from [HTMLElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement#instance_methods) should also be available on [HTMLButtonElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement#instance_methods). - -export const htmlElementModule = ` -open DOMAPI - -// A concrete type for \`T.t\` is passed later using the \`include\` keyword. -module Impl = (T: { type t }) => { - -/\*_ -[Read more on MDN](https://developer.mozilla.org/docs/Web/API/HTMLElement/focus) -_/ -@send -external focus: (T.t, ~options: focusOptions=?) => unit = "focus" +The corresponding binding follows the same shape: +```ReScript +type rec t = { + ...DOMTree.htmlElement, + disabled: bool, } -include Impl({ type t = htmlElement }) -`; - - - -export const buttonModule = ` -open DOMAPI - -// Include all the methods from HTMLElement -include HTMLElement.Impl({ type t = htmlButtonElement }) - -// Add additional methods specific to HTMLButtonElement: - -/\*_ -Returns whether a form will validate when it is submitted, without having to submit it. -[Read more on MDN](https://developer.mozilla.org/docs/Web/API/HTMLButtonElement/checkValidity) -_/ @send -external checkValidity: htmlButtonElement => bool = "checkValidity" -`; +external checkValidity: t => bool = "checkValidity" +``` + +Some DOM bindings reuse inherited operations through existing `Impl` helpers, but those are +implementation details, not additional consumer namespaces. Do not add `include` directives +just to expose internal modules. Prefer a concrete public owner module and selectively expose +only the aliases consumers need. - +Not every public module represents an interface value or exports `t`; some modules provide +operations or auxiliary types only. Internal `*Types` modules are implementation details. See +the [API module structure](../02-api-module-structure/) and +[public API surface](../api-surface/) for the package's current module layout. diff --git a/docs/content/docs/contributing/releasing.mdx b/docs/content/docs/contributing/releasing.mdx new file mode 100644 index 00000000..c6521539 --- /dev/null +++ b/docs/content/docs/contributing/releasing.mdx @@ -0,0 +1,79 @@ +--- +title: Alpha releases +description: Maintainer guidance for validating and releasing a numbered WebAPI alpha. +slug: "08-alpha-releases" +--- + +The release checklist for `@rescript/webapi` is tracked in +[#382](https://github.com/rescript-lang/experimental-rescript-webapi/issues/382). ReScript +`13.0.0-alpha.5` is the tested compiler baseline. A stable compiler is not a prerequisite; +library alpha releases may include breaking changes between versions. + +## Prepare a candidate + +The release version is the synchronized `version` in `package.json` and `package-lock.json`. +Use the repository's release validation before changing that version: + +```shell +npm run format:check +npm run build +npm run check:features +npm run test:negative +npm test +npm run test:browser +npm run build:docs +npm run check:docs-links +npm run check:consumer +npm run check:release +``` + +The browser suite runs Chromium only; it is not an exhaustive browser compatibility matrix. +The consumer check compiles the packed artifact in a fresh project. A successful source build +alone is not evidence that package installation works. + +The initial candidate is `1.0.0-alpha.1`, tagged `v1.0.0-alpha.1`. For each later alpha, update +`package.json` and `package-lock.json` together to the next `1.0.0-alpha.N` version, add the +user-visible changes and migration guidance to `CHANGELOG.md`, and create the matching Git tag +`v1.0.0-alpha.N` from the validated release commit. Run `npm run check:release` after the version +change; it rejects an already-published version and checks the expected alpha metadata. + +## Validate and publish + +The Release workflow's unprivileged `validate` job checks and packs the candidate once. It runs +`npm run check:consumer` and a publish dry-run against that exact `.tgz`; the PR and a manual +`workflow_dispatch` with `dry_run: true` use this validation path. The archive is not exposed for +a later local download on those paths; a real main-only publication transfers the same artifact +internally from `validate` to `publish`. If reproducing the artifact checks locally, pack once +and pass the same path to both commands: + +```shell +mkdir -p .release +npm pack --json --pack-destination .release > .release/metadata.json +artifact=".release/$(node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(".release/metadata.json", "utf8"))[0].filename)')" +npm run check:consumer -- "$artifact" +npm publish "$artifact" --access public --tag alpha --dry-run --ignore-scripts +``` + +Only an authorized maintainer should start a real publication with `workflow_dispatch` from +`main` and `dry_run: false`. The separate publish job is main-only; it verifies and publishes the +validated archive with the `alpha` dist-tag and `--provenance`, and must not advance `latest`. +After a real publish, verify the registry dist-tags: + +```shell +npm view @rescript/webapi dist-tags --json +``` + +The workflow requests npm provenance through GitHub Actions OIDC. Before the first real +publication, maintainers must configure and verify npm trusted publishing for this repository +and workflow in npm's publishing settings. The expected trusted-publisher tuple is: + +- Package: `@rescript/webapi` +- Owner: `rescript-lang` +- Repository: `experimental-rescript-webapi` +- Workflow filename: `release.yml` +- GitHub environment: none + +In npm's package settings, **Trusted publishing** should show those exact owner, repository, and +workflow filename fields, no GitHub environment, and allow direct `npm publish` actions. +Provenance should be enabled. These external npm account settings have not been verified from the +repository. The workflow cannot confirm them by itself. diff --git a/docs/content/docs/font-attribution.mdx b/docs/content/docs/font-attribution.mdx new file mode 100644 index 00000000..cc1467c8 --- /dev/null +++ b/docs/content/docs/font-attribution.mdx @@ -0,0 +1,17 @@ +--- +title: Font attribution +description: Font licensing and typography choices for the documentation site. +slug: "font-attribution" +--- + +## System fonts + +The documentation site uses system sans-serif and monospace font stacks. It does not +redistribute font binaries. The rendered typography may vary with fonts installed by the reader's +operating system. + +The previous bundled Inter and Roboto Mono WOFF2 files were removed because the repository +did not record their source releases, subset-generation process, or applicable license notices. +Font metadata in those binaries identified family and version numbers but did not establish +source provenance or licensing. Rather than infer a license from a family name or an upstream +project's current terms, the site now relies on fonts already available on the reader's system. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index d02839a2..52a3628c 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -23,24 +23,40 @@ import { Tabs, TabItem, Code } from "@astrojs/starlight/components"; ## Installation -Install the package using your favorite package manager: +ReScript `13.0.0-alpha.5` is the tested compiler baseline, and library alpha releases may +include breaking changes. See [project status](./project-status/) for the accepted peer range +and its testing limits. + +Install the package and tested compiler: - + - + -and add `@rescript/webapi` to your `rescript.json`: +The `@alpha` install command targets the published alpha dist-tag. The planned `1.0.0-alpha.1` +candidate is not published yet; see [project status](./project-status/) for the current tag and +note that candidate installation is verified from its packed tarball. + +Add the source directory and package dependency to `rescript.json`: export const rescriptJson = ` { - "dependencies": [ - "@rescript/webapi" - ] + "name": "webapi-consumer", + "sources": [{ "dir": "src" }], + "dependencies": ["@rescript/webapi"] } `; @@ -81,3 +97,5 @@ export { + +Save the ReScript sample in `src/Main.res`, then compile it with `npx rescript build`. diff --git a/docs/content/docs/philosophy.mdx b/docs/content/docs/philosophy.mdx index 32a0f623..b7ab59d8 100644 --- a/docs/content/docs/philosophy.mdx +++ b/docs/content/docs/philosophy.mdx @@ -6,9 +6,14 @@ slug: "design-philosophy" The core idea of these ReScript bindings is that each interface is modeled as a record type. Where possible, inheritance is represented using record spreading, and methods are modeled as functions in a separate module. This design allows for greater familiarity with the underlying JavaScript APIs, making it more friendly for newcomers. -## ReScript v12 - -These bindings utilize new features introduced in ReScript v12; thus, they are not compatible with older versions of ReScript. +## ReScript 13 alpha compatibility + +This project currently supports ReScript `13.0.0-alpha.5`. ReScript 13 is still an alpha, and +the library may continue to publish `1.0.0-alpha.N` releases alongside it. Library alpha releases +may include breaking API changes; a stable ReScript compiler is not required to use or release +this library's alpha versions. See the [API surface](../api-surface/) for the public module layout +and the [release readiness tracker](https://github.com/rescript-lang/experimental-rescript-webapi/issues/382) +for the current release scope. ## Web APIs diff --git a/docs/content/docs/project-status.mdx b/docs/content/docs/project-status.mdx index 577184de..b7e97396 100644 --- a/docs/content/docs/project-status.mdx +++ b/docs/content/docs/project-status.mdx @@ -4,22 +4,74 @@ description: Learn more about the current status of @rescript/webapi. slug: "project-status" --- -This project is highly experimental and subject to change. -It is a work in progress and might not cover every API. -The core idea is to prioritize ground coverage based on community needs. -We aim to focus on the most used APIs first and then expand from there. +This project is an experimental ReScript 13 alpha package, not a stable or exhaustive set of +browser bindings. It exposes a broad collection of Web API bindings, but implementation depth +varies by API and browser support is determined by the host runtime, not by whether a ReScript +module compiles. + +`@rescript/webapi` alpha versions can make breaking API changes between releases. ReScript +`13.0.0-alpha.5` is the tested compiler baseline. The package peer range accepts +`>=13.0.0-alpha.5 <13.0.0`, which allows later `13.0.0` prereleases but does not claim each one +is tested; stable ReScript is not currently included in that range or required to ship this +alpha. + +The install example uses the `alpha` dist-tag intended for `1.0.0-alpha.1`. At the time of this +candidate, npm's public `alpha` tag still resolves to `0.1.0-alpha-b1d58f9`; the candidate is +validated from its packed tarball, but has not been published. After an authorized publication, +the same `@alpha` install command will resolve to the new candidate. ## Contributions [Contributions](../01-getting-started) are welcome! Please [open an issue](https://github.com/rescript-lang/experimental-rescript-webapi/issues/new/choose) if you have a specific API you would like to see added. This really helps prioritize our work and ensures that the most important APIs are addressed first. -## Current Status +## Architecture and validation + +The source is organized into API-family directories, while consumers use the flat `WebAPI` +namespace and public modules such as `WebAPI.Request` and `WebAPI.Response`. Generated +implementation modules and internal type-holder modules are not part of that public surface. +Bindings are maintained and corrected by hand as well as generated; the generator is not a +reproducible source of truth for every checked-in binding. + +Approximately 80 bindings still carry private-record TODOs; a blanket conversion is deferred +because the current compiler reports mutation warning 111. Generator reproducibility is also +follow-up work, not an alpha release gate. + +CI uses Node 26 for ReScript compilation and Node runtime fixtures. Its Playwright suite runs +compiled Chromium regressions for the confirmed alpha contracts, including typed byte/audio +buffers, DOM constructors and narrowing, nullable Canvas/Locks results, IndexedDB deletion, +WebSocket receiver behavior, and worker Fetch. These targeted checks do not cover every binding. +There is no Firefox, WebKit, or browser-version matrix. API availability, secure-context +requirements, permissions, and user activation still depend on the browser and execution context. + +## Historical backlog + +Several long-standing requests remain useful references but are not individually alpha release +gates. [#236 constructor naming](https://github.com/rescript-lang/experimental-rescript-webapi/issues/236) +remains open, although a maintainer comment says it may be ready to close. The listed constructor +families now use semantic names in `FontFace`, `VideoFrame`, `MediaStream`, `Path2D`, `DOMMatrix`, +`DOMMatrixReadOnly`, `WebSocket`, and `OfflineAudioContext`. `ReadableStream.fromUnderlyingSource` +still combines an optional strategy rather than splitting it into a separately named variant. +Do not treat the issue's old checklist or comment as a recorded closure. + +[#27 overload improvements](https://github.com/rescript-lang/experimental-rescript-webapi/issues/27) +also remains open. Its long checklist predates the current module layout and overlaps constructor +work in #236; numbered overloads such as `Path2D.roundRect2` and `roundRect3` still exist. This +is incremental cleanup, not an alpha gate. -All bindings were generated using a modified version of [TypeScript-DOM-lib-generator](https://github.com/microsoft/TypeScript-DOM-lib-generator). -The next step is to manual tweak these files so they can enhance the developer experience. +[#237 module/type simplification](https://github.com/rescript-lang/experimental-rescript-webapi/issues/237) +is still open, but its implementation slices #240, #241, #242, and #243 are closed (as are #238 +and #239). The current source has no root `src/*API.res` type-holder files or +`Global.window`/`Global.navigator` references. The parent checklist has not been refreshed; the +refactor appears complete in code, but its tracker remains open. -## Future +[#23 API overview](https://github.com/rescript-lang/experimental-rescript-webapi/issues/23) is a +stale point-in-time MDN coverage checklist; use the current public API documentation and source +manifest instead. [#4 error modeling](https://github.com/rescript-lang/experimental-rescript-webapi/issues/4) +remains an open design discussion: comments disagree about whether DOMException should be modeled +separately from `Error`, citing different TypeScript DOM declarations. [#5 extension helpers](https://github.com/rescript-lang/experimental-rescript-webapi/issues/5) +also remains open; comments differ on whether typed helper APIs belong in owner modules or +separate extension modules. Neither has a settled project-wide design. None of these historical +issues is automatically closed or made a blocker by this status page. -Our goal is to make this a true community project. Consumers of the bindings should be able to contribute back to the project with minimal hassle. -This means that the project should be easy to understand and contribute to. Once a contribution is accepted, a new release will follow shortly. +For the current release checklist and explicitly deferred work, see [#382](https://github.com/rescript-lang/experimental-rescript-webapi/issues/382). diff --git a/docs/fonts/roboto-mono-400.woff2 b/docs/fonts/roboto-mono-400.woff2 deleted file mode 100644 index 0a6a0df1..00000000 Binary files a/docs/fonts/roboto-mono-400.woff2 and /dev/null differ diff --git a/docs/fonts/roboto-mono-700.woff2 b/docs/fonts/roboto-mono-700.woff2 deleted file mode 100644 index b6d0cb17..00000000 Binary files a/docs/fonts/roboto-mono-700.woff2 and /dev/null differ diff --git a/docs/fonts/subset-Inter-Bold.woff2 b/docs/fonts/subset-Inter-Bold.woff2 deleted file mode 100644 index f5e2b21b..00000000 Binary files a/docs/fonts/subset-Inter-Bold.woff2 and /dev/null differ diff --git a/docs/fonts/subset-Inter-Italic.woff2 b/docs/fonts/subset-Inter-Italic.woff2 deleted file mode 100644 index e3021147..00000000 Binary files a/docs/fonts/subset-Inter-Italic.woff2 and /dev/null differ diff --git a/docs/fonts/subset-Inter-Medium.woff2 b/docs/fonts/subset-Inter-Medium.woff2 deleted file mode 100644 index 567f4b66..00000000 Binary files a/docs/fonts/subset-Inter-Medium.woff2 and /dev/null differ diff --git a/docs/fonts/subset-Inter-Regular.woff2 b/docs/fonts/subset-Inter-Regular.woff2 deleted file mode 100644 index 0a55534b..00000000 Binary files a/docs/fonts/subset-Inter-Regular.woff2 and /dev/null differ diff --git a/docs/fonts/subset-Inter-SemiBold.woff2 b/docs/fonts/subset-Inter-SemiBold.woff2 deleted file mode 100644 index 08479e63..00000000 Binary files a/docs/fonts/subset-Inter-SemiBold.woff2 and /dev/null differ diff --git a/docs/layouts/BaseLayout.astro b/docs/layouts/BaseLayout.astro deleted file mode 100644 index e77ccc58..00000000 --- a/docs/layouts/BaseLayout.astro +++ /dev/null @@ -1,3 +0,0 @@ ---- -import '@fontsource-variable/inter'; ---- \ No newline at end of file diff --git a/docs/pages/examples.astro b/docs/pages/examples.astro index cdc4f385..1687da3c 100644 --- a/docs/pages/examples.astro +++ b/docs/pages/examples.astro @@ -17,7 +17,7 @@ const headings = testFiles.map(({ name }) => ({

The best example of usage of these bindings are the{" "} - tests.
+ tests.
These exercise the public bindings directly. Reusable Web API bindings and conversion helpers belong in the library rather than in individual examples.

diff --git a/docs/siteConfig.js b/docs/siteConfig.js new file mode 100644 index 00000000..26ceaf5f --- /dev/null +++ b/docs/siteConfig.js @@ -0,0 +1,2 @@ +export const docsSite = "https://rescript-lang.github.io"; +export const docsBase = "experimental-rescript-webapi"; diff --git a/docs/styles/fonts.css b/docs/styles/fonts.css deleted file mode 100644 index 1b3d817b..00000000 --- a/docs/styles/fonts.css +++ /dev/null @@ -1,106 +0,0 @@ -/* latin */ -@font-face { - font-family: "Inter"; - src: - local("Inter Bold"), - local("Inter-Bold"), - url("../fonts/subset-Inter-Bold.woff2") format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -/* latin */ -@font-face { - font-family: "Inter"; - src: - local("Inter Italic"), - local("Inter-Italic"), - url("../fonts/subset-Inter-Italic.woff2") format("woff2"); - font-weight: 400; - font-style: italic; - font-display: swap; - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -/* latin */ -@font-face { - font-family: "Inter"; - src: - local("Inter Medium"), - local("Inter-Medium"), - url("../fonts/subset-Inter-Medium.woff2") format("woff2"); - font-weight: 500; - font-style: normal; - font-display: swap; - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -/* latin */ -@font-face { - font-family: "Inter"; - src: - local("Inter Semi Bold"), - local("Inter-SemiBold"), - url("../fonts/subset-Inter-SemiBold.woff2") format("woff2"); - font-weight: 600; - font-style: normal; - font-display: swap; - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -/* latin */ -@font-face { - font-family: "Inter"; - src: - local("Inter Regular"), - local("Inter-Regular"), - url("../fonts/subset-Inter-Regular.woff2") format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -/* ROBOTO MONO */ - -/* latin */ -@font-face { - font-family: "Roboto Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: - local("Roboto Mono"), - local("RobotoMono-Regular"), - url("../fonts/roboto-mono-400.woff2") format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -/* latin */ -@font-face { - font-family: "Roboto Mono"; - font-style: bold; - font-weight: 700; - font-display: swap; - src: - local("Roboto Mono Bold"), - local("RobotoMono-Bold"), - url("../fonts/roboto-mono-700.woff2") format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, - U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} diff --git a/docs/styles/theme.css b/docs/styles/theme.css index 413e55d4..3533161c 100644 --- a/docs/styles/theme.css +++ b/docs/styles/theme.css @@ -1,6 +1,7 @@ :root, ::backdrop { - --sl-font: "Inter", "Roboto Mono"; + --sl-font: system-ui, sans-serif; + --sl-font-mono: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace; --sl-sidebar-width: 23rem; --sl-text-3xl: 1.5rem; --sl-text-2xl: 1.3rem; diff --git a/docs/typeLinks.js b/docs/typeLinks.js new file mode 100644 index 00000000..7f0cdbfd --- /dev/null +++ b/docs/typeLinks.js @@ -0,0 +1,91 @@ +const stripReScriptComments = (source) => { + const output = []; + let index = 0; + let quote = ""; + + while (index < source.length) { + if (quote !== "") { + const character = source[index] ?? ""; + output.push(character); + if (character === "\\" && index + 1 < source.length) { + output.push(source[index + 1] ?? ""); + index += 2; + } else { + if (character === quote) quote = ""; + index += 1; + } + } else if (source[index] === '"' || source[index] === "`") { + quote = source[index] ?? ""; + output.push(quote); + index += 1; + } else if (source.startsWith("//", index)) { + while (index < source.length && source[index] !== "\n") index += 1; + } else if (source.startsWith("/*", index)) { + index += 2; + while (index < source.length && !source.startsWith("*/", index)) { + if (source[index] === "\n") output.push("\n"); + index += 1; + } + index = Math.min(index + 2, source.length); + output.push(" "); + } else { + output.push(source[index] ?? ""); + index += 1; + } + } + return output.join(""); +}; + +const maskNestedDeclarations = (source) => { + const depth = { value: 0 }; + let quote = ""; + return source + .split("\n") + .map((line) => { + const result = depth.value === 0 ? line : ""; + for (let index = 0; index < line.length; index += 1) { + const character = line[index] ?? ""; + if (quote !== "") { + if (character === "\\") index += 1; + else if (character === quote) quote = ""; + } else if (character === '"' || character === "`") { + quote = character; + } else if (character === "{") { + depth.value += 1; + } else if (character === "}") { + depth.value = Math.max(0, depth.value - 1); + } + } + return result; + }) + .join("\n"); +}; + +export const parsePublicTypeAliases = (moduleName, source) => + [ + ...maskNestedDeclarations(stripReScriptComments(source)).matchAll( + /^type[ \t]+([a-zA-Z][a-zA-Z0-9_]*)(?:<[^>\r\n]*>)?[ \t]*=[ \t]*([A-Z][A-Za-z0-9_]*)\.([a-z][A-Za-z0-9_]*)\b/gm, + ), + ] + .map((match) => ({ + moduleName, + typeName: match[1] ?? "", + backingModule: match[2] ?? "", + backingType: match[3] ?? "", + })) + .filter( + ({ typeName, backingModule, backingType }) => + typeName !== "" && backingModule !== "" && backingType !== "", + ); + +export const createPublicTypeTargets = (aliases) => { + const owners = new Map(); + for (const alias of aliases) { + const key = `${alias.backingModule}.${alias.backingType}`; + const matches = owners.get(key) ?? []; + owners.set(key, [...matches, { moduleName: alias.moduleName, typeName: alias.typeName }]); + } + return new Map( + [...owners].flatMap(([key, matches]) => (matches.length === 1 ? [[key, matches[0]]] : [])), + ); +}; diff --git a/docs/utils.js b/docs/utils.js index 9613a062..b69e88b9 100644 --- a/docs/utils.js +++ b/docs/utils.js @@ -3,6 +3,7 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import { readdirSync, existsSync, readFileSync } from "fs"; import { micromark } from "micromark"; +import { createPublicTypeTargets, parsePublicTypeAliases } from "./typeLinks.js"; const execAsync = promisify(exec); const rootDir = process.cwd(); @@ -44,6 +45,11 @@ export const apiModules = publicSourceEntries .filter(({ filePath }) => existsSync(filePath)) .sort((a, b) => a.moduleName.localeCompare(b.moduleName)); +const publicTypeAliases = apiModules.flatMap(({ filePath, moduleName }) => + parsePublicTypeAliases(moduleName, readFileSync(filePath, "utf8")), +); +export const publicTypeTargets = createPublicTypeTargets(publicTypeAliases); + async function getRescriptDoc(absoluteFilePath) { const { stdout, stderr } = await execAsync(`rescript-tools doc ${absoluteFilePath}`, { maxBuffer: 1024 * 1024 * 10, // Increase buffer to 10 MB diff --git a/package.json b/package.json index 9985197b..ec5e1c4c 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", "check:consumer": "node scripts/check-consumer.mjs", "check:release": "node --test scripts/validate-release-artifact.test.mjs && node scripts/check-release.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", + "check:docs-links": "node --test scripts/check-doc-links.test.mjs scripts/check-doc-type-links.test.mjs && node scripts/check-doc-links.mjs", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./astro.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./scripts/check-doc-links.mjs ./scripts/check-doc-links.test.mjs ./scripts/check-doc-type-links.test.mjs ./package.json ./README.md ./CHANGELOG.md ./docs && prettier --write .github/workflows/*.yml ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./astro.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./scripts/check-doc-links.mjs ./scripts/check-doc-links.test.mjs ./scripts/check-doc-type-links.test.mjs ./package.json ./README.md ./CHANGELOG.md ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs new file mode 100644 index 00000000..5cdfd061 --- /dev/null +++ b/scripts/check-doc-links.mjs @@ -0,0 +1,418 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { docsBase, docsSite } from "../docs/siteConfig.js"; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), ".."); + +const decodeHTML = (value) => + value.replace(/&(#x[\da-f]+|#\d+|amp|quot|apos|lt|gt|nbsp);/gi, (entity, reference) => { + const normalized = reference.toLowerCase(); + if (normalized === "amp") return "&"; + if (normalized === "quot") return '"'; + if (normalized === "apos" || normalized === "#39") return "'"; + if (normalized === "lt") return "<"; + if (normalized === "gt") return ">"; + if (normalized === "nbsp") return "\u00a0"; + const codePoint = normalized.startsWith("#x") + ? Number.parseInt(normalized.slice(2), 16) + : Number.parseInt(normalized.slice(1), 10); + return codePoint > 0 && codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff) + ? String.fromCodePoint(codePoint) + : "\ufffd"; + }); + +const isExternal = (url, siteOrigin) => { + if (!/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return false; + try { + return new URL(url, siteOrigin).origin !== siteOrigin; + } catch { + return true; + } +}; + +const parseAttributes = (text) => { + const attributes = new Map(); + const pattern = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; + for (const match of text.matchAll(pattern)) { + attributes.set( + (match[1] ?? "").toLowerCase(), + decodeHTML(match[2] ?? match[3] ?? match[4] ?? ""), + ); + } + return attributes; +}; + +const findTagEnd = (html, start) => { + let quote = ""; + for (let index = start; index < html.length; index += 1) { + const character = html[index]; + if (quote !== "") { + if (character === quote) quote = ""; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === ">") { + return index; + } + } + return html.length; +}; + +const parseElements = (html) => { + const elements = []; + const lowerHTML = html.toLowerCase(); + let cursor = 0; + let rawTextElement = ""; + + while (cursor < html.length) { + if (rawTextElement !== "") { + const closingTag = lowerHTML.indexOf(`", start + 4); + cursor = commentEnd === -1 ? html.length : commentEnd + 3; + continue; + } + if (html.startsWith("$/.exec(tag); + cursor = end + 1; + if (match === null) continue; + + const name = (match[1] ?? "").toLowerCase(); + const attributes = parseAttributes(match[2] ?? ""); + elements.push({ name, attributes }); + if (["script", "style", "textarea", "title"].includes(name)) rawTextElement = name; + } + return elements; +}; + +const sourceSetReferencesFrom = (sourceSet) => { + const references = []; + let index = 0; + while (index < sourceSet.length) { + while (sourceSet[index] === "," || /\s/.test(sourceSet[index] ?? "")) index += 1; + if (index >= sourceSet.length) break; + + const isDataURL = sourceSet.slice(index, index + 5).toLowerCase() === "data:"; + let url = ""; + while ( + index < sourceSet.length && + !/\s/.test(sourceSet[index] ?? "") && + (isDataURL || sourceSet[index] !== ",") + ) { + url += sourceSet[index] ?? ""; + index += 1; + } + if (url !== "") references.push(url); + while (index < sourceSet.length && sourceSet[index] !== ",") index += 1; + if (sourceSet[index] === ",") index += 1; + } + return references; +}; + +const attributesFrom = (elements) => + elements.flatMap(({ name, attributes }) => { + // Canonical URLs describe a deployment's preferred host/path, not navigation or assets. + if (name === "link" && attributes.get("rel")?.split(/\s+/).includes("canonical")) return []; + const directReferences = ["href", "src", "poster", "action", "data", "xlink:href"] + .map((name) => attributes.get(name)) + .filter((value) => value !== undefined); + const sourceSet = attributes.get("srcset") ?? ""; + const sourceSetReferences = sourceSetReferencesFrom(sourceSet); + const styleReferences = [ + ...cssURLsFrom(attributes.get("style") ?? ""), + ...cssURLsFrom(attributes.get("style-text") ?? ""), + ]; + return [...directReferences, ...sourceSetReferences, ...styleReferences]; + }); + +const skipCSSWhitespaceAndComments = (css, start) => { + let index = start; + while (index < css.length) { + if (/\s/.test(css[index] ?? "")) { + index += 1; + } else if (css.startsWith("/*", index)) { + const commentEnd = css.indexOf("*/", index + 2); + index = commentEnd === -1 ? css.length : commentEnd + 2; + } else { + break; + } + } + return index; +}; + +const readCSSEscape = (css, start) => { + const escaped = css.slice(start + 1); + const hex = /^[0-9a-f]{1,6}/i.exec(escaped)?.[0]; + if (hex === undefined) { + return { value: escaped[0] ?? "", nextIndex: Math.min(start + 2, css.length) }; + } + const codePoint = Number.parseInt(hex, 16); + let nextIndex = start + 1 + hex.length; + if (/\s/.test(css[nextIndex] ?? "")) nextIndex += 1; + const safeCodePoint = + codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff) + ? 0xfffd + : codePoint; + return { value: String.fromCodePoint(safeCodePoint), nextIndex }; +}; + +const readCSSString = (css, start) => { + const quote = css[start] ?? ""; + let value = ""; + let index = start + 1; + while (index < css.length && css[index] !== quote) { + if (css[index] === "\\") { + const escaped = readCSSEscape(css, index); + value += escaped.value; + index = escaped.nextIndex; + } else { + value += css[index] ?? ""; + index += 1; + } + } + return { value, nextIndex: Math.min(index + 1, css.length) }; +}; + +const readCSSIdentifier = (css, start) => { + let value = ""; + let index = start; + while (index < css.length) { + const character = css[index] ?? ""; + if (character === "\\") { + const escaped = readCSSEscape(css, index); + value += escaped.value; + index = escaped.nextIndex; + } else if (/[a-z0-9_-]/i.test(character) || character.charCodeAt(0) >= 0x80) { + value += character; + index += 1; + } else { + break; + } + } + return { value, nextIndex: index }; +}; + +const readCSSURL = (css, start) => { + const contentStart = skipCSSWhitespaceAndComments(css, start); + const quote = css[contentStart]; + if (quote === '"' || quote === "'") { + const parsed = readCSSString(css, contentStart); + const close = skipCSSWhitespaceAndComments(css, parsed.nextIndex); + return css[close] === ")" ? { ...parsed, nextIndex: close + 1 } : null; + } + + let value = ""; + let index = contentStart; + while (index < css.length && css[index] !== ")") { + if (css.startsWith("/*", index)) { + const commentEnd = css.indexOf("*/", index + 2); + index = commentEnd === -1 ? css.length : commentEnd + 2; + } else if (css[index] === "\\") { + const escaped = readCSSEscape(css, index); + value += escaped.value; + index = escaped.nextIndex; + } else { + value += css[index] ?? ""; + index += 1; + } + } + return css[index] === ")" ? { value: value.trim(), nextIndex: index + 1 } : null; +}; + +const cssURLsFrom = (css) => { + const references = []; + let index = 0; + while (index < css.length) { + if (css.startsWith("/*", index)) { + const commentEnd = css.indexOf("*/", index + 2); + index = commentEnd === -1 ? css.length : commentEnd + 2; + continue; + } + + const character = css[index] ?? ""; + if (character === '"' || character === "'") { + index = readCSSString(css, index).nextIndex; + continue; + } + + if (css.slice(index, index + 7).toLowerCase() === "@import") { + const importValue = skipCSSWhitespaceAndComments(css, index + 7); + if (css[importValue] === '"' || css[importValue] === "'") { + const parsed = readCSSString(css, importValue); + references.push(parsed.value); + index = parsed.nextIndex; + continue; + } + } + + if (/[a-z_-]/i.test(character) || character === "\\") { + const identifier = readCSSIdentifier(css, index); + if (identifier.value.toLowerCase() === "url" && css[identifier.nextIndex] === "(") { + const parsed = readCSSURL(css, identifier.nextIndex + 1); + if (parsed !== null && !parsed.value.toLowerCase().startsWith("var(")) { + references.push(parsed.value); + index = parsed.nextIndex; + continue; + } + } + index = Math.max(identifier.nextIndex, index + 1); + } else { + index += 1; + } + } + return references; +}; + +const fragmentsFrom = (html) => + new Set( + parseElements(html).flatMap(({ name, attributes }) => [ + ...(attributes.has("id") ? [attributes.get("id")] : []), + ...(name === "a" && attributes.has("name") ? [attributes.get("name")] : []), + ]), + ); + +const normalizeBase = (base) => { + const segments = base.split("/").filter(Boolean); + return segments.length === 0 ? "/" : `/${segments.join("/")}/`; +}; + +const decodeFragment = (fragment) => { + try { + return decodeURIComponent(fragment); + } catch { + return fragment; + } +}; + +const resolveTarget = (source, reference, base) => { + const basePath = normalizeBase(base); + try { + const pageURL = `https://docs.invalid${basePath}${source.replace(/index\.html$/, "")}`; + const targetURL = new URL(reference, pageURL); + const targetPath = decodeURIComponent(targetURL.pathname); + if (!targetPath.startsWith(basePath)) { + return { _tag: "Failure", reason: `link resolves outside base path ${basePath}` }; + } + const relativePath = targetPath.slice(basePath.length); + const file = + relativePath === "" + ? "index.html" + : relativePath.endsWith("/") + ? `${relativePath}index.html` + : relativePath; + return { + _tag: "Success", + file, + fragment: decodeFragment(targetURL.hash.slice(1)), + }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { _tag: "Failure", reason: `invalid URL or percent-encoding (${reason})` }; + } +}; + +export const checkDocumentLinks = (documents, base, siteOrigin = "https://docs.invalid") => { + const errors = []; + const fragmentCache = new Map(); + if (![...documents.keys()].some((source) => source.endsWith(".html"))) { + return ["No built HTML documents were found."]; + } + for (const [source, html] of documents) { + const references = source.endsWith(".html") + ? attributesFrom(parseElements(html)) + : source.endsWith(".css") + ? cssURLsFrom(html) + : []; + for (const reference of references) { + if (reference === "" || isExternal(reference, siteOrigin)) continue; + const target = resolveTarget(source, reference, base); + if (target._tag === "Failure") { + errors.push(`${source}: ${reference}: ${target.reason}`); + continue; + } + const routeFile = documents.has(target.file) + ? target.file + : !path.extname(target.file) && documents.has(`${target.file}/index.html`) + ? `${target.file}/index.html` + : target.file; + const targetHTML = documents.get(routeFile); + if (targetHTML === undefined) { + errors.push(`${source}: ${reference}: missing ${target.file}`); + continue; + } + if (target.fragment && /\.(?:html|svg)$/i.test(routeFile)) { + const targetFragments = fragmentCache.get(routeFile) ?? fragmentsFrom(targetHTML); + fragmentCache.set(routeFile, targetFragments); + if (targetFragments.has(target.fragment)) continue; + errors.push( + `${source}: ${reference}: missing fragment #${target.fragment} in ${routeFile}`, + ); + } + } + } + return errors; +}; + +const filesIn = (directory, relative = "") => + readdirSync(path.join(directory, relative), { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(relative, entry.name); + return entry.isDirectory() ? filesIn(directory, entryPath) : [entryPath]; + }); + +export const requiredDocPages = [ + "index.html", + "apidocs/index.html", + "examples/index.html", + "font-attribution/index.html", +]; + +export const missingRequiredDocPages = (documents) => + requiredDocPages.filter((page) => !documents.has(page)); + +if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === scriptPath) { + const outputDirectory = path.resolve(process.argv[2] ?? "dist"); + const siteURL = new URL(docsBase, docsSite); + const base = process.argv[3] ?? siteURL.pathname; + const siteOrigin = process.argv[4] ?? new URL(docsSite).origin; + const documents = new Map( + filesIn(outputDirectory).map((file) => [ + file, + readFileSync(path.join(outputDirectory, file), "utf8"), + ]), + ); + const missingPages = missingRequiredDocPages(documents).map( + (page) => `Missing required docs page ${page}.`, + ); + const errors = [...missingPages, ...checkDocumentLinks(documents, base, siteOrigin)]; + if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); + } + console.log( + `Validated local links, assets, and fragments in ${documents.size} built files. External and canonical URLs were not checked.`, + ); +} diff --git a/scripts/check-doc-links.test.mjs b/scripts/check-doc-links.test.mjs new file mode 100644 index 00000000..0b87628f --- /dev/null +++ b/scripts/check-doc-links.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { checkDocumentLinks, missingRequiredDocPages } from "./check-doc-links.mjs"; + +test("validates page, asset, and fragment links under a deployment base", () => { + const pages = new Map([ + [ + "index.html", + 'Headers', + ], + ["apidocs/fetch/index.html", '

Headers

'], + ["logo.svg", ""], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), []); +}); + +test("resolves relative guide links from their page and detects missing targets", () => { + const pages = new Map([ + ["examples/index.html", 'testsbad'], + ["06-testing/index.html", "
guide
"], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), [ + "examples/index.html: #missing: missing fragment #missing in examples/index.html", + ]); +}); + +test("rejects absolute site links that omit the deployment base", () => { + const pages = new Map([["index.html", 'Fetch']]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), [ + "index.html: /apidocs/fetch/: link resolves outside base path /docs-site/", + ]); +}); + +test("supports a root deployment base", () => { + const pages = new Map([ + ["index.html", 'guide'], + ["guide/index.html", "

Guide

"], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/"), []); +}); + +test("rejects relative links that escape the deployment base", () => { + const pages = new Map([ + ["index.html", 'guide'], + ["guide/index.html", "

Guide

"], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs/"), [ + "index.html: ../guide/: link resolves outside base path /docs/", + ]); +}); + +test("does not strip an accidentally duplicated base path from relative URLs", () => { + const pages = new Map([ + ["index.html", 'guide'], + ["guide/index.html", "

Guide

"], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs/"), [ + "index.html: docs/guide/: missing docs/guide/index.html", + ]); +}); + +test("does not treat external links as local routes", () => { + const pages = new Map([ + ["index.html", 'external'], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), []); +}); + +test("validates absolute same-site links against built routes", () => { + const pages = new Map([ + ["index.html", 'guide'], + ["guide/index.html", '

Guide

'], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/", "https://docs.invalid"), []); +}); + +test("does not treat canonical metadata as a navigational route", () => { + const pages = new Map([ + [ + "index.html", + 'guide', + ], + ["guide/index.html", "

Guide

"], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/", "https://docs.invalid"), []); +}); + +test("validates SVG fragment targets as well as HTML fragments", () => { + const pages = new Map([ + ["index.html", 'icon'], + ["assets/icons.svg", ''], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), []); +}); + +test("does not split commas inside data URLs when checking srcset assets", () => { + const pages = new Map([ + [ + "index.html", + '', + ], + ["assets/second.svg", ""], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), []); +}); + +test("finds stale implementation-module links and accepts unlinked internal type text", () => { + const pages = new Map([ + [ + "apidocs/request/index.html", + '

FetchTypes.headers

headers', + ], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), [ + "apidocs/request/index.html: /docs-site/apidocs/fetch-types/#headers: missing apidocs/fetch-types/index.html", + ]); +}); + +test("ignores URL-like markup in code and script text", () => { + const pages = new Map([ + [ + "index.html", + `
<a href="/docs-site/not-a-route">
+ + guide`, + ], + ["guide/index.html", "

Guide

"], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), []); +}); + +test("reports malformed percent-encoding and rejects an empty build", () => { + const pages = new Map([ + ["index.html", 'bad fragmentbad path'], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), [ + "index.html: #%E0%A4%A: missing fragment #%E0%A4%A in index.html", + "index.html: bad%: invalid URL or percent-encoding (URI malformed)", + ]); + assert.deepEqual(checkDocumentLinks(new Map(), "/docs-site/"), [ + "No built HTML documents were found.", + ]); +}); + +test("requires the docs home, API index, examples, and font-attribution pages", () => { + const artifact = new Map([ + ["index.html", ""], + ["apidocs/index.html", ""], + ["examples/index.html", ""], + ]); + + assert.deepEqual(missingRequiredDocPages(artifact), ["font-attribution/index.html"]); +}); + +test("normalizes numeric HTML entities and literal percent fragments", () => { + const pages = new Map([ + [ + "index.html", + 'accententity textpercent', + ], + ["guide/index.html", '

'], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs/"), []); +}); + +test("checks local CSS imports, url() assets, and inline styles", () => { + const pages = new Map([ + [ + "index.html", + '
', + ], + [ + "styles/site.css", + String.raw`/* old asset: url("../removed.woff2") */ @import "../theme.css"; .icon { content: "url(../not-an-asset.svg)"; background: url("../assets/icon.svg"); src: u\72l("../assets/escaped.svg"); }`, + ], + ["theme.css", "body { color: black; }"], + ["assets/inline.svg", ""], + ["assets/icon.svg", ""], + ["assets/escaped.svg", ""], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), []); +}); + +test("reports missing assets from actual CSS url() tokens", () => { + const pages = new Map([ + ["index.html", ''], + [ + "site.css", + String.raw`/* url("/docs-site/comment.svg") */ .x{background:url("/docs-site/missing.svg")}`, + ], + ]); + + assert.deepEqual(checkDocumentLinks(pages, "/docs-site/"), [ + "site.css: /docs-site/missing.svg: missing missing.svg", + ]); +}); diff --git a/scripts/check-doc-type-links.test.mjs b/scripts/check-doc-type-links.test.mjs new file mode 100644 index 00000000..5aef178b --- /dev/null +++ b/scripts/check-doc-type-links.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createPublicTypeTargets, parsePublicTypeAliases } from "../docs/typeLinks.js"; + +test("parses public aliases to backing module types, including generic aliases", () => { + assert.deepEqual( + parsePublicTypeAliases( + "ReadableStream", + "type t<'r> = FileTypes.readableStream<'r>\ntype options = StreamTypes.options", + ), + [ + { + moduleName: "ReadableStream", + typeName: "t", + backingModule: "FileTypes", + backingType: "readableStream", + }, + { + moduleName: "ReadableStream", + typeName: "options", + backingModule: "StreamTypes", + backingType: "options", + }, + ], + ); +}); + +test("ignores comment examples and nested-module aliases when finding public targets", () => { + assert.deepEqual( + parsePublicTypeAliases( + "PublicOwner", + `/** +type example = Internal.real +*/ +type t = Internal.real +module Impl = { +type hidden = Internal.secret +}`, + ), + [ + { + moduleName: "PublicOwner", + typeName: "t", + backingModule: "Internal", + backingType: "real", + }, + ], + ); +}); + +test("only links backing types with one public owner", () => { + const targets = createPublicTypeTargets([ + { + moduleName: "Request", + typeName: "t", + backingModule: "FetchTypes", + backingType: "request", + }, + { + moduleName: "Request", + typeName: "requestInit", + backingModule: "FetchTypes", + backingType: "requestInit", + }, + { + moduleName: "Response", + typeName: "t", + backingModule: "FetchTypes", + backingType: "response", + }, + { + moduleName: "Request", + typeName: "bodyInit", + backingModule: "FetchTypes", + backingType: "bodyInit", + }, + { + moduleName: "Response", + typeName: "bodyInit", + backingModule: "FetchTypes", + backingType: "bodyInit", + }, + ]); + + assert.deepEqual(targets.get("FetchTypes.request"), { moduleName: "Request", typeName: "t" }); + assert.deepEqual(targets.get("FetchTypes.requestInit"), { + moduleName: "Request", + typeName: "requestInit", + }); + assert.equal(targets.get("FetchTypes.bodyInit"), undefined); +});