diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 71643dd..8a1cd48 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -2,7 +2,9 @@
"name": "JSONText Dev Container",
"image": "mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie",
"features": {
- "ghcr.io/devcontainers-extra/features/deno:1": {}
+ "ghcr.io/devcontainers-extra/features/deno:1": {
+ "version": "2.9.5"
+ }
},
"customizations": {
"vscode": {
@@ -15,8 +17,10 @@
"editor.codeActionsOnSave": {
"source.organizeImports": "always"
},
- "deno.enablePaths": ["./tests/"],
- "deno.config": "./deno.json"
+ "json.schemaDownload.trustedDomains": {
+ "https://deno.land/x/deno/cli/schemas/lint-rules.v1.json": true,
+ "https://deno.land/x/deno/cli/schemas/lint-tags.v1.json": true
+ }
}
}
}
diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml
index c5db448..cff4e4b 100644
--- a/.github/workflows/bump.yml
+++ b/.github/workflows/bump.yml
@@ -1,12 +1,21 @@
-name: Bump
+name: Bump Version
on:
workflow_dispatch:
inputs:
- version:
- description: "new version(e.g., 1.2.3)"
+ increment:
+ description: "version increment"
required: true
- type: string
+ default: patch
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ - prepatch
+ - preminor
+ - premajor
+ - prerelease
permissions:
contents: write
@@ -19,24 +28,19 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- - name: Set up Node.js
- uses: actions/setup-node@v6
+ - name: Set up Deno
+ uses: denoland/setup-deno@v2
with:
- node-version: lts/*
- cache: npm
- registry-url: https://registry.npmjs.org/
+ deno-version: v2.9.5
- - name: Store Version
- run: |
- TARGET_VERSION="${{ github.event.inputs.version }}"
- TARGET_VERSION="${TARGET_VERSION#v}"
- echo "VERSION=$TARGET_VERSION" >> $GITHUB_ENV
-
- - name: Update Package Version
+ - name: Bump Package Versions
+ env:
+ INCREMENT: ${{ inputs.increment }}
run: |
- jq ".version = \"$VERSION\"" package.json > tmp.json && mv tmp.json package.json
- jq ".version = \"$VERSION\"" deno.json > tmp.json && mv tmp.json deno.json
- npm install --package-lock-only --ignore-scripts
+ deno bump-version "$INCREMENT" --config deno.json
+ deno bump-version "$INCREMENT" --config package.json
+ VERSION="$(deno bump-version --config deno.json)"
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Setup Git
run: |
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 9ad695b..a1b99a8 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,4 +1,4 @@
-name: Publish
+name: Publish Package
on:
release:
@@ -14,29 +14,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- - name: Checkout
+ - name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
- cache: npm
registry-url: https://registry.npmjs.org/
- - name: Update npm to latest
- run: npm install -g npm@latest
-
- - name: Install dependencies
- run: |
- rm -rf node_modules package-lock.json
- npm install
+ - name: Set up Deno
+ uses: denoland/setup-deno@v2
+ with:
+ deno-version: v2.9.5
- - name: Build
- run: npm run build -- --mode lib
+ - name: Build Package
+ run: deno task build
- name: Publish to npm
run: npm publish --access public --provenance
- name: Publish to JSR
- run: npx jsr publish --allow-dirty
+ run: deno publish
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 23bed05..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,4 +0,0 @@
-# Changelog
-
-All notable changes are documented in
-[GitHub Releases](https://github.com/lcweden/jsontext/releases).
diff --git a/README.md b/README.md
index 44d72d3..d5f6538 100644
--- a/README.md
+++ b/README.md
@@ -4,508 +4,37 @@
[](https://www.npmjs.com/package/jsontext)
[](https://jsr.io/@lcweden/jsontext)
-A state machine for incremental JSON processing.
-
-## Quick Start
-
-The following example demonstrates how to use `JSONTextSelectorStream` to extract all `address` from
-a JSON fetched from [DummyJSON](https://dummyjson.com/).
-
-```javascript
-import { JSONTextSelectorStream } from "jsontext";
-
-const response = await fetch("https://dummyjson.com/users");
-const addresses = response.body.pipeThrough(new JSONTextSelectorStream("$.users[*].address"));
-
-for await (const value of addresses) {
- console.log(value.json());
-}
-```
+JSONText is a low-level, incremental UTF-8 JSON decoder and encoder for token-level processing,
+selective extraction, and Web Streams pipelines.
## Installation
-`jsontext` is an ESM-only package available on both `NPM` and `JSR`. The core decoder and encoder
-run in any modern JavaScript environment; the optional `*Stream` classes additionally require
-`WHATWG` Streams support:
-
-### NPM
-
-Install via [npm](https://www.npmjs.com/package/jsontext):
-
-```bash
-npm install jsontext
-```
-
-### Deno
-
-Install via [JSR](https://jsr.io/@lcweden/jsontext):
+### JSR
```bash
deno add jsr:@lcweden/jsontext
```
-## APIs
-
-See full reference on [JSR](https://jsr.io/@lcweden/jsontext/doc/).
-
-| Category | Exports |
-| :--------- | :------------------------------------------------------------------------------------------------------- |
-| Core | [`JSONTextDecoder`], [`JSONTextEncoder`] |
-| Stream | [`JSONTextDecoderStream`], [`JSONTextEncoderStream`], [`JSONTextSelectorStream`], [`JSONTextLineStream`] |
-| Components | [`Token`], [`Value`], [`Kind`] |
-| Error | [`SyntacticError`] |
-
-[`JSONTextDecoder`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextDecoder
-[`JSONTextEncoder`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextEncoder
-[`JSONTextDecoderStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextDecoderStream
-[`JSONTextEncoderStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextEncoderStream
-[`JSONTextSelectorStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextSelectorStream
-[`JSONTextLineStream`]: https://jsr.io/@lcweden/jsontext/doc/~/JSONTextLineStream
-[`Token`]: https://jsr.io/@lcweden/jsontext/doc/~/Token
-[`Value`]: https://jsr.io/@lcweden/jsontext/doc/~/Value
-[`Kind`]: https://jsr.io/@lcweden/jsontext/doc/~/KIND
-[`SyntacticError`]: https://jsr.io/@lcweden/jsontext/doc/~/SyntacticError
-
-### Core
-
-The core APIs provide more control and flexibility. They are designed for scenarios where Web
-Streams are not available or when you need granular control.
-
-#### JSONTextDecoder
-
-A low-level, stateful JSON decoder that processes bytes incrementally. It is suitable for developing
-custom JSON processing logic and `TransformStreams`.
-
-Unlike `JSON.parse`, you need to `.push()` bytes into `JSONTextDecoder` as they arrive, and pull
-`Tokens` or `Values`.
-
-##### Basic Usage
-
-The following example demonstrates how to `.push()` bytes into `JSONTextDecoder` and read tokens one
-by one. The decoder automatically buffers incomplete tokens across bytes.
-
-```javascript
-const decoder = new JSONTextDecoder();
-
-decoder.push(new TextEncoder().encode(`{"name": "Al`));
-decoder.push(new TextEncoder().encode(`ice", "age": 18`));
-decoder.push(new TextEncoder().encode(`}`));
-
-decoder.end(); // no more bytes are coming, signal the end of input
-
-decoder.readToken().kind; // KIND.OBJECT_BEGIN ('{')
-decoder.readToken().asString(); // "name"
-decoder.readToken().asString(); // "Alice"
-decoder.readToken().asString(); // "age"
-decoder.readToken().asNumber(); // 18
-decoder.readToken().kind; // KIND.OBJECT_END ('}')
-
-decoder.checkEOF();
-```
-
-You may want to check the type before parsing a `Token`. `KIND` is a constant enum that can be used
-like this: `token.kind === KIND.STRING` or `token.kind === KIND.BOOLEAN`.
-
-> [!TIP]
-> `.end()` signals that no more bytes will be pushed. The decoder needs this signal to confirm that
-> a number at the very end of the stream is complete and not just more digits still coming, since
-> there is no delimiter after it. Always call `.end()` when you know the input is done.
-
-> [!TIP]
-> `checkEOF()` asserts that the entire input was consumed and well-formed, no unclosed objects or
-> trailing garbage bytes.
-
-##### Extracting and Skipping Values
-
-Other than reading tokens one by one, you can also read a `Value` with `.readValue()`, which can be
-a scalar, an entire object, or an array.
-
-```javascript
-const decoder = new JSONTextDecoder(new TextEncoder().encode(`{"id": 1, "metadata": { }}`));
-let token;
-
-while (true) {
- token = decoder.readToken();
-
- if (token === undefined) {
- break; // need more bytes
- }
-
- if (token.asString() === "metadata") {
- const value = decoder.readValue();
- const metadata = value.json();
- } else {
- decoder.skipValue(); // skip the value of this token without parsing it
- }
-}
-
-decoder.end();
-decoder.checkEOF();
-```
-
-The example above follows the sequence:
-
-| step | action |
-| :--- | :-------------------------- |
-| 1 | read a token (`"id"`) |
-| 2 | skip the value (`1`) |
-| 3 | read a token (`"metadata"`) |
-| 4 | read a value (`{ }`) |
-| 5 | parse the value as JSON |
-
-> [!TIP]
-> Use `.stackPointer()` to get the [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901),
-> which is useful for targeting specific paths in the document like
-> `decoder.stackPointer() === "/metadata"`.
-
-##### Web Streams
-
-The following example demonstrates how to use `JSONTextDecoder` with a `ReadableStream` from
-`fetch`.
-
```javascript
-const response = await fetch("your.api/endpoint");
-const decoder = new JSONTextDecoder();
-
-// Outer loop: wait for new chunks to arrive
-for await (const chunk of response.body) {
- decoder.push(chunk);
-
- // Inner loop: read all decodable tokens from the current buffer
- for (let token; (token = decoder.readToken()) !== undefined;) {
- // read token or value...
- }
-}
-
-decoder.end();
-decoder.checkEOF();
+import { JSONTextSelectorStream } from "@lcweden/jsontext";
```
-Requires the user to manage backpressure and chunk boundaries, it gives you the most control and
-flexibility. Check `JSONTextDecoderStream` to see how to wrap it in a `TransformStream` that handles
-all the stream mechanics for you.
-
-#### JSONTextEncoder
-
-It is the exact counterpart to `JSONTextDecoder`, which allows you to construct a JSON document
-token by token or value.
-
-##### Basic Usage
-
-You can feel free to write tokens and values in any order using `Token` and `Value` provided
-methods.
-
-```javascript
-import { Token, Value } from "jsontext";
-
-const decoder = new TextDecoder();
-const encoder = new JSONTextEncoder();
-
-encoder.writeToken(Token.ARRAY_BEGIN);
-encoder.writeValue(Value.from({ id: 1, status: "active" }));
-encoder.writeValue(Value.from({ id: 2, status: "pending" }));
-encoder.writeToken(Token.ARRAY_END);
-
-const bytes = encoder.takeBytes();
-const text = decoder.decode(bytes);
-// '[{"id":1,"status":"active"},{"id":2,"status":"pending"}]'
-```
-
-##### Round Trip
-
-A common use case is piping a decoder directly into an encoder to mutate a stream on the fly. In
-this pattern, you drain tokens from the decoder, modify them if needed, and write them to the
-encoder.
-
-```javascript
-const decoder = new JSONTextDecoder();
-const encoder = new JSONTextEncoder();
-
-const response = await fetch("your.api/endpoint");
-
-for await (const chunk of response.body) {
- decoder.push(chunk);
-
- for (let token; (token = decoder.readToken()) !== undefined;) {
- encoder.writeToken(token);
- }
-
- const bytes = encoder.takeBytes();
-}
-
-decoder.end();
-decoder.checkEOF();
-```
-
-> [!IMPORTANT]
-> `takeBytes()` only gives you the encoded bytes and clears the encoder's internal buffer. It does
-> not write them anywhere. You must manually pipe these bytes to your destination, such as a file
-> writer, network socket, or controller.
-
-### Stream
-
-These classes wrap the core decoder and encoder in `TransformStream` interfaces, making them easy to
-handle some common use cases and compose with other Web Streams APIs. See the [Examples](#examples)
-section for more details.
-
-#### JSONTextDecoderStream
+### npm
-Wraps a `JSONTextDecoder` and emits `Token`s as they are decoded. Ideal for token-level processing,
-such as filtering or transforming tokens. If you need to work with `Value`, use `JSONTextDecoder`
-directly.
-
-```javascript
-const response = await fetch("your.api/endpoint");
-const tokens = response.body.pipeThrough(new JSONTextDecoderStream());
-
-for await (const token of tokens) {
- // ...
-}
-```
-
-#### JSONTextEncoderStream
-
-Wraps a `JSONTextEncoder` and accepts `Token` only. While streams like `JSONTextSelectorStream` and
-`JSONTextLineStream` emit `Value`, `Value` provides a `.tokens()` generator that can be used to feed
-tokens into `JSONTextEncoderStream`.
-
-The following example demonstrates how to write a `TransformStream` that converts `Value` into
-`Token` and pipe it into a `JSONTextEncoderStream`.
-
-```javascript
-const encoder = new JSONTextEncoderStream();
-const transformer = new TransformStream({
- transform(value, controller) {
- for (const token of value.tokens()) {
- controller.enqueue(token);
- }
- },
-});
-
-stream.pipeThrough(transformer).pipeThrough(encoder);
-```
-
-#### JSONTextSelectorStream
-
-`JSONTextSelectorStream` supports a subset of
-[JSON Path](https://datatracker.ietf.org/doc/html/rfc9535) syntax for selecting specific values from
-a JSON document.
-
-| Supported | Syntax |
-| :---------------------------------------------------------------------------------------------- | :-------------------------------------- |
-| [Root Identifier](https://datatracker.ietf.org/doc/html/rfc9535#name-root-identifier) | `$` |
-| [Child Segment](https://datatracker.ietf.org/doc/html/rfc9535#name-child-segment) | `.`, `[]` |
-| [Descendant Segment](https://datatracker.ietf.org/doc/html/rfc9535#name-descendant-segment) | `..` |
-| [Name Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-name-selector) | `.name`, `['name']`, `['name', 'name']` |
-| [Wildcard Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-wildcard-selector) | `.*` |
-| [Index Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-index-selector) | `[0]` |
-| [Array Slice Selector](https://datatracker.ietf.org/doc/html/rfc9535#name-array-slice-selector) | `[start:end:step]` |
-
-> [!NOTE]
-> Negative numbers in index and slice selectors are not supported.
-
-The following example extracts all `email` values from `{ "users": [ ... ] }`.
-
-```javascript
-const response = await fetch("your.api/endpoint");
-const emails = response.body.pipeThrough(new JSONTextSelectorStream("$.users[*].email"));
-
-for await (const value of emails) {
- console.log(value.json());
-}
-```
-
-> [!TIP]
-> `Value` has an optional `.pointer` property that returns the
-> [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) of where the value was located in
-> the source document. `JSONTextSelectorStream` sets this automatically, so you can use it to get
-> the exact location of each selected value.
-
-#### JSONTextLineStream
-
-`JSONTextLineStream` is designed for processing JSON Lines (JSONL) format, but it can also handle
-concatenated JSON documents.
-
-```javascript
-const response = await fetch("your.api/endpoint");
-const lines = response.body.pipeThrough(new JSONTextLineStream());
-
-for await (const value of lines) {
- console.log(value.json());
-}
-```
-
-### Components
-
-#### Token
-
-A `Token` represents the smallest lexical unit of JSON. It is either a scalar (like `"Alice"`,
-`true`, `123`, `null`) or a structural symbol (like `{`, `}`, `[`, `]`), it **never** represents a
-whole object or array.
-
-See JSR documentation for all available methods, such as `ARRAY_BEGIN`, `.asNumber()`,
-`.isScalar()`, etc.
-
-> [!IMPORTANT]
-> Tokens and Values returned from a decoder are views into its internal buffer. This buffer is
-> overwritten the next time you `.push()` more bytes.
->
-> If you need to keep a token or value around for later use, you must copy it using `.clone()`:
->
-> ```javascript
-> const collected = [];
-> while ((token = decoder.readToken()) !== undefined) {
-> collected.push(token); // ❌ UNSAFE: all entries will point to the mutated bytes
-> collected.push(token.clone()); // ✅ SAFE: creates an independent copy
-> }
-> ```
-
-#### Value
-
-A `Value` represents a complete JSON unit. It can be a simple scalar, or it can be an entire
-`object` or `array` including everything nested inside it.
-
-Use `Value` when you need a specific subtree. You can call `value.json()` to materialize it into a
-JavaScript object, or use `decoder.skipValue()` to cheaply discard massive branches you don't need
-without ever parsing them.
-
-##### Create a Value instance `from`
-
-`.from()` is a static helper that creates a `Value` instance from any JSON-serializable value.
-
-```javascript
-const value = Value.from("Hello, World!");
-```
-
-##### Canonicalize
-
-`.canonicalize()` implements the
-[JSON Canonicalization Scheme](https://datatracker.ietf.org/doc/html/rfc8785) by recursively sorting
-object keys by UTF-16 code unit order and normalizing numbers. The result is deterministic and
-idempotent, making it ideal for hashing or strict comparisons.
-
-```javascript
-const value = Value.from({ b: 2, a: 1 }).canonicalize(); // {"a":1,"b":2}
-```
-
-##### Tokenize
-
-`.tokens()` is a generator method that yields each `Token` within this value in document order. This
-allows you to process or transform the value token by token without materializing the whole thing in
-memory.
-
-```javascript
-const value = Value.from({ name: "Alice", tags: ["admin", "user"] });
-
-for (const token of value.tokens()) {
- if (token.kind === KIND.STRING) {
- console.log(token.asString());
- }
-}
+```bash
+npm install jsontext
```
-#### Kind
-
-`KIND` is a constant object containing string discriminants that identify the structural role of a
-JSON token. Always use these constants for comparisons to avoid typos.
-
-| Kind | Value |
-| :------------------ | :--------- |
-| `KIND.NULL` | `"null"` |
-| `KIND.FALSE` | `"false"` |
-| `KIND.TRUE` | `"true"` |
-| `KIND.STRING` | `"string"` |
-| `KIND.NUMBER` | `"number"` |
-| `KIND.OBJECT_BEGIN` | `"{"` |
-| `KIND.OBJECT_END` | `"}"` |
-| `KIND.ARRAY_BEGIN` | `"["` |
-| `KIND.ARRAY_END` | `"]"` |
-
-You can check a token's kind with `token.kind === KIND.STRING` or use helper methods like
-`token.isScalar()`, `token.isStructural()`, etc.
-
-### Error
-
-`jsontext` throws standard JavaScript errors (`TypeError`, `RangeError`, `SyntaxError`) for
-programmer mistakes such as invalid arguments or type mismatches. For malformed JSON input, it
-throws the custom `SyntacticError` described below.
-
-#### SyntacticError
-
-When input violates [The JavaScript Object Notation](https://datatracker.ietf.org/doc/html/rfc8259),
-it throws a `SyntacticError` carrying both the byte `offset` and the JSON `pointer` to help pinpoint
-the exact failure.
-
```javascript
-import { JSONTextDecoder, SyntacticError } from "jsontext";
-
-try {
- const encoder = new TextEncoder();
- const decoder = new JSONTextDecoder(encoder.encode(`{"a": 1, "b": }`));
-
- decoder.end();
-
- while (decoder.readToken() !== undefined) {
- /* ... */
- }
-} catch (error) {
- if (error instanceof SyntacticError) {
- console.error(error.offset);
- console.error(error.pointer);
- console.error(error.message);
- }
-}
+import { JSONTextSelectorStream } from "jsontext";
```
-## Performance
-
-`jsontext` is designed for flat memory usage regardless of input size. The following shows a
-passthrough run on a 1 GB file — heap stays near baseline throughout:
-
-
-
-For full profiling results across passthrough, round-trip, and query scenarios, see
-[docs/performance.md](docs/performance.md).
-
-## Examples
+JSONText is ESM-only. The core APIs use `Uint8Array` chunks and work in modern JavaScript runtimes.
+The `*Stream` APIs require WHATWG Streams support.
-Below are some simple examples demonstrating how to use `jsontext` for common JSON processing tasks.
-For more examples, see the [docs/](docs/).
-
-### Replace `null` with an empty string
-
-In this example, we read a JSON stream from an API endpoint, replace all `null` values with empty
-strings, and write the modified JSON back out as a stream without ever materializing the whole
-document in memory.
-
-```javascript
-import { JSONTextDecoderStream, JSONTextEncoderStream, KIND, Token } from "jsontext";
-
-const response = await fetch("your.api/endpoint");
-
-if (!response.ok || !response.body) {
- throw new Error("Failed to fetch data");
-}
-
-const decoder = new JSONTextDecoderStream();
-const encoder = new JSONTextEncoderStream();
-const replacer = new TransformStream({
- transform(token, controller) {
- if (token.kind === KIND.NULL) { // Detect a `null` token
- controller.enqueue(Token.fromString("")); // Emit an empty string token instead
- } else {
- controller.enqueue(token);
- }
- },
-});
-
-const stream = response.body.pipeThrough(decoder).pipeThrough(replacer).pipeThrough(encoder);
-const blob = await new Response(stream).blob();
-```
+## API
-> [!TIP]
-> `JSONTextDecoderStream` supports token-level processing only. If you need to replace values that
-> may be nested inside objects or arrays, you will need to use `JSONTextDecoder` directly.
+See the [JSR API reference](https://jsr.io/@lcweden/jsontext/doc/) for constructors, methods.
## License
@@ -514,4 +43,6 @@ This project is licensed under the [MIT](LICENSE) License.
## Acknowledgements
This project is inspired by Go's
-[`encoding/json/jsontext`](https://pkg.go.dev/encoding/json/jsontext) standard library.
+[`encoding/json/jsontext`](https://pkg.go.dev/encoding/json/jsontext) standard library package and
+.NET's
+[`System.Text.Json`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json?view=net-8.0).
diff --git a/deno.json b/deno.json
index 5a4d502..5aa27d8 100644
--- a/deno.json
+++ b/deno.json
@@ -4,10 +4,13 @@
"license": "MIT",
"tasks": {
"bench": "deno bench --allow-all",
+ "build": "deno task transpile && deno task bundle",
+ "bundle": "deno bundle src/index.ts --minify --outdir dist --platform browser --sourcemap",
"check": "deno check src/index.ts",
"format": "deno fmt",
"lint": "deno lint",
- "test": "deno test --coverage --allow-all"
+ "test": "deno test --allow-all --coverage",
+ "transpile": "cd src && deno transpile index.ts --outdir ../dist --declaration"
},
"imports": {
"#src/": "./src/",
@@ -21,38 +24,15 @@
},
"lint": {
"rules": {
- "tags": [
- "recommended"
- ],
- "include": [
- "no-slow-types"
- ],
- "exclude": [
- "no-sloppy-imports"
- ]
+ "tags": ["recommended"],
+ "include": ["no-slow-types"],
+ "exclude": ["no-sloppy-imports"]
}
},
- "unstable": [
- "sloppy-imports"
- ],
- "exclude": [
- "node_modules/",
- "public/",
- "dist/"
- ],
+ "unstable": ["sloppy-imports"],
+ "exclude": ["dist/"],
"publish": {
- "include": [
- "src/",
- "README.md",
- "LICENSE",
- "deno.json"
- ],
- "exclude": [
- "tests/",
- ".github/",
- ".vscode/",
- "dist/",
- "public/"
- ]
+ "include": ["src/", "README.md", "LICENSE", "deno.json"],
+ "exclude": ["tests/", ".github/", ".vscode/", "dist/"]
}
}
diff --git a/deno.lock b/deno.lock
index 7a4b627..28036d2 100644
--- a/deno.lock
+++ b/deno.lock
@@ -18,14 +18,6 @@
"workspace": {
"dependencies": [
"jsr:@std/assert@1"
- ],
- "packageJson": {
- "dependencies": [
- "npm:@types/node@^25.9.1",
- "npm:typescript@~6.0.2",
- "npm:unplugin-dts@^1.0.1",
- "npm:vite@^8.0.9"
- ]
- }
+ ]
}
}
diff --git a/docs/performance.md b/docs/performance.md
deleted file mode 100644
index 4c6f6f5..0000000
--- a/docs/performance.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# Performance
-
-This section focuses on memory performance. When processing huge files, the goal is to keep the
-memory baseline flat and GC pauses to an absolute minimum, entirely independent of the input size.
-
-> [!NOTE]
-> The following examples are run on `Node.js` using a 1 GB JSON file. Performance profiling is
-> generated via `clinic`.
-
-## Passthrough
-
-This scenario demonstrates the absolute base cost of parsing. We use the core `JSONTextDecoder` to
-read chunks from a 1 GB file, tokenize them, and immediately discard the tokens.
-
-```javascript
-import { createReadStream } from "node:fs";
-import { JSONTextDecoder } from "jsontext";
-
-const decoder = new JSONTextDecoder();
-const stream = createReadStream("data.json");
-
-for await (const chunk of stream) {
- decoder.push(chunk);
-
- while (decoder.readToken() !== undefined) {
- /** Drain */
- }
-}
-
-decoder.end();
-decoder.checkEOF();
-```
-
-
-
-## Round Trip
-
-This scenario represents a full I/O cycle. We stream bytes from the 1 GB file, decode them into
-Tokens using the core `JSONTextDecoder`, immediately feed those tokens into `JSONTextEncoder`, and
-write the re-encoded bytes to a destination `/dev/null`.
-
-```javascript
-import { createReadStream, createWriteStream } from "node:fs";
-import { JSONTextDecoder, JSONTextEncoder } from "jsontext";
-
-const input = createReadStream("data.json");
-const output = createWriteStream("/dev/null");
-const decoder = new JSONTextDecoder();
-const encoder = new JSONTextEncoder();
-
-for await (const chunk of input) {
- decoder.push(chunk);
-
- for (let token; (token = decoder.readToken()) !== undefined;) {
- encoder.writeToken(token);
- }
-
- const bytes = encoder.takeBytes();
-
- if (bytes.length > 0) {
- output.write(bytes);
- }
-}
-
-decoder.end();
-decoder.checkEOF();
-output.end();
-```
-
-
-
-> [!IMPORTANT]
-> Using `JSONTextDecoderStream` and `JSONTextEncoderStream` directly in Node.js requires `.toWeb()`
-> to convert to Web Streams, which adds an extra buffering layer and can push Heap Used up to 300 MB
-> before triggering GC in this scenario.
-
-## Query
-
-This scenario demonstrates a data querying use case. We use `JSONTextSelectorStream` with a
-descendant JSON Path expression `$..id` to scan the entire 1 GB file. For each match, we call
-`json()` to decode the value into a JavaScript object, and keep a count of the total matches.
-
-Since `JSONTextSelectorStream` is a Web Streams `TransformStream`, we use `.toWeb()` to bridge
-`Node.js` streams.
-
-```javascript
-import { JSONTextSelectorStream } from "jsontext";
-import { createReadStream } from "node:fs";
-import { Readable } from "node:stream";
-
-const stream = createReadStream("data.json");
-const selector = new JSONTextSelectorStream("$..id");
-let count = 0;
-
-for await (const value of Readable.toWeb(stream).pipeThrough(selector)) {
- value.json();
- count++;
-}
-
-console.log(`Total values: ${count}`);
-// Total values: 565255 for the 1 GB file used in this example
-```
-
-
-
-> [!TIP]
-> `JSONTextSelectorStream` only emits matched values, so the frequency of `.enqueue()` calls is low
-> bounded by the number of matches, not the number of tokens. This makes the microtask overhead from
-> Web Streams acceptable here. In the Round Trip scenario every token triggers an `.enqueue()`,
-> which creates enough microtask pressure to push Heap Used to 300 MB and trigger GC. That is why we
-> use the core APIs directly there instead.
diff --git a/index.html b/index.html
deleted file mode 100644
index 4b56218..0000000
--- a/index.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
- jsontext
-
-
-
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index f409f45..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,1316 +0,0 @@
-{
- "name": "jsontext",
- "version": "0.2.3",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "jsontext",
- "version": "0.2.3",
- "license": "MIT",
- "devDependencies": {
- "@types/node": "^25.9.1",
- "typescript": "~6.0.2",
- "unplugin-dts": "^1.0.1",
- "vite": "^8.0.9"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@emnapi/core": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
- "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.2",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/wasi-threads": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
- "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
- "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.3"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.139.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
- "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
- }
- },
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
- "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
- "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
- "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
- "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
- "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
- "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
- "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
- "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
- "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
- "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
- "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
- "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
- "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "1.11.1",
- "@emnapi/runtime": "1.11.1",
- "@napi-rs/wasm-runtime": "^1.1.6"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
- "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
- "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
- "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/pluginutils": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
- "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^2.0.2",
- "picomatch": "^4.0.2"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
- },
- "peerDependenciesMeta": {
- "rollup": {
- "optional": true
- }
- }
- },
- "node_modules/@tybys/wasm-util": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
- "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/node": {
- "version": "25.9.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
- "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": ">=7.24.0 <7.24.7"
- }
- },
- "node_modules/@volar/language-core": {
- "version": "2.4.28",
- "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
- "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/source-map": "2.4.28"
- }
- },
- "node_modules/@volar/source-map": {
- "version": "2.4.28",
- "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
- "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@volar/typescript": {
- "version": "2.4.28",
- "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
- "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@volar/language-core": "2.4.28",
- "path-browserify": "^1.0.1",
- "vscode-uri": "^3.0.8"
- }
- },
- "node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "acorn": "bin/acorn"
- },
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/compare-versions": {
- "version": "6.1.1",
- "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
- "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/confbox": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
- "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/estree-walker": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/exsolve": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
- "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/kolorist": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
- "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lightningcss": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
- "dev": true,
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.32.0",
- "lightningcss-darwin-arm64": "1.32.0",
- "lightningcss-darwin-x64": "1.32.0",
- "lightningcss-freebsd-x64": "1.32.0",
- "lightningcss-linux-arm-gnueabihf": "1.32.0",
- "lightningcss-linux-arm64-gnu": "1.32.0",
- "lightningcss-linux-arm64-musl": "1.32.0",
- "lightningcss-linux-x64-gnu": "1.32.0",
- "lightningcss-linux-x64-musl": "1.32.0",
- "lightningcss-win32-arm64-msvc": "1.32.0",
- "lightningcss-win32-x64-msvc": "1.32.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/local-pkg": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
- "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mlly": "^1.7.4",
- "pkg-types": "^2.3.0",
- "quansync": "^0.2.11"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/mlly": {
- "version": "1.8.2",
- "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
- "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "acorn": "^8.16.0",
- "pathe": "^2.0.3",
- "pkg-types": "^1.3.1",
- "ufo": "^1.6.3"
- }
- },
- "node_modules/mlly/node_modules/confbox": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
- "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/mlly/node_modules/pkg-types": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
- "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.1.8",
- "mlly": "^1.7.4",
- "pathe": "^2.0.1"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.16",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
- "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/path-browserify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
- "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/pathe": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
- "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/pkg-types": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
- "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "confbox": "^0.2.4",
- "exsolve": "^1.0.8",
- "pathe": "^2.0.3"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.17",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz",
- "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.12",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/quansync": {
- "version": "0.2.11",
- "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
- "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
- "dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/antfu"
- },
- {
- "type": "individual",
- "url": "https://github.com/sponsors/sxzz"
- }
- ],
- "license": "MIT"
- },
- "node_modules/rolldown": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
- "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@oxc-project/types": "=0.139.0",
- "@rolldown/pluginutils": "^1.0.0"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.1.5",
- "@rolldown/binding-darwin-arm64": "1.1.5",
- "@rolldown/binding-darwin-x64": "1.1.5",
- "@rolldown/binding-freebsd-x64": "1.1.5",
- "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
- "@rolldown/binding-linux-arm64-gnu": "1.1.5",
- "@rolldown/binding-linux-arm64-musl": "1.1.5",
- "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
- "@rolldown/binding-linux-s390x-gnu": "1.1.5",
- "@rolldown/binding-linux-x64-gnu": "1.1.5",
- "@rolldown/binding-linux-x64-musl": "1.1.5",
- "@rolldown/binding-openharmony-arm64": "1.1.5",
- "@rolldown/binding-wasm32-wasi": "1.1.5",
- "@rolldown/binding-win32-arm64-msvc": "1.1.5",
- "@rolldown/binding-win32-x64-msvc": "1.1.5"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
- },
- "node_modules/typescript": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
- "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/ufo": {
- "version": "1.6.4",
- "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
- "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/undici-types": {
- "version": "7.24.6",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
- "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/unplugin": {
- "version": "2.3.11",
- "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
- "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.5",
- "acorn": "^8.15.0",
- "picomatch": "^4.0.3",
- "webpack-virtual-modules": "^0.6.2"
- },
- "engines": {
- "node": ">=18.12.0"
- }
- },
- "node_modules/unplugin-dts": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.0.1.tgz",
- "integrity": "sha512-EdJZxdWP4Tm/xhe58/zAge3Tu0OKDYygm8rucRrcCZ4XzgA31jexUKhaJuEMddOPBDs9ONnq6vwigbjeBqkfuw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rollup/pluginutils": "^5.1.4",
- "@volar/typescript": "^2.4.26",
- "compare-versions": "^6.1.1",
- "debug": "^4.4.0",
- "kolorist": "^1.8.0",
- "local-pkg": "^1.1.1",
- "magic-string": "^0.30.17",
- "unplugin": "^2.3.2"
- },
- "peerDependencies": {
- "@microsoft/api-extractor": ">=7",
- "@rspack/core": "^1",
- "@vue/language-core": "~3.1.5",
- "esbuild": "*",
- "rolldown": "*",
- "rollup": ">=3",
- "typescript": ">=4",
- "vite": ">=3",
- "webpack": "^4 || ^5"
- },
- "peerDependenciesMeta": {
- "@microsoft/api-extractor": {
- "optional": true
- },
- "@rspack/core": {
- "optional": true
- },
- "@vue/language-core": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "rolldown": {
- "optional": true
- },
- "rollup": {
- "optional": true
- },
- "vite": {
- "optional": true
- },
- "webpack": {
- "optional": true
- }
- }
- },
- "node_modules/vite": {
- "version": "8.1.4",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
- "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "lightningcss": "^1.32.0",
- "picomatch": "^4.0.5",
- "postcss": "^8.5.16",
- "rolldown": "~1.1.4",
- "tinyglobby": "^0.2.17"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.3.0",
- "esbuild": "^0.27.0 || ^0.28.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "@vitejs/devtools": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vscode-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
- "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/webpack-virtual-modules": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
- "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
- "dev": true,
- "license": "MIT"
- }
- }
-}
diff --git a/package.json b/package.json
index 1abaec2..11f37d9 100644
--- a/package.json
+++ b/package.json
@@ -1,42 +1,9 @@
{
"name": "jsontext",
"version": "0.2.3",
+ "description": "A state machine for incremental JSON processing with high-level APIs.",
+ "keywords": ["json", "streaming", "parser", "encoder"],
"license": "MIT",
- "description": "A state machine for incremental JSON processing.",
- "keywords": [
- "json",
- "json-path",
- "json-pointer",
- "jsonl",
- "jsonlines",
- "parser",
- "encoder",
- "decoder",
- "stream",
- "streaming",
- "web-streams",
- "ndjson",
- "state-machine"
- ],
- "type": "module",
- "types": "./dist/index.d.ts",
- "imports": {
- "#src/*": "./src/*"
- },
- "exports": {
- ".": {
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js"
- }
- },
- "files": [
- "dist/**/*",
- "!dist/**/*.map"
- ],
- "author": {
- "name": "lcweden",
- "url": "https://github.com/lcweden"
- },
"homepage": "https://github.com/lcweden/jsontext",
"repository": {
"type": "git",
@@ -45,23 +12,30 @@
"bugs": {
"url": "https://github.com/lcweden/jsontext/issues"
},
+ "author": {
+ "name": "lcweden",
+ "url": "https://github.com/lcweden"
+ },
+ "type": "module",
+ "types": "./dist/index.d.ts",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "imports": {
+ "#src/*": ["./dist/*.js", "./dist/*.ts", "./dist/*.d.ts"]
+ },
+ "exports": {
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
+ },
+ "files": ["dist"],
"scripts": {
- "build": "vite build",
- "bench": "deno task bench",
- "dev": "vite",
- "format": "deno task format",
- "lint": "deno task lint",
- "preview": "vite preview",
"test": "deno task test"
},
- "sideEffects": false,
+ "directories": {
+ "doc": "docs",
+ "test": "tests"
+ },
"engines": {
"node": ">=18"
},
- "devDependencies": {
- "@types/node": "^25.9.1",
- "typescript": "~6.0.2",
- "unplugin-dts": "^1.0.1",
- "vite": "^8.0.9"
- }
+ "sideEffects": false
}
diff --git a/public/example.com.har b/public/example.com.har
deleted file mode 100644
index 8a23347..0000000
--- a/public/example.com.har
+++ /dev/null
@@ -1,192 +0,0 @@
-{
- "log": {
- "version": "1.2",
- "creator": {
- "name": "WebInspector",
- "version": "537.36"
- },
- "pages": [
- {
- "startedDateTime": "2026-05-07T13:34:01.622Z",
- "id": "page_1",
- "title": "https://example.com/",
- "pageTimings": {
- "onContentLoad": 41.40600000391714,
- "onLoad": 41.72400000970811
- }
- }
- ],
- "entries": [
- {
- "_initiator": {
- "type": "other"
- },
- "_priority": "VeryHigh",
- "_resourceType": "document",
- "cache": {},
- "connection": "443",
- "request": {
- "method": "GET",
- "url": "https://example.com/",
- "httpVersion": "http/2.0",
- "headers": [
- {
- "name": ":authority",
- "value": "example.com"
- },
- {
- "name": ":method",
- "value": "GET"
- },
- {
- "name": ":path",
- "value": "/"
- },
- {
- "name": ":scheme",
- "value": "https"
- },
- {
- "name": "accept",
- "value": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
- },
- {
- "name": "accept-encoding",
- "value": "gzip, deflate, br, zstd"
- },
- {
- "name": "accept-language",
- "value": "zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6"
- },
- {
- "name": "cache-control",
- "value": "max-age=0"
- },
- {
- "name": "priority",
- "value": "u=0, i"
- },
- {
- "name": "referer",
- "value": "https://www.google.com/"
- },
- {
- "name": "sec-ch-ua",
- "value": "\"Google Chrome\";v=\"147\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"147\""
- },
- {
- "name": "sec-ch-ua-mobile",
- "value": "?0"
- },
- {
- "name": "sec-ch-ua-platform",
- "value": "\"macOS\""
- },
- {
- "name": "sec-fetch-dest",
- "value": "document"
- },
- {
- "name": "sec-fetch-mode",
- "value": "navigate"
- },
- {
- "name": "sec-fetch-site",
- "value": "cross-site"
- },
- {
- "name": "sec-fetch-user",
- "value": "?1"
- },
- {
- "name": "upgrade-insecure-requests",
- "value": "1"
- },
- {
- "name": "user-agent",
- "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
- }
- ],
- "queryString": [],
- "cookies": [],
- "headersSize": -1,
- "bodySize": 0
- },
- "response": {
- "status": 200,
- "statusText": "",
- "httpVersion": "http/2.0",
- "headers": [
- {
- "name": "age",
- "value": "7877"
- },
- {
- "name": "allow",
- "value": "GET, HEAD"
- },
- {
- "name": "cf-cache-status",
- "value": "HIT"
- },
- {
- "name": "cf-ray",
- "value": "9f80998c3aa8d8ec-HKG"
- },
- {
- "name": "content-encoding",
- "value": "br"
- },
- {
- "name": "content-type",
- "value": "text/html"
- },
- {
- "name": "date",
- "value": "Thu, 07 May 2026 13:34:01 GMT"
- },
- {
- "name": "last-modified",
- "value": "Wed, 06 May 2026 14:17:14 GMT"
- },
- {
- "name": "server",
- "value": "cloudflare"
- }
- ],
- "cookies": [],
- "content": {
- "size": 528,
- "mimeType": "text/html",
- "text": "Example Domain Example Domain This domain is for use in documentation examples without needing permission. Avoid use in operations.
Learn more
\n"
- },
- "redirectURL": "",
- "headersSize": -1,
- "bodySize": -1,
- "_transferSize": 411,
- "_error": null,
- "_fetchedViaServiceWorker": false
- },
- "serverIPAddress": "172.66.147.243",
- "startedDateTime": "2026-05-07T13:34:01.621Z",
- "time": 33.278999995673075,
- "timings": {
- "blocked": 1.7909999983943998,
- "dns": -1,
- "ssl": -1,
- "connect": -1,
- "send": 0.14900000000000002,
- "wait": 30.936000013643877,
- "receive": 0.4029999836347997,
- "_blocked_queueing": 0.7259999983943999,
- "_workerStart": -1,
- "_workerReady": -1,
- "_workerFetchStart": -1,
- "_workerRespondWithSettled": -1
- },
- "_connectionId": "2446955",
- "pageref": "page_1"
- }
- ]
- }
-}
diff --git a/src/api/decoder.ts b/src/api/decoder.ts
index 9f8a9e2..7a94d38 100644
--- a/src/api/decoder.ts
+++ b/src/api/decoder.ts
@@ -1,161 +1,129 @@
+import type { Kind } from "#src/api/kind";
+import Token from "#src/api/token";
+import Value from "#src/api/value";
import { DEFAULT_DECODER_OPTIONS } from "#src/common/constants";
-import Decoder from "#src/modules/decoder";
-import type Token from "#src/modules/token";
-import type Value from "#src/modules/value";
-import type { Kind } from "#src/types/kind";
-import type { DecoderOptions } from "#src/types/options";
+import Parser from "#src/modules/parser";
/**
- * Options for {@link JSONTextDecoder}.
+ * Options for {@link JSONTextDecoder} and {@link JSONTextDecoderStream}.
*
* @public
*/
-type JSONTextDecoderOptions = DecoderOptions;
+type JSONTextDecoderOptions = {
+ /** Allow duplicate object key names. Defaults to `false`. */
+ allowDuplicateNames?: boolean;
+ /** Allow invalid UTF-8 byte sequences. Defaults to `false`. */
+ allowInvalidUTF8?: boolean;
+};
/**
- * Low-level, stateful JSON decoder that processes input incrementally.
- *
- * Feed byte chunks via {@link push} then consume tokens with
- * {@link readToken} / {@link readValue} / {@link skipValue}.
- * Call {@link end} when the stream is exhausted to flush any buffered state.
+ * Low-level, stateful JSON decoder that processes bytes incrementally.
*
* @public
*/
class JSONTextDecoder {
- #decoder: Decoder;
+ #parser: Parser;
- /**
- * @param bytes - Initial bytes to pre-load into the decoder.
- * @param options - Decoding options.
- */
- constructor(bytes: Uint8Array = new Uint8Array(), options?: JSONTextDecoderOptions) {
- this.#decoder = new Decoder(bytes, { ...DEFAULT_DECODER_OPTIONS, ...options });
+ /** Creates a decoder with the supplied options. */
+ constructor(options?: JSONTextDecoderOptions) {
+ this.#parser = new Parser({ ...DEFAULT_DECODER_OPTIONS, ...options });
}
- /**
- * Asserts that the input has been fully consumed.
- *
- * @throws {SyntaxError} If the decoder is still inside a nested structure,
- * or if non-whitespace characters remain after the value.
- */
- checkEOF(): void {
- this.#decoder.checkEOF();
+ /** The current structural nesting depth. */
+ get depth(): number {
+ return this.#parser.depth;
}
- /**
- * Returns the current nesting depth of the structural state.
- *
- * @returns The current nesting depth — `1` at the top level, incremented by each open object or array.
- */
- depth(): number {
- return this.#decoder.depth();
+ /** The absolute byte offset of the current input position. */
+ get inputOffset(): number {
+ return this.#parser.inputOffset;
}
- /**
- * Signals that no more input will be pushed.
- *
- * After calling this, number tokens no longer require a trailing byte to
- * confirm their end.
- */
- end(): void {
- this.#decoder.end();
+ /** The unconsumed bytes currently retained by the decoder. */
+ get unreadBytes(): Uint8Array {
+ return this.#parser.unreadBytes;
}
- /**
- * The byte offset of the end of the last consumed token within the total
- * input seen so far.
- *
- * @returns The global byte offset from the start of the stream.
- */
- inputOffset(): number {
- return this.#decoder.inputOffset();
+ /** Verifies that the input is complete and contains no trailing characters. */
+ checkEOF(): void {
+ this.#parser.checkEOF();
}
- /**
- * Appends a chunk of bytes to the internal buffer.
- *
- * @param bytes - The next chunk of JSON-encoded bytes.
- */
+ /** Marks the input as complete so the decoder can validate its final token. */
+ end(): void {
+ this.#parser.close();
+ }
+
+ /** Appends a chunk of JSON bytes to the decoder. */
push(bytes: Uint8Array): void {
- this.#decoder.push(bytes);
+ this.#parser.push(bytes);
}
/**
- * Returns the {@link Kind} of the next token without consuming it,
- * or `undefined` if no complete token is available yet.
+ * Peeks at the next token kind without consuming it.
*
- * @returns The {@link Kind} of the next token, or `undefined` if no complete token is available yet.
- * @throws {SyntacticError} If an invalid character or unexpected delimiter is encountered.
+ * @returns The next {@link Kind}, or `undefined` when more input is needed.
+ * @throws {SyntaxError} If the next bytes contain invalid JSON syntax.
*/
peekKind(): Kind | undefined {
- return this.#decoder.peekKind();
+ return this.#parser.peekKind();
}
- /**
- * Resets the decoder to its initial state, discarding all buffered input
- * and state.
- */
+ /** Resets the decoder, clearing buffered input and structural state. */
reset(): void {
- this.#decoder.reset();
+ this.#parser.reset();
}
/**
- * Reads and returns the next {@link Token} from the buffer, or `undefined`
- * if no complete token is available yet.
+ * Reads the next JSON token.
*
- * @returns The next token, or `undefined` if no complete token is available yet.
- * @throws {SyntacticError} If invalid JSON syntax is encountered.
+ * @returns A {@link Token}, or `undefined` when more input is needed.
+ * @throws {SyntaxError} If the next token is invalid or violates the current structure.
*/
readToken(): Token | undefined {
- return this.#decoder.readToken();
+ const span = this.#parser.readToken();
+
+ if (span === undefined) {
+ return undefined;
+ }
+
+ return new Token(span);
}
/**
- * Reads and returns the next complete {@link Value} from the buffer, or
- * `undefined` if there is not yet enough input to form a complete value.
+ * Reads the next complete JSON value.
*
- * @returns The next value, or `undefined` if no complete value is available yet.
- * @throws {SyntacticError} If invalid JSON syntax is encountered.
+ * @returns A {@link Value}, or `undefined` when more input is needed.
+ * @throws {SyntaxError} If the next value is invalid or violates the current structure.
*/
readValue(): Value | undefined {
- return this.#decoder.readValue();
+ const span = this.#parser.readValue();
+
+ if (span === undefined) {
+ return undefined;
+ }
+
+ return new Value(span);
}
/**
- * Skips over the next complete value without returning it.
+ * Skips the next complete JSON value without allocating a {@link Value}.
*
- * @returns `true` if a value was skipped, `false` if no complete value was available yet.
- * @throws {SyntacticError} If invalid JSON syntax is encountered.
+ * @returns `true` when a value was skipped, or `false` when more input is needed.
+ * @throws {SyntaxError} If the next value is invalid or violates the current structure.
*/
skipValue(): boolean {
- return this.#decoder.skipValue();
+ return this.#parser.skipValue();
}
/**
- * Returns a JSON Pointer string describing a position in the current
- * nesting context.
+ * Returns a JSON Pointer for a position relative to the current decoder state.
*
- * | `where` | Meaning |
- * |---------|----------------------------------------------------------|
- * | `1` | The position of the **next** value to be read (default). |
- * | `0` | The position of the **current** container. |
- * | `-1` | The position of the **previously** read value. |
- *
- * @param where - Which position to return. Defaults to `1`.
- * @returns A JSON Pointer string, e.g. `"/foo/0"`.
+ * @param where Relative position: `-1` previous, `0` current, or `1` next.
+ * @returns A JSON Pointer string for the selected position.
*/
stackPointer(where: 0 | 1 | -1 = 1): string {
- return this.#decoder.stackPointer(where).toString();
- }
-
- /**
- * Returns a view of the bytes in the internal buffer that have not yet been
- * consumed.
- *
- * @returns A `Uint8Array` view of the unread bytes in the internal buffer.
- */
- unreadBytes(): Uint8Array {
- return this.#decoder.unreadBytes();
+ return this.#parser.stackPointer(where).toString();
}
}
diff --git a/src/api/encoder.ts b/src/api/encoder.ts
index f260c4b..ca9ebb1 100644
--- a/src/api/encoder.ts
+++ b/src/api/encoder.ts
@@ -1,61 +1,70 @@
+import type Token from "#src/api/token";
+import type Value from "#src/api/value";
import { DEFAULT_ENCODER_OPTIONS } from "#src/common/constants";
-import Encoder from "#src/modules/encoder";
-import type Token from "#src/modules/token";
-import type Value from "#src/modules/value";
-import type { EncoderOptions } from "#src/types/options";
+import Serializer from "#src/modules/serializer";
/**
- * Options for {@link JSONTextEncoder}.
+ * Options for {@link JSONTextEncoder} and {@link JSONTextEncoderStream}.
*
* @public
*/
-type JSONTextEncoderOptions = EncoderOptions;
+type JSONTextEncoderOptions = {
+ /** Allow duplicate object key names. Defaults to `false`. */
+ allowDuplicateNames?: boolean;
+ /** Allow invalid UTF-8 byte sequences. Defaults to `false`. */
+ allowInvalidUTF8?: boolean;
+ /** Normalize raw number tokens. Defaults to `false`. */
+ canonicalizeRawNumbers?: boolean;
+ /** Escape `<`, `>`, and `&` for HTML embedding. Defaults to `false`. */
+ escapeForHTML?: boolean;
+ /** Escape JavaScript line and paragraph separators. Defaults to `false`. */
+ escapeForJS?: boolean;
+ /** Indentation string used when `multiline` is enabled. Defaults to a tab. */
+ indent?: string;
+ /** Prefix added before each indented line. Defaults to an empty string. */
+ indentPrefix?: string;
+ /** Emit each value on its own line. Defaults to `true`. */
+ multiline?: boolean;
+ /** Emit a space after object `:` separators. Defaults to `true`. */
+ spaceAfterColon?: boolean;
+ /** Emit a space after array and object `,` separators. Defaults to `false`. */
+ spaceAfterComma?: boolean;
+};
/**
* Low-level, stateful JSON encoder that produces bytes incrementally.
*
* Write tokens or values via {@link writeToken} / {@link writeValue}, then
- * retrieve the accumulated output with {@link bytes}. Call {@link reset} to
+ * retrieve the accumulated output with {@link takeBytes}. Call {@link reset} to
* start a new document without creating a new instance.
*
* @public
*/
class JSONTextEncoder {
- #encoder: Encoder;
+ #serializer: Serializer;
/**
- * @param options - Encoding options.
+ * Creates a new JSONTextEncoder instance with the given options.
+ *
+ * @param options Encoding options.
*/
constructor(options?: JSONTextEncoderOptions) {
- this.#encoder = new Encoder({ ...DEFAULT_ENCODER_OPTIONS, ...options });
+ this.#serializer = new Serializer({ ...DEFAULT_ENCODER_OPTIONS, ...options });
}
- /**
- * The current nesting depth — `1` at the top level, incremented by each
- * open object or array.
- *
- * @returns The current nesting depth — `1` at the top level, incremented by each open object or array.
- */
- depth(): number {
- return this.#encoder.depth();
+ /** The current structural nesting depth. */
+ get depth(): number {
+ return this.#serializer.depth;
}
- /**
- * The byte offset of the end of the last token written, equal to the total
- * number of bytes produced so far.
- *
- * @returns The byte offset of the end of the last token written.
- */
- outputOffset(): number {
- return this.#encoder.outputOffset();
+ /** The byte offset of the end of the last token written. */
+ get outputOffset(): number {
+ return this.#serializer.outputOffset;
}
- /**
- * Resets the encoder to its initial state, clearing the output buffer and
- * all structural state.
- */
+ /** Resets the encoder, clearing its output buffer and structural state. */
reset(): void {
- this.#encoder.reset();
+ this.#serializer.reset();
}
/**
@@ -68,11 +77,11 @@ class JSONTextEncoder {
* | `0` | The position of the **current** container. |
* | `-1` | The position of the **previously** written value. |
*
- * @param where - Which position to return. Defaults to `1`.
+ * @param where Relative position: `-1` previous, `0` current, or `1` next. Defaults to `1`.
* @returns A JSON Pointer string, e.g. `"/foo/0"`.
*/
stackPointer(where: 0 | 1 | -1 = 1): string {
- return this.#encoder.stackPointer(where).toString();
+ return this.#serializer.stackPointer(where).toString();
}
/**
@@ -83,29 +92,28 @@ class JSONTextEncoder {
* @returns A copy of the bytes written since the last `takeBytes` call.
*/
takeBytes(): Uint8Array {
- return this.#encoder.takeBytes();
+ return this.#serializer.takeBytes();
}
/**
* Encodes a single {@link Token} and appends its bytes to the output buffer.
*
- * @param token - The token to encode.
- * @throws {SyntacticError} If the token is not valid at the current position.
+ * @param token The token to encode.
+ * @throws {SyntaxError} If the token is not valid at the current position.
*/
writeToken(token: Token): void {
- this.#encoder.writeToken(token);
+ this.#serializer.writeToken(token.kind, token.bytes);
}
/**
* Encodes a complete {@link Value} and appends its bytes to the output
* buffer.
*
- * @param value - The value to encode.
- * @throws {SyntacticError} If the value is not valid at the current
- * position.
+ * @param value The value to encode.
+ * @throws {SyntaxError} If the value is not valid at the current position.
*/
writeValue(value: Value): void {
- this.#encoder.writeValue(value);
+ this.#serializer.writeValue(value.kind, value.bytes);
}
}
diff --git a/src/api/kind.ts b/src/api/kind.ts
new file mode 100644
index 0000000..21d6a2d
--- /dev/null
+++ b/src/api/kind.ts
@@ -0,0 +1,25 @@
+import { KIND } from "#src/common/constants";
+
+/**
+ * String discriminants identifying the kind of a JSON token.
+ *
+ * @public
+ */
+export const Kind: {
+ readonly NULL: "null";
+ readonly FALSE: "false";
+ readonly TRUE: "true";
+ readonly STRING: "string";
+ readonly NUMBER: "number";
+ readonly OBJECT_BEGIN: "{";
+ readonly OBJECT_END: "}";
+ readonly ARRAY_BEGIN: "[";
+ readonly ARRAY_END: "]";
+} = KIND;
+
+/**
+ * A union of the string values used to identify JSON token kinds.
+ *
+ * @public
+ */
+export type Kind = typeof Kind[keyof typeof Kind];
diff --git a/src/modules/token.ts b/src/api/token.ts
similarity index 86%
rename from src/modules/token.ts
rename to src/api/token.ts
index ad830d1..567c354 100644
--- a/src/modules/token.ts
+++ b/src/api/token.ts
@@ -1,5 +1,4 @@
-import { KIND } from "#src/common/constants";
-import type { Kind } from "#src/types/kind";
+import { Kind } from "#src/api/kind";
import { normalize } from "#src/utils/kind";
import { decodeText, encodeText } from "#src/utils/text";
@@ -166,11 +165,11 @@ class Token {
*/
isScalar(): boolean {
return (
- this.#kind === KIND.STRING ||
- this.#kind === KIND.NUMBER ||
- this.#kind === KIND.TRUE ||
- this.#kind === KIND.FALSE ||
- this.#kind === KIND.NULL
+ this.#kind === Kind.STRING ||
+ this.#kind === Kind.NUMBER ||
+ this.#kind === Kind.TRUE ||
+ this.#kind === Kind.FALSE ||
+ this.#kind === Kind.NULL
);
}
@@ -181,10 +180,10 @@ class Token {
*/
isStructural(): boolean {
return (
- this.kind === KIND.OBJECT_BEGIN ||
- this.kind === KIND.OBJECT_END ||
- this.kind === KIND.ARRAY_BEGIN ||
- this.kind === KIND.ARRAY_END
+ this.kind === Kind.OBJECT_BEGIN ||
+ this.kind === Kind.OBJECT_END ||
+ this.kind === Kind.ARRAY_BEGIN ||
+ this.kind === Kind.ARRAY_END
);
}
@@ -192,10 +191,10 @@ class Token {
* Decodes this token as a JavaScript string.
*
* @returns The unescaped string value.
- * @throws {TypeError} If this token is not of kind {@link KIND.STRING}.
+ * @throws {TypeError} If this token is not of kind {@link Kind.STRING}.
*/
asString(): string {
- if (this.#kind !== KIND.STRING) {
+ if (this.#kind !== Kind.STRING) {
throw new TypeError(`invalid JSON token kind: ${this.#kind}`);
}
@@ -209,10 +208,10 @@ class Token {
* Decodes this token as a JavaScript number.
*
* @returns The numeric value.
- * @throws {TypeError} If this token is not of kind {@link KIND.NUMBER}.
+ * @throws {TypeError} If this token is not of kind {@link Kind.NUMBER}.
*/
asNumber(): number {
- if (this.#kind !== KIND.NUMBER) {
+ if (this.#kind !== Kind.NUMBER) {
throw new TypeError(`invalid JSON token kind: ${this.#kind}`);
}
@@ -226,14 +225,14 @@ class Token {
* Decodes this token as a JavaScript boolean.
*
* @returns `true` for `true` tokens, `false` for `false` tokens.
- * @throws {TypeError} If this token is not of kind {@link KIND.TRUE} or {@link KIND.FALSE}.
+ * @throws {TypeError} If this token is not of kind {@link Kind.TRUE} or {@link Kind.FALSE}.
*/
asBoolean(): boolean {
- if (this.#kind === KIND.TRUE) {
+ if (this.#kind === Kind.TRUE) {
return true;
}
- if (this.#kind === KIND.FALSE) {
+ if (this.#kind === Kind.FALSE) {
return false;
}
@@ -244,10 +243,10 @@ class Token {
* Decodes this token as `null`.
*
* @returns `null`.
- * @throws {TypeError} If this token is not of kind {@link KIND.NULL}.
+ * @throws {TypeError} If this token is not of kind {@link Kind.NULL}.
*/
asNull(): null {
- if (this.#kind !== KIND.NULL) {
+ if (this.#kind !== Kind.NULL) {
throw new TypeError(`invalid JSON token kind: ${this.#kind}`);
}
diff --git a/src/modules/value.ts b/src/api/value.ts
similarity index 78%
rename from src/modules/value.ts
rename to src/api/value.ts
index 6791751..5aae758 100644
--- a/src/modules/value.ts
+++ b/src/api/value.ts
@@ -1,7 +1,7 @@
+import { Kind } from "#src/api/kind";
+import Token from "#src/api/token";
import { ASCII, DEFAULT_DECODER_OPTIONS, KIND } from "#src/common/constants";
-import Decoder from "#src/modules/decoder";
-import type Token from "#src/modules/token";
-import type { Kind } from "#src/types/kind";
+import Parser from "#src/modules/parser";
import { normalize } from "#src/utils/kind";
import { decodeText, encodeText } from "#src/utils/text";
import { compareUTF16, consumeWhitespace } from "#src/utils/wire";
@@ -19,14 +19,14 @@ import { compareUTF16, consumeWhitespace } from "#src/utils/wire";
class Value {
#bytes: Uint8Array;
#kind: Kind;
- #pointer?: string;
+ #pointer: string | undefined;
/**
* Creates a `Value` from raw UTF-8 bytes.
* Leading whitespace is accepted and preserved in {@link bytes}.
*
- * @param bytes - Raw UTF-8 bytes of a complete JSON value.
- * @param pointer - Optional JSON Pointer (RFC 6901) indicating where this valuewas located in the source document. Typically set by {@link JSONTextSelectorStream}.
+ * @param bytes Raw UTF-8 bytes of a complete JSON value.
+ * @param pointer Optional JSON Pointer indicating where this value was located in the source document.
* @throws {RangeError} If `bytes` is empty.
* @throws {SyntaxError} If no valid JSON token is found after skipping leading whitespace.
*/
@@ -92,7 +92,7 @@ class Value {
* and normalizes numbers. The result is deterministic and idempotent.
*
* @returns A new `Value` in canonical form.
- * @throws {SyntacticError} If the bytes do not represent valid JSON.
+ * @throws {SyntaxError} If the bytes do not represent valid JSON.
* @example
* ```javascript
* Value.from({ b: 2, a: 1 }).canonicalize().text() // '{"a":1,"b":2}'
@@ -100,10 +100,14 @@ class Value {
* ```
*/
canonicalize(): Value {
- const decoder = new Decoder(this.#bytes, { allowDuplicateNames: true });
- decoder.end();
+ const allowInvalidUTF8 = false;
+ const allowDuplicateNames = true;
+ const parser = new Parser({ allowInvalidUTF8, allowDuplicateNames });
+
+ parser.push(this.#bytes);
+ parser.close();
- return new Value(this.#processValue(decoder));
+ return new Value(this.#processValue(parser));
}
/**
@@ -123,17 +127,20 @@ class Value {
*/
isValid(): boolean {
try {
- const decoder = new Decoder(this.#bytes, {});
+ const allowInvalidUTF8 = false;
+ const allowDuplicateNames = false;
+ const parser = new Parser({ allowInvalidUTF8, allowDuplicateNames });
- decoder.end();
+ parser.push(this.#bytes);
+ parser.close();
- const result = decoder.readValue();
+ const result = parser.readValue();
if (result === undefined) {
return false;
}
- decoder.checkEOF();
+ parser.checkEOF();
return true;
} catch {
@@ -169,19 +176,21 @@ class Value {
* tokens including structural delimiters, keys, and nested values.
*
* @yields {Token} Tokens in document order.
- * @throws {SyntacticError} If the bytes do not represent valid JSON.
+ * @throws {SyntaxError} If the bytes do not represent valid JSON.
*/
*tokens(): Generator {
- const decoder = new Decoder(this.#bytes, DEFAULT_DECODER_OPTIONS);
+ const parser = new Parser(DEFAULT_DECODER_OPTIONS);
+
+ parser.push(this.#bytes);
while (true) {
- const token = decoder.readToken();
+ const bytes = parser.readToken();
- if (token === undefined) {
+ if (bytes === undefined) {
break;
}
- yield token;
+ yield new Token(bytes);
}
}
@@ -193,29 +202,27 @@ class Value {
* @param decoder The decoder positioned at the start of the value.
* @returns The canonicalized UTF-8 bytes of the value.
*/
- #processValue(decoder: Decoder): Uint8Array {
- const kind = decoder.peekKind();
+ #processValue(parser: Parser): Uint8Array {
+ const kind = parser.peekKind();
if (kind === KIND.OBJECT_BEGIN) {
- return this.#processObject(decoder);
+ return this.#processObject(parser);
}
if (kind === KIND.ARRAY_BEGIN) {
- return this.#processArray(decoder);
+ return this.#processArray(parser);
}
if (kind === KIND.NUMBER) {
- const token = decoder.readToken()!;
- const decoded = decodeText(token.bytes, true);
+ const bytes = parser.readToken()!;
+ const decoded = decodeText(bytes, true);
const parsed = JSON.parse(decoded);
const encoded = encodeText(String(parsed));
return encoded;
}
- const token = decoder.readToken()!;
-
- return token.bytes;
+ return parser.readToken()!;
}
/**
@@ -225,24 +232,24 @@ class Value {
* @param decoder The decoder positioned at the opening `{`.
* @returns The canonicalized UTF-8 bytes of the object.
*/
- #processObject(decoder: Decoder): Uint8Array {
- decoder.readToken();
+ #processObject(parser: Parser): Uint8Array {
+ parser.readToken();
const members: { name: string; key: Uint8Array; value: Uint8Array }[] = [];
- while (decoder.peekKind() !== KIND.OBJECT_END) {
- const token = decoder.readToken();
+ while (parser.peekKind() !== KIND.OBJECT_END) {
+ const bytes = parser.readToken();
- if (token) {
- const decoded = decodeText(token.bytes, true);
+ if (bytes) {
+ const decoded = decodeText(bytes, true);
const parsed = JSON.parse(decoded);
- const value = this.#processValue(decoder);
+ const value = this.#processValue(parser);
- members.push({ name: parsed, key: token.bytes, value });
+ members.push({ name: parsed, key: bytes, value });
}
}
- decoder.readToken();
+ parser.readToken();
members.sort((a, b) => compareUTF16(a.name, b.name));
@@ -285,16 +292,16 @@ class Value {
* @param decoder The decoder positioned at the opening `[`.
* @returns The UTF-8 bytes of the re-serialized array.
*/
- #processArray(decoder: Decoder): Uint8Array {
- decoder.readToken();
+ #processArray(parser: Parser): Uint8Array {
+ parser.readToken();
const items: Uint8Array[] = [];
- while (decoder.peekKind() !== KIND.ARRAY_END) {
- items.push(this.#processValue(decoder));
+ while (parser.peekKind() !== KIND.ARRAY_END) {
+ items.push(this.#processValue(parser));
}
- decoder.readToken();
+ parser.readToken();
const OPEN = new Uint8Array([ASCII.OPENING_BRACKET]);
const CLOSE = new Uint8Array([ASCII.CLOSING_BRACKET]);
diff --git a/src/common/constants.ts b/src/common/constants.ts
index a090798..394e876 100644
--- a/src/common/constants.ts
+++ b/src/common/constants.ts
@@ -1,5 +1,5 @@
-/** ASCII byte values used for JSON parsing and encoding. */
-const ASCII = {
+/** ASCII byte values used to recognize and emit JSON syntax. */
+export const ASCII = {
TAB: 0x09,
LINE_FEED: 0x0A,
CARRIAGE_RETURN: 0x0D,
@@ -52,8 +52,8 @@ const ASCII = {
DELETE: 0x7F,
} as const;
-/** Unicode escape sequences for safe HTML and JavaScript embedding. */
-const UNICODE = {
+/** Unicode escape sequences used when embedding JSON in HTML or JavaScript. */
+export const UNICODE = {
OPEN_ANGLED_BRACKET: "\\u003c",
CLOSE_ANGLED_BRACKET: "\\u003e",
AMPERSAND: "\\u0026",
@@ -61,8 +61,8 @@ const UNICODE = {
PARAGRAPH_SEPARATOR: "\\u2029",
} as const;
-/** String discriminants identifying the structural role of a JSON token. */
-const KIND = {
+/** String discriminants identifying the kind of a JSON token. */
+export const KIND = {
NULL: "null",
FALSE: "false",
TRUE: "true",
@@ -74,17 +74,19 @@ const KIND = {
ARRAY_END: "]",
} as const;
-/** Maximum JSON nesting depth supported by the decoder and encoder. */
-const MAX_NESTING_DEPTH = 10_000;
+/** Maximum JSON nesting depth accepted by the decoder and encoder. */
+export const MAX_NESTING_DEPTH = 10_000;
-/** Default option values for decoding. */
-const DEFAULT_DECODER_OPTIONS = {
+/** Default options applied when constructing a decoder or decoder stream. */
+export const DEFAULT_DECODER_OPTIONS = {
allowDuplicateNames: false,
allowInvalidUTF8: false,
} as const;
-/** Default option values for encoding. */
-const DEFAULT_ENCODER_OPTIONS = {
+/** Default options applied when constructing an encoder or encoder stream. */
+export const DEFAULT_ENCODER_OPTIONS = {
+ allowInvalidUTF8: false,
+ allowDuplicateNames: false,
escapeForHTML: false,
escapeForJS: false,
canonicalizeRawNumbers: false,
@@ -95,34 +97,22 @@ const DEFAULT_ENCODER_OPTIONS = {
indentPrefix: "",
} as const;
-/** JSON Path identifier types. */
-const IDENTIFIER = {
+/** Numeric identifiers used by the JSON Path parser. */
+export const IDENTIFIER = {
ROOT: 0,
CURRENT: 1,
} as const;
-/** JSON Path segment kinds. */
-const SEGMENT = {
+/** Numeric segment kinds used by the JSON Path parser. */
+export const SEGMENT = {
CHILD: 0,
DESCENDANT: 1,
} as const;
-/** JSON Path selector kinds. */
-const SELECTOR = {
+/** Numeric selector kinds used by the JSON Path parser. */
+export const SELECTOR = {
NAME: 0,
WILDCARD: 1,
INDEX: 2,
ARRAY_SLICE: 3,
} as const;
-
-export {
- ASCII,
- DEFAULT_DECODER_OPTIONS,
- DEFAULT_ENCODER_OPTIONS,
- IDENTIFIER,
- KIND,
- MAX_NESTING_DEPTH,
- SEGMENT,
- SELECTOR,
- UNICODE,
-};
diff --git a/src/common/errors.ts b/src/common/errors.ts
index 6e96ec6..f552fe9 100644
--- a/src/common/errors.ts
+++ b/src/common/errors.ts
@@ -6,13 +6,15 @@
* where `within ` is omitted when `pointer` is an empty string.
*/
class SyntacticError extends SyntaxError {
- pointer: string;
- offset: number;
+ #pointer: string;
+ #offset: number;
/**
- * @param message - A human-readable description of the syntax error.
- * @param pointer - JSON Pointer to the location in the document.
- * @param offset - Byte offset at which the error was detected.
+ * Creates a new `SyntacticError` instance.
+ *
+ * @param message A human-readable description of the syntax error.
+ * @param pointer JSON Pointer to the location in the document.
+ * @param offset Byte offset at which the error was detected.
*/
constructor(message: string, pointer: string, offset: number) {
const within = pointer ? ` within ${pointer}` : "";
@@ -21,8 +23,18 @@ class SyntacticError extends SyntaxError {
super(`${message}${within}${at}`);
this.name = "SyntacticError";
- this.pointer = pointer;
- this.offset = offset;
+ this.#pointer = pointer;
+ this.#offset = offset;
+ }
+
+ /** JSON Pointer identifying the location of the syntax error. */
+ get pointer(): string {
+ return this.#pointer;
+ }
+
+ /** Byte offset at which the syntax error was detected. */
+ get offset(): number {
+ return this.#offset;
}
}
diff --git a/src/types/path.ts b/src/common/types.ts
similarity index 75%
rename from src/types/path.ts
rename to src/common/types.ts
index 80fc017..5819291 100644
--- a/src/types/path.ts
+++ b/src/common/types.ts
@@ -1,4 +1,7 @@
-import { IDENTIFIER, SEGMENT, SELECTOR } from "#src/common/constants";
+import { IDENTIFIER, KIND, SEGMENT, SELECTOR } from "#src/common/constants";
+
+/** A union of the string values used to identify JSON token kinds. */
+export type Kind = typeof KIND[keyof typeof KIND];
export type NameSelector = { type: typeof SELECTOR.NAME; name: string };
diff --git a/src/index.ts b/src/index.ts
index 842ff66..b1396f9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,21 +1,17 @@
export type { JSONTextDecoderOptions } from "#src/api/decoder";
export type { JSONTextEncoderOptions } from "#src/api/encoder";
-export type { JSONTextDecoderStreamOptions } from "#src/libs/stream-decoder";
-export type { JSONTextEncoderStreamOptions } from "#src/libs/stream-encoder";
-export type { JSONTextLineStreamOptions } from "#src/libs/stream-line";
-export type { JSONTextSelectorStreamOptions } from "#src/libs/stream-selector";
-
-export type { Kind } from "#src/types/kind";
+export type { JSONTextDecoderStreamOptions } from "#src/lib/stream-decoder";
+export type { JSONTextEncoderStreamOptions } from "#src/lib/stream-encoder";
+export type { JSONTextLineStreamOptions } from "#src/lib/stream-line";
+export type { JSONTextSelectorStreamOptions } from "#src/lib/stream-selector";
export { default as JSONTextDecoder } from "#src/api/decoder";
export { default as JSONTextEncoder } from "#src/api/encoder";
-export { default as Token } from "#src/modules/token";
-export { default as Value } from "#src/modules/value";
-
-export { default as JSONTextDecoderStream } from "#src/libs/stream-decoder";
-export { default as JSONTextEncoderStream } from "#src/libs/stream-encoder";
-export { default as JSONTextLineStream } from "#src/libs/stream-line";
-export { default as JSONTextSelectorStream } from "#src/libs/stream-selector";
+export { Kind } from "#src/api/kind";
+export { default as Token } from "#src/api/token";
+export { default as Value } from "#src/api/value";
-export { KIND } from "#src/common/constants";
-export { SyntacticError } from "#src/common/errors";
+export { default as JSONTextDecoderStream } from "#src/lib/stream-decoder";
+export { default as JSONTextEncoderStream } from "#src/lib/stream-encoder";
+export { default as JSONTextLineStream } from "#src/lib/stream-line";
+export { default as JSONTextSelectorStream } from "#src/lib/stream-selector";
diff --git a/src/libs/stream-decoder.ts b/src/lib/stream-decoder.ts
similarity index 68%
rename from src/libs/stream-decoder.ts
rename to src/lib/stream-decoder.ts
index b9e20f0..ad1c94e 100644
--- a/src/libs/stream-decoder.ts
+++ b/src/lib/stream-decoder.ts
@@ -1,14 +1,14 @@
+import type { JSONTextDecoderOptions } from "#src/api/decoder";
+import Token from "#src/api/token";
import { DEFAULT_DECODER_OPTIONS } from "#src/common/constants";
-import Decoder from "#src/modules/decoder";
-import type Token from "#src/modules/token";
-import type { DecoderOptions } from "#src/types/options";
+import Parser from "#src/modules/parser";
/**
* Options for {@link JSONTextDecoderStream}.
*
* @public
*/
-type JSONTextDecoderStreamOptions = DecoderOptions & {
+type JSONTextDecoderStreamOptions = JSONTextDecoderOptions & {
/** Queuing strategy for the writable side. */
writableStrategy?: QueuingStrategy;
/** Queuing strategy for the readable side. */
@@ -31,10 +31,12 @@ type JSONTextDecoderStreamOptions = DecoderOptions & {
* ```
*/
class JSONTextDecoderStream extends TransformStream {
- #decoder: Decoder;
+ #parser: Parser;
/**
- * @param options - Decoder and queuing strategy options.
+ * Creates a stream that decodes byte chunks into JSON tokens.
+ *
+ * @param options Decoder and queuing strategy options.
*/
constructor(options: JSONTextDecoderStreamOptions = {}) {
const { writableStrategy, readableStrategy, ...rest } = options;
@@ -43,28 +45,26 @@ class JSONTextDecoderStream extends TransformStream {
super(
{
transform: (chunk, controller) => {
- this.#decoder.push(chunk);
+ this.#parser.push(chunk);
this.#drain(controller);
},
flush: (controller) => {
- this.#decoder.end();
+ this.#parser.close();
this.#drain(controller);
- this.#decoder.checkEOF();
+ this.#parser.checkEOF();
},
},
writableStrategy,
readableStrategy,
);
- this.#decoder = new Decoder(new Uint8Array(), decoderOptions);
+ this.#parser = new Parser(decoderOptions);
}
- /**
- * Drains all available tokens from the decoder into the readable side.
- */
+ /** Drains all available tokens from the decoder into the readable side. */
#drain(controller: TransformStreamDefaultController): void {
- for (let token; (token = this.#decoder.readToken()) !== undefined;) {
- controller.enqueue(token);
+ for (let bytes; (bytes = this.#parser.readToken()) !== undefined;) {
+ controller.enqueue(new Token(bytes));
}
}
}
diff --git a/src/libs/stream-encoder.ts b/src/lib/stream-encoder.ts
similarity index 73%
rename from src/libs/stream-encoder.ts
rename to src/lib/stream-encoder.ts
index 8af0012..d1f04b3 100644
--- a/src/libs/stream-encoder.ts
+++ b/src/lib/stream-encoder.ts
@@ -1,14 +1,14 @@
+import type { JSONTextEncoderOptions } from "#src/api/encoder";
+import type Token from "#src/api/token";
import { DEFAULT_ENCODER_OPTIONS } from "#src/common/constants";
-import Encoder from "#src/modules/encoder";
-import type Token from "#src/modules/token";
-import type { EncoderOptions } from "#src/types/options";
+import Serializer from "#src/modules/serializer";
/**
* Options for {@link JSONTextEncoderStream}.
*
* @public
*/
-type JSONTextEncoderStreamOptions = EncoderOptions & {
+type JSONTextEncoderStreamOptions = JSONTextEncoderOptions & {
/** Queuing strategy for the writable side. */
writableStrategy?: QueuingStrategy;
/** Queuing strategy for the readable side. */
@@ -37,10 +37,12 @@ type JSONTextEncoderStreamOptions = EncoderOptions & {
* ```
*/
class JSONTextEncoderStream extends TransformStream {
- #encoder: Encoder;
+ #serializer: Serializer;
/**
- * @param options - Encoder and queuing strategy options.
+ * Creates a stream that encodes JSON tokens into byte chunks.
+ *
+ * @param options Encoder and queuing strategy options.
*/
constructor(options: JSONTextEncoderStreamOptions = {}) {
const { writableStrategy, readableStrategy, ...rest } = options;
@@ -49,7 +51,7 @@ class JSONTextEncoderStream extends TransformStream {
super(
{
transform: (token, controller) => {
- this.#encoder.writeToken(token);
+ this.#serializer.writeToken(token.kind, token.bytes);
this.#drain(controller);
},
flush: (controller) => {
@@ -60,14 +62,12 @@ class JSONTextEncoderStream extends TransformStream {
readableStrategy,
);
- this.#encoder = new Encoder(encoderOptions);
+ this.#serializer = new Serializer(encoderOptions);
}
- /**
- * Flushes accumulated bytes from the encoder into the readable side.
- */
+ /** Flushes accumulated bytes from the encoder into the readable side. */
#drain(controller: TransformStreamDefaultController): void {
- const bytes = this.#encoder.takeBytes();
+ const bytes = this.#serializer.takeBytes();
if (bytes.length > 0) {
controller.enqueue(bytes);
diff --git a/src/libs/stream-line.ts b/src/lib/stream-line.ts
similarity index 69%
rename from src/libs/stream-line.ts
rename to src/lib/stream-line.ts
index e5ce67d..44b4a55 100644
--- a/src/libs/stream-line.ts
+++ b/src/lib/stream-line.ts
@@ -1,14 +1,14 @@
+import type { JSONTextDecoderOptions } from "#src/api/decoder";
+import Value from "#src/api/value";
import { DEFAULT_DECODER_OPTIONS } from "#src/common/constants";
-import Decoder from "#src/modules/decoder";
-import type Value from "#src/modules/value";
-import type { DecoderOptions } from "#src/types/options";
+import Parser from "#src/modules/parser";
/**
* Options for {@link JSONTextLineStream}.
*
* @public
*/
-type JSONTextLineStreamOptions = DecoderOptions & {
+type JSONTextLineStreamOptions = JSONTextDecoderOptions & {
/** Queuing strategy for the writable side. */
writableStrategy?: QueuingStrategy;
/** Queuing strategy for the readable side. */
@@ -32,10 +32,12 @@ type JSONTextLineStreamOptions = DecoderOptions & {
* ```
*/
class JSONTextLineStream extends TransformStream {
- #decoder: Decoder;
+ #parser: Parser;
/**
- * @param options - Decoder and queuing strategy options.
+ * Creates a stream that decodes byte chunks into complete JSON values.
+ *
+ * @param options Decoder and queuing strategy options.
*/
constructor(options: JSONTextLineStreamOptions = {}) {
const { writableStrategy, readableStrategy, ...rest } = options;
@@ -44,11 +46,11 @@ class JSONTextLineStream extends TransformStream {
super(
{
transform: (chunk, controller) => {
- this.#decoder.push(chunk);
+ this.#parser.push(chunk);
this.#drain(controller);
},
flush: (controller) => {
- this.#decoder.end();
+ this.#parser.close();
this.#drain(controller);
},
},
@@ -56,15 +58,13 @@ class JSONTextLineStream extends TransformStream {
readableStrategy,
);
- this.#decoder = new Decoder(new Uint8Array(), decoderOptions);
+ this.#parser = new Parser(decoderOptions);
}
- /**
- * Drains all available values from the decoder into the readable side.
- */
+ /** Drains all available values from the decoder into the readable side. */
#drain(controller: TransformStreamDefaultController): void {
- for (let value; (value = this.#decoder.readValue()) !== undefined;) {
- controller.enqueue(value);
+ for (let bytes; (bytes = this.#parser.readValue()) !== undefined;) {
+ controller.enqueue(new Value(bytes));
}
}
}
diff --git a/src/libs/stream-selector.ts b/src/lib/stream-selector.ts
similarity index 76%
rename from src/libs/stream-selector.ts
rename to src/lib/stream-selector.ts
index c6fa064..01f1ca0 100644
--- a/src/libs/stream-selector.ts
+++ b/src/lib/stream-selector.ts
@@ -1,9 +1,9 @@
+import type { JSONTextDecoderOptions } from "#src/api/decoder";
+import Value from "#src/api/value";
import { DEFAULT_DECODER_OPTIONS, KIND, MAX_NESTING_DEPTH } from "#src/common/constants";
-import Decoder from "#src/modules/decoder";
+import Parser from "#src/modules/parser";
import type { Matcher } from "#src/modules/path";
import Path from "#src/modules/path";
-import Value from "#src/modules/value";
-import type { DecoderOptions } from "#src/types/options";
import { encodeText } from "#src/utils/text";
/**
@@ -11,7 +11,7 @@ import { encodeText } from "#src/utils/text";
*
* @public
*/
-type JSONTextSelectorStreamOptions = DecoderOptions & {
+type JSONTextSelectorStreamOptions = JSONTextDecoderOptions & {
/** Queuing strategy for the writable side. */
writableStrategy?: QueuingStrategy;
/** Queuing strategy for the readable side. */
@@ -31,8 +31,8 @@ type JSONTextSelectorStreamOptions = DecoderOptions & {
* - **Descendant Segment**: `..`
* - **Name Selector**: `.name` or `['name']`
* - **Wildcard Selector**: `.*` or `[*]`
- * - **Index Selector (positive)**: `[1]`
- * - **Array Slice Selector (positive)**: `[0:5]` or `[::2]`
+ * - **Index Selector (non-negative)**: `[1]`
+ * - **Array Slice Selector (non-negative)**: `[0:5]` or `[::2]`
*
* @see https://www.rfc-editor.org/rfc/rfc9535
* @public
@@ -44,7 +44,7 @@ type JSONTextSelectorStreamOptions = DecoderOptions & {
* ```
*/
class JSONTextSelectorStream extends TransformStream {
- #decoder: Decoder;
+ #parser: Parser;
#matcher: Matcher;
#indexes: Uint32Array;
#types: Uint8Array;
@@ -52,8 +52,10 @@ class JSONTextSelectorStream extends TransformStream {
#depth: number;
/**
- * @param input - A JSON Path expression string (subset of RFC 9535) selecting which values to emit.
- * @param options - Decoder and queuing strategy options.
+ * Creates a stream that emits values selected by a JSON Path expression.
+ *
+ * @param input A JSON Path expression selecting which values to emit.
+ * @param options Decoder and queuing strategy options.
* @throws {SyntaxError} If the path expression is invalid.
*/
constructor(input: string, options: JSONTextSelectorStreamOptions = {}) {
@@ -63,20 +65,20 @@ class JSONTextSelectorStream extends TransformStream {
super(
{
transform: (chunk, controller) => {
- this.#decoder.push(chunk);
+ this.#parser.push(chunk);
this.#drain(controller);
},
flush: (controller) => {
- this.#decoder.end();
+ this.#parser.close();
this.#drain(controller);
- this.#decoder.checkEOF();
+ this.#parser.checkEOF();
},
},
writableStrategy,
readableStrategy,
);
- this.#decoder = new Decoder(new Uint8Array(), decoderOptions);
+ this.#parser = new Parser(decoderOptions);
this.#matcher = new Path(encodeText(input)).createMatcher();
this.#indexes = new Uint32Array(MAX_NESTING_DEPTH);
this.#types = new Uint8Array(MAX_NESTING_DEPTH);
@@ -84,20 +86,17 @@ class JSONTextSelectorStream extends TransformStream {
this.#depth = 0;
}
- /**
- * Reads decoder output step by step, advancing the path matcher, and
- * enqueues values at positions that satisfy the path.
- */
+ /** Reads decoder output and enqueues values at positions that satisfy the path. */
#drain(controller: TransformStreamDefaultController): void {
while (true) {
- const kind = this.#decoder.peekKind();
+ const kind = this.#parser.peekKind();
if (kind === undefined) {
break;
}
if (kind === KIND.OBJECT_END || kind === KIND.ARRAY_END) {
- this.#decoder.readToken();
+ this.#parser.readToken();
if (this.#depth > 0 && this.#pushs[this.#depth - 1] > 0) {
this.#matcher.pop();
@@ -118,8 +117,8 @@ class JSONTextSelectorStream extends TransformStream {
continue;
}
- if (this.#decoder.needObjectName()) {
- if (this.#decoder.readToken() === undefined) {
+ if (this.#parser.needObjectName) {
+ if (this.#parser.readToken() === undefined) {
return;
}
@@ -130,7 +129,7 @@ class JSONTextSelectorStream extends TransformStream {
if (this.#depth > 0) {
const isObject = this.#types[this.#depth - 1] === 1;
- const step = isObject ? this.#decoder.lastObjectName() : this.#indexes[this.#depth - 1];
+ const step = isObject ? this.#parser.lastObjectName : this.#indexes[this.#depth - 1];
this.#matcher.push(step);
pushed = true;
@@ -139,9 +138,9 @@ class JSONTextSelectorStream extends TransformStream {
const isContainer = kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN;
if (this.#matcher.isAccepting()) {
- const value = this.#decoder.readValue();
+ const bytes = this.#parser.readValue();
- if (value === undefined) {
+ if (bytes === undefined) {
if (pushed) {
this.#matcher.pop();
}
@@ -149,7 +148,7 @@ class JSONTextSelectorStream extends TransformStream {
return;
}
- controller.enqueue(new Value(value.bytes, this.#decoder.stackPointer(-1).toString()));
+ controller.enqueue(new Value(bytes, this.#parser.stackPointer(-1).toString()));
if (pushed) {
this.#matcher.pop();
@@ -167,7 +166,7 @@ class JSONTextSelectorStream extends TransformStream {
}
if (!isContainer || this.#matcher.isDead()) {
- if (!this.#decoder.skipValue()) {
+ if (!this.#parser.skipValue()) {
if (pushed) {
this.#matcher.pop();
}
@@ -190,7 +189,7 @@ class JSONTextSelectorStream extends TransformStream {
continue;
}
- this.#decoder.readToken();
+ this.#parser.readToken();
this.#types[this.#depth] = kind === KIND.OBJECT_BEGIN ? 1 : 0;
this.#indexes[this.#depth] = 0;
this.#pushs[this.#depth] = pushed ? 1 : 0;
diff --git a/src/modules/automaton.ts b/src/modules/automaton.ts
index 1831015..d48bd00 100644
--- a/src/modules/automaton.ts
+++ b/src/modules/automaton.ts
@@ -1,25 +1,23 @@
import { MAX_NESTING_DEPTH } from "#src/common/constants";
+import type { Kind } from "#src/common/types";
import Entry from "#src/modules/entry";
-import type { Kind } from "#src/types/kind";
-
-/**
- * A state machine that enforces JSON syntax rules and tracks the structural nesting depth.
- * It ensures that the sequence of incoming tokens adheres strictly to `RFC 8259`.
- *
- * @internal
- */
+
+/** A state machine that enforces JSON syntax rules and tracks structural nesting depth. */
class Automaton {
#last: Entry;
#stack: Entry[];
- /**
- * Creates a new Automaton instance.
- */
+ /** Creates a new Automaton instance. */
constructor() {
this.#last = new Entry("array");
this.#stack = [];
}
+ /** The current structural nesting depth. */
+ get depth(): number {
+ return this.#stack.length + 1;
+ }
+
/** The current entry at the deepest active parsing context. */
get last(): Entry {
return this.#last;
@@ -36,19 +34,14 @@ class Automaton {
* @throws {SyntaxError} If the current entry requires an object name.
*/
appendLiteral(): void {
- if (this.#last.needObjectName()) {
+ if (this.#last.needsObjectName) {
throw new SyntaxError("object name must be a string");
}
this.#last.increment();
}
- /**
- * Appends a string value to the current context.
- *
- * Unlike {@link appendLiteral}, strings are valid in both object-name and
- * value positions, so no structural guard is applied.
- */
+ /** Appends a string value without applying an object-name guard. */
appendString(): void {
this.#last.increment();
}
@@ -62,15 +55,6 @@ class Automaton {
this.appendLiteral();
}
- /**
- * Returns the current nesting depth of the structural state.
- *
- * @returns The current nesting depth — `1` at the top level, incremented by each open object or array.
- */
- depth(): number {
- return this.#stack.length + 1;
- }
-
/**
* Retrieves the structural entry at the specified depth index.
*
@@ -92,7 +76,7 @@ class Automaton {
* @throws {RangeError} If the maximum nesting depth is exceeded.
*/
pushObject(): void {
- if (this.#last.needObjectName()) {
+ if (this.#last.needsObjectName) {
throw new SyntaxError("object name must be a string");
}
@@ -112,11 +96,11 @@ class Automaton {
* @throws {SyntaxError} If the current context is not an object, or if it is prematurely closed while expecting a value.
*/
popObject(): void {
- if (!this.#last.isObject()) {
+ if (!this.#last.isObject) {
throw new SyntaxError("mismatching } for object");
}
- if (this.#last.needObjectValue()) {
+ if (this.#last.needsObjectValue) {
throw new SyntaxError("missing value after object name");
}
@@ -134,7 +118,7 @@ class Automaton {
* @throws {RangeError} If the maximum nesting depth is exceeded.
*/
pushArray(): void {
- if (this.#last.needObjectName()) {
+ if (this.#last.needsObjectName) {
throw new SyntaxError("object name must be a string");
}
@@ -154,7 +138,7 @@ class Automaton {
* @throws {SyntaxError} If the current context is not an array, or if it's the implicit top-level array being closed.
*/
popArray(): void {
- if (!this.#last.isArray() || this.#stack.length === 0) {
+ if (!this.#last.isArray || this.#stack.length === 0) {
throw new SyntaxError("mismatching structural token for object or array");
}
@@ -165,14 +149,20 @@ class Automaton {
}
}
+ /** Resets the automaton to its initial top-level array state. */
+ reset(): void {
+ this.#last = new Entry("array");
+ this.#stack.length = 0;
+ }
+
/**
* Determines whether an implicit delimiter is required before the next token.
*
* @param kind The kind of the next incoming token.
* @returns `":"` if a colon is needed, `","` if a comma is needed, or `null` if no delimiter is expected.
*/
- needDelimiter(kind: Kind): ":" | "," | null {
- if (this.#last.needImplicitColon()) {
+ requiredDelimiter(kind: Kind): ":" | "," | null {
+ if (this.#last.needsImplicitColon) {
return ":";
}
diff --git a/src/modules/cursor.ts b/src/modules/cursor.ts
index 8a1045e..741874a 100644
--- a/src/modules/cursor.ts
+++ b/src/modules/cursor.ts
@@ -1,217 +1,89 @@
-/**
- * A sliding window cursor used by the Decoder to navigate through incoming stream chunks.
- *
- * @internal
- */
+/** Maintains the retained bytes and absolute offset of an incremental input stream. */
class Cursor {
- #baseOffset: number;
#bytes: Uint8Array;
#ended: boolean;
#owned: boolean;
- #previousStart: number;
- #previousEnd: number;
- #peekPosition: number;
- #peekError: Error | null;
+ #offset: number;
- /**
- * Creates a new Cursor instance starting with an initial chunk of bytes.
- *
- * @param bytes The initial Uint8Array chunk to read from.
- */
- constructor(bytes: Uint8Array) {
- this.#baseOffset = 0;
- this.#bytes = bytes;
+ /** Creates a new Cursor instance. */
+ constructor() {
+ this.#bytes = new Uint8Array();
this.#ended = false;
+ this.#offset = 0;
this.#owned = false;
- this.#previousStart = 0;
- this.#previousEnd = 0;
- this.#peekPosition = 0;
- this.#peekError = null;
- }
-
- /**
- * Indicates whether the underlying stream has completely ended (EOF).
- */
- get ended(): boolean {
- return this.#ended;
}
- /**
- * The current active byte buffer.
- */
+ /** The current active byte buffer. */
get bytes(): Uint8Array {
return this.#bytes;
}
- /**
- * The start position of the previous chunk.
- */
- get previousStart(): number {
- return this.#previousStart;
- }
-
- set previousStart(value: number) {
- this.#previousStart = value;
- }
-
- /**
- * The end position of the previous chunk.
- */
- get previousEnd(): number {
- return this.#previousEnd;
- }
-
- set previousEnd(value: number) {
- this.#previousEnd = value;
- }
-
- /**
- * The current peek position within the byte buffer.
- */
- get peekPosition(): number {
- return this.#peekPosition;
- }
-
- set peekPosition(value: number) {
- this.#peekPosition = value;
- }
-
- /**
- * The current peek error, if any.
- */
- get peekError(): Error | null {
- return this.#peekError;
- }
-
- set peekError(value: Error | null) {
- this.#peekError = value;
+ /** Indicates whether the underlying stream has completely ended. */
+ get ended(): boolean {
+ return this.#ended;
}
/**
- * Appends a newly received chunk of bytes to the cursor.
+ * Appends a newly received chunk and discards previously consumed data.
*
- * @param bytes The new Uint8Array chunk arriving from the stream.
+ * @param chunk New bytes from the stream.
+ * @param start Number of bytes already consumed from the current buffer.
*/
- appendBytes(bytes: Uint8Array): void {
- const unread = this.unreadBytes();
- const start = this.#previousEnd;
-
- if (unread.length > 0) {
- const capacity = start + unread.length + bytes.length;
- const length = unread.length + bytes.length;
-
- if (this.#owned && capacity <= this.#bytes.buffer.byteLength) {
- const buffer = this.#bytes.buffer;
-
- this.#bytes = new Uint8Array(buffer, 0, capacity);
- this.#bytes.set(bytes, start + unread.length);
+ append(chunk: Uint8Array, start: number): void {
+ const retainedLength = this.#bytes.length - start;
+ const newLength = retainedLength + chunk.length;
- return;
- }
+ this.#offset += start;
- if (this.#owned && length <= this.#bytes.buffer.byteLength) {
- const buffer = this.#bytes.buffer;
- const view = new Uint8Array(buffer);
+ if (retainedLength === 0) {
+ this.#bytes = chunk;
+ this.#owned = false;
- view.copyWithin(0, start, start + unread.length);
+ return;
+ }
- this.#bytes = new Uint8Array(buffer, 0, length);
- this.#bytes.set(bytes, unread.length);
- } else {
- const capacity = this.#owned ? this.#bytes.buffer.byteLength : 0;
- const array = new Uint8Array(Math.max(length, capacity * 2));
+ if (this.#owned && newLength <= this.#bytes.buffer.byteLength) {
+ const buffer = this.#bytes.buffer;
+ const current = new Uint8Array(buffer);
- array.set(unread, 0);
- array.set(bytes, unread.length);
+ current.copyWithin(0, start, start + retainedLength);
- this.#bytes = new Uint8Array(array.buffer, 0, length);
- this.#owned = true;
- }
+ this.#bytes = new Uint8Array(buffer, 0, newLength);
+ this.#bytes.set(chunk, retainedLength);
} else {
- this.#bytes = bytes;
- this.#owned = false;
- }
- if (this.#peekPosition > 0) {
- this.#peekPosition -= this.#previousEnd;
- }
+ const capacity = this.#owned ? this.#bytes.buffer.byteLength : 0;
+ const doubling = Math.max(newLength, capacity * 2);
+ const next = new Uint8Array(doubling);
- this.#baseOffset += this.#previousEnd;
- this.#previousStart = 0;
- this.#previousEnd = 0;
- }
+ next.set(this.#bytes.subarray(start), 0);
+ next.set(chunk, retainedLength);
- /**
- * Discards the previously processed token bytes by advancing the start pointer.
- * This signals that the current memory region is no longer needed.
- */
- discardPrevious(): void {
- if (this.#previousStart < this.#previousEnd && this.#previousStart < this.#bytes.length) {
- this.#previousStart = this.#previousEnd;
+ this.#bytes = new Uint8Array(next.buffer, 0, newLength);
+ this.#owned = true;
}
}
/**
- * Marks the cursor as ended, indicating that no more bytes will be received.
- */
- end(): void {
- this.#ended = true;
- }
-
- /**
- * Checks if it has reached the end of the current internal buffer
- * and requires more data from the stream to continue.
+ * Calculates the absolute stream offset for a position in the retained buffer.
*
- * @param position The current index being processed.
- * @returns `true` if more chunks need to be pulled from the stream, `false` otherwise.
+ * @param position The position within the current retained buffer.
+ * @returns The byte offset from the beginning of the stream.
*/
- needMore(position: number): boolean {
- return position === this.#bytes.length;
+ at(position: number): number {
+ return this.#offset + position;
}
- /**
- * Calculates the absolute offset in the entire stream for a given local buffer position.
- *
- * @param position The local index within the current chunk.
- * @returns The global byte offset from the very beginning of the stream.
- */
- offsetAt(position: number): number {
- return this.#baseOffset + position;
- }
-
- /**
- * Extracts the bytes of the most recently processed token.
- *
- * @returns A subarray representing the previous token.
- */
- previousBytes(): Uint8Array {
- return this.#bytes.subarray(this.#previousStart, this.#previousEnd);
- }
-
- /**
- * Calculates the absolute start offset of the most recently processed token.
- *
- * @returns The global byte offset from the very beginning of the stream.
- */
- previousOffsetStart(): number {
- return this.#baseOffset + this.#previousStart;
- }
-
- /**
- * Calculates the absolute end offset of the most recently processed token.
- *
- * @returns The global byte offset from the very beginning of the stream.
- */
- previousOffsetEnd(): number {
- return this.#baseOffset + this.#previousEnd;
+ /** Marks the cursor as ended so no more bytes can be received. */
+ close(): void {
+ this.#ended = true;
}
- /**
- * Extracts the bytes that have been read but not yet discarded, representing the unread portion of the buffer.
- *
- * @returns A subarray of the current buffer containing all unread bytes.
- */
- unreadBytes(): Uint8Array {
- return this.#bytes.subarray(this.#previousEnd, this.#bytes.length);
+ /** Resets the cursor, clearing its buffer and offsets. */
+ reset(): void {
+ this.#bytes = new Uint8Array();
+ this.#ended = false;
+ this.#offset = 0;
+ this.#owned = false;
}
}
diff --git a/src/modules/decoder.ts b/src/modules/decoder.ts
deleted file mode 100644
index d496856..0000000
--- a/src/modules/decoder.ts
+++ /dev/null
@@ -1,625 +0,0 @@
-import { ASCII, KIND } from "#src/common/constants";
-import { SyntacticError } from "#src/common/errors";
-import Cursor from "#src/modules/cursor";
-import type Pointer from "#src/modules/pointer";
-import State from "#src/modules/state";
-import Token from "#src/modules/token";
-import Value from "#src/modules/value";
-import type { Kind } from "#src/types/kind";
-import type { DecoderOptions } from "#src/types/options";
-import { normalize } from "#src/utils/kind";
-import { decodeText } from "#src/utils/text";
-import {
- consumeFalse,
- consumeNull,
- consumeNumber,
- consumeSimpleNumber,
- consumeSimpleString,
- consumeStringResumable,
- consumeTrue,
- consumeWhitespace,
-} from "#src/utils/wire";
-
-/**
- * Low-level streaming JSON decoder that reads UTF-8 encoded bytes,
- * navigates through them via a {@link Cursor}, and enforces RFC 8259
- * structural rules through a {@link State} machine.
- *
- * @internal
- */
-class Decoder {
- #offset: number;
- #cursor: Cursor;
- #state: State;
- #options: DecoderOptions;
-
- /**
- * Creates a new Decoder with an initial byte buffer and options.
- *
- * @param bytes Initial UTF-8 bytes to decode.
- * @param options Decoder configuration options.
- */
- constructor(bytes: Uint8Array, options: DecoderOptions) {
- this.#offset = 0;
- this.#cursor = new Cursor(bytes);
- this.#state = new State(options);
- this.#options = options;
- }
-
- /**
- * Asserts that exactly one complete JSON value has been consumed with no
- * trailing content.
- *
- * @throws {SyntaxError} If the decoder is still inside a nested structure,
- * or if non-whitespace characters remain after the value.
- */
- checkEOF(): void {
- if (this.#state.depth() > 1) {
- throw new SyntaxError(`Unexpected end of input`);
- }
-
- const position = consumeWhitespace(this.#cursor.bytes, this.#cursor.previousEnd);
-
- if (!this.#cursor.needMore(position)) {
- throw new SyntaxError(`Unexpected trailing characters at position ${position}`);
- }
- }
-
- /**
- * Returns the current nesting depth of the structural state.
- *
- * @returns The current nesting depth — `1` at the top level, incremented by each open object or array.
- */
- depth(): number {
- return this.#state.depth();
- }
-
- /**
- * Signals that no more bytes will arrive (end of stream).
- *
- * After calling this, number tokens no longer require a trailing byte to
- * confirm their end.
- */
- end(): void {
- this.#cursor.end();
- }
-
- /**
- * Returns the absolute byte offset at the end of the last consumed token.
- *
- * @returns The global byte offset from the start of the stream.
- */
- inputOffset(): number {
- return this.#cursor.previousOffsetEnd();
- }
-
- /**
- * Checks whether the current context expects an object key.
- *
- * @returns `true` if the next token must be a string serving as an object name, `false` otherwise.
- */
- needObjectName(): boolean {
- return this.#state.needObjectName();
- }
-
- /**
- * Returns the most recently consumed object key string.
- *
- * @returns The current object property name, or an empty string if not inside an object.
- */
- lastObjectName(): string {
- return this.#state.lastObjectName();
- }
-
- /**
- * Appends the next byte chunk from the stream to the input buffer.
- *
- * @param bytes The incoming UTF-8 bytes.
- */
- push(bytes: Uint8Array): void {
- this.#cursor.appendBytes(bytes);
- }
-
- /**
- * Peeks at the kind of the next token without consuming it.
- *
- * Skips leading whitespace and validates any required structural delimiter
- * (`","` or `":"`).
- * The result is cached until the next call to {@link readToken},
- * {@link readValue}, or {@link skipValue}.
- *
- * @returns The {@link Kind} of the next token, or `undefined` if more bytes are needed to determine it.
- * @throws {SyntacticError} If an invalid character or unexpected delimiter is encountered.
- */
- peekKind(): Kind | undefined {
- if (this.#cursor.peekPosition > 0) {
- if (this.#cursor.peekError) {
- throw this.#cursor.peekError;
- }
-
- const byte = this.#cursor.bytes[this.#cursor.peekPosition];
- const kind = normalize(byte);
-
- return kind;
- }
-
- this.#cursor.discardPrevious();
-
- let position = consumeWhitespace(this.#cursor.bytes, this.#cursor.previousEnd);
- let delimiter = null;
-
- if (this.#cursor.needMore(position)) {
- return undefined;
- }
-
- const byte = this.#cursor.bytes[position];
-
- if (byte === ASCII.COLON || byte === ASCII.COMMA) {
- delimiter = String.fromCharCode(byte);
- position++;
- position = consumeWhitespace(this.#cursor.bytes, position);
-
- if (this.#cursor.needMore(position)) {
- return undefined;
- }
- }
-
- const kind = normalize(this.#cursor.bytes[position]);
-
- if (!kind) {
- const message = `invalid character`;
- const pointer = this.#state.stackPointer(0).toString();
- const offset = this.#cursor.offsetAt(position);
- const error = new SyntacticError(message, pointer, offset);
-
- this.#cursor.peekError = error;
-
- throw error;
- }
-
- const expected = this.#state.needDelimiter(kind);
-
- if (expected !== delimiter) {
- const pointer = this.#state.stackPointer(0).toString();
- const offset = this.#cursor.offsetAt(position);
- const error = new SyntacticError("invalid delimiter", pointer, offset);
-
- this.#cursor.peekError = error;
-
- throw error;
- }
-
- this.#cursor.peekPosition = position;
-
- return kind;
- }
-
- /**
- * Resets the decoder to its initial state, discarding all buffered bytes
- * and structural state.
- */
- reset(): void {
- this.#offset = 0;
- this.#cursor = new Cursor(new Uint8Array());
- this.#state = new State(this.#options);
- }
-
- /**
- * Consumes and returns the next token from the input buffer.
- *
- * For structural tokens (`{`, `}`, `[`, `]`), the state machine is updated
- * accordingly. For string tokens that serve as object names, the name is
- * recorded in the state.
- *
- * @returns The next {@link Token}, or `undefined` if more bytes are needed.
- * @throws {SyntacticError} If the token is malformed or structurally invalid.
- */
- readToken(): Token | undefined {
- const kind = this.peekKind();
-
- if (kind === undefined) {
- return undefined;
- }
-
- const start = this.#cursor.peekPosition;
- let size = 0;
-
- try {
- switch (kind) {
- case KIND.NULL:
- size = this.#consumeNull(start);
- break;
- case KIND.TRUE:
- size = this.#consumeTrue(start);
- break;
- case KIND.FALSE:
- size = this.#consumeFalse(start);
- break;
- case KIND.STRING:
- size = this.#consumeString(start);
- break;
- case KIND.NUMBER:
- size = this.#consumeNumber(start);
- break;
- case KIND.OBJECT_BEGIN:
- size = this.#consumeObjectBegin();
- break;
- case KIND.OBJECT_END:
- size = this.#consumeObjectEnd();
- break;
- case KIND.ARRAY_BEGIN:
- size = this.#consumeArrayBegin();
- break;
- case KIND.ARRAY_END:
- size = this.#consumeArrayEnd();
- break;
- }
- } catch (error) {
- if (error instanceof SyntacticError) {
- throw error;
- }
-
- if (error instanceof SyntaxError) {
- const pointer = this.#state.stackPointer(0).toString();
- const offset = this.#cursor.offsetAt(start);
-
- throw new SyntacticError(error.message, pointer, offset);
- }
-
- throw error;
- }
-
- if (size === 0) {
- return undefined;
- }
-
- this.#cursor.peekPosition = 0;
- this.#cursor.previousStart = start;
- this.#cursor.previousEnd = start + size;
-
- return new Token(this.#cursor.previousBytes());
- }
-
- /**
- * Consumes and returns the next complete JSON value from the input buffer.
- *
- * For composite values (objects and arrays), the raw bytes are captured as
- * a single unit without entering the nested structure. For string values that
- * serve as object names, the name is recorded in the state.
- *
- * @returns The next {@link Value}, or `undefined` if more bytes are needed.
- * @throws {SyntacticError} If the value is malformed or structurally invalid.
- */
- readValue(): Value | undefined {
- const kind = this.peekKind();
-
- if (kind === undefined) {
- return undefined;
- }
-
- const start = this.#cursor.peekPosition;
- const size = this.#consumeValue(start);
-
- if (size === 0) {
- return undefined;
- }
-
- const bytes = this.#cursor.bytes.subarray(start, start + size);
-
- if (kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN) {
- try {
- JSON.parse(decodeText(bytes, !this.#options.allowInvalidUTF8));
- } catch (error) {
- if (error instanceof Error) {
- this.#cursor.peekError = error;
- }
-
- throw error;
- }
- }
-
- if (kind === KIND.STRING) {
- const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
- const parsed = JSON.parse(decoded);
-
- if (this.#state.needObjectName()) {
- this.#state.setLast(parsed);
- }
-
- this.#state.appendString();
- } else if (kind === KIND.NUMBER) {
- this.#state.appendNumber();
- } else {
- this.#state.appendLiteral();
- }
-
- this.#cursor.peekPosition = 0;
- this.#cursor.previousStart = start;
- this.#cursor.previousEnd = start + size;
-
- return new Value(bytes);
- }
-
- /**
- * Consumes the next complete JSON value without returning its bytes.
- *
- * @returns `true` if a value was consumed, `false` if more bytes are needed.
- * @throws {SyntacticError} If the value is malformed or structurally invalid.
- */
- skipValue(): boolean {
- const kind = this.peekKind();
-
- if (kind === undefined) {
- return false;
- }
-
- if (kind !== KIND.OBJECT_BEGIN && kind !== KIND.ARRAY_BEGIN) {
- return this.readToken() !== undefined;
- }
-
- const start = this.#cursor.peekPosition;
- const size = this.#consumeValue(start);
-
- if (size === 0) {
- return false;
- }
-
- this.#cursor.peekPosition = 0;
- this.#cursor.previousStart = start;
- this.#cursor.previousEnd = start + size;
- this.#state.appendLiteral();
-
- return true;
- }
-
- /**
- * Generates a JSON Pointer representing a location relative to the current
- * decoding position.
- *
- * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value.
- * @returns A {@link Pointer} representing the absolute path.
- */
- stackPointer(where: 0 | 1 | -1 = 1): Pointer {
- return this.#state.stackPointer(where);
- }
-
- /**
- * Returns the unconsumed portion of the input buffer.
- *
- * @returns A subarray of bytes that have not yet been processed.
- */
- unreadBytes(): Uint8Array {
- return this.#cursor.unreadBytes();
- }
-
- #consumeValue(start: number): number {
- let position = start;
- const bytes = this.#cursor.bytes;
- const kind = normalize(bytes[position]);
-
- if (kind === KIND.NULL) {
- return consumeNull(bytes, position);
- }
-
- if (kind === KIND.TRUE) {
- return consumeTrue(bytes, position);
- }
-
- if (kind === KIND.FALSE) {
- return consumeFalse(bytes, position);
- }
-
- if (kind === KIND.STRING) {
- if (this.#offset === 0) {
- const size = consumeSimpleString(bytes, position);
- if (size > 0) return size;
- }
-
- const result = consumeStringResumable(
- bytes,
- position,
- this.#offset,
- !this.#options.allowInvalidUTF8,
- );
-
- if (!result.completed) {
- this.#offset = result.consumed;
- return 0;
- }
-
- this.#offset = 0;
- return result.consumed;
- }
-
- if (kind === KIND.NUMBER) {
- let size = consumeSimpleNumber(bytes, position);
-
- if (size === 0) {
- size = consumeNumber(bytes, position);
- }
-
- if (size > 0 && position + size === bytes.length && !this.#cursor.ended) {
- return 0;
- }
-
- return size;
- }
-
- if (kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN) {
- let depth = 0;
- let inString = false;
- let inEscape = false;
-
- while (position < bytes.length) {
- const byte = bytes[position];
-
- if (inString) {
- if (inEscape) {
- inEscape = false;
- } else if (byte === ASCII.BACKSLASH) {
- inEscape = true;
- } else if (byte === ASCII.QUOTE) {
- inString = false;
- }
-
- position++;
-
- continue;
- }
-
- if (byte === ASCII.QUOTE) {
- inString = true;
- } else if (byte === ASCII.OPENING_BRACE || byte === ASCII.OPENING_BRACKET) {
- depth++;
- } else if (byte === ASCII.CLOSING_BRACE || byte === ASCII.CLOSING_BRACKET) {
- depth--;
-
- if (depth === 0) {
- return position + 1 - start;
- }
- }
-
- position++;
- }
-
- return 0;
- }
-
- return 0;
- }
-
- #consumeNull(start: number): number {
- const size = consumeNull(this.#cursor.bytes, start);
-
- if (size === 0) {
- if (this.#cursor.bytes.length - start < 4) {
- return 0;
- }
-
- const pointer = this.#state.stackPointer(0).toString();
- const offset = this.#cursor.offsetAt(start);
- const error = new SyntacticError("invalid literal null", pointer, offset);
-
- throw error;
- }
-
- this.#state.appendLiteral();
-
- return size;
- }
-
- #consumeTrue(start: number): number {
- const size = consumeTrue(this.#cursor.bytes, start);
-
- if (size === 0) {
- if (this.#cursor.bytes.length - start < 4) {
- return 0;
- }
-
- const pointer = this.#state.stackPointer(0).toString();
- const offset = this.#cursor.offsetAt(start);
- const error = new SyntacticError("invalid literal true", pointer, offset);
-
- throw error;
- }
-
- this.#state.appendLiteral();
-
- return size;
- }
-
- #consumeFalse(start: number): number {
- const size = consumeFalse(this.#cursor.bytes, start);
-
- if (size === 0) {
- if (this.#cursor.bytes.length - start < 5) {
- return 0;
- }
-
- const pointer = this.#state.stackPointer(0).toString();
- const offset = this.#cursor.offsetAt(start);
- const error = new SyntacticError("invalid literal false", pointer, offset);
-
- throw error;
- }
-
- this.#state.appendLiteral();
-
- return size;
- }
-
- #consumeString(start: number): number {
- let size = 0;
-
- if (this.#offset === 0) {
- size = consumeSimpleString(this.#cursor.bytes, start);
- }
-
- if (size === 0) {
- const result = consumeStringResumable(
- this.#cursor.bytes,
- start,
- this.#offset,
- !this.#options.allowInvalidUTF8,
- );
-
- if (!result.completed) {
- this.#offset = result.consumed;
- return 0;
- }
-
- size = result.consumed;
- this.#offset = 0;
- }
-
- if (this.#state.needObjectName()) {
- const bytes = this.#cursor.bytes.subarray(start, start + size);
- const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
- const string = JSON.parse(decoded);
-
- this.#state.setLast(string);
- }
-
- this.#state.appendString();
-
- return size;
- }
-
- #consumeNumber(start: number): number {
- let size = consumeSimpleNumber(this.#cursor.bytes, start);
-
- if (size === 0) {
- size = consumeNumber(this.#cursor.bytes, start);
-
- if (size === 0) {
- return 0;
- }
- }
-
- if (start + size === this.#cursor.bytes.length && !this.#cursor.ended) {
- return 0;
- }
-
- this.#state.appendNumber();
-
- return size;
- }
-
- #consumeObjectBegin(): number {
- return (this.#state.pushObject(), 1);
- }
-
- #consumeObjectEnd(): number {
- return (this.#state.popObject(), 1);
- }
-
- #consumeArrayBegin(): number {
- return (this.#state.pushArray(), 1);
- }
-
- #consumeArrayEnd(): number {
- return (this.#state.popArray(), 1);
- }
-}
-
-export default Decoder;
diff --git a/src/modules/encoder.ts b/src/modules/encoder.ts
deleted file mode 100644
index fc5b704..0000000
--- a/src/modules/encoder.ts
+++ /dev/null
@@ -1,358 +0,0 @@
-import { ASCII, KIND, UNICODE } from "#src/common/constants";
-import { SyntacticError } from "#src/common/errors";
-import type Pointer from "#src/modules/pointer";
-import State from "#src/modules/state";
-import Tape from "#src/modules/tape";
-import Token from "#src/modules/token";
-import Value from "#src/modules/value";
-import type { Kind } from "#src/types/kind";
-import type { EncoderOptions } from "#src/types/options";
-import { decodeText, encodeText } from "#src/utils/text";
-
-/**
- * Low-level JSON encoder that serializes tokens and values onto an internal
- * {@link Tape}, enforcing RFC 8259 structural rules and applying optional
- * formatting (indentation, HTML escaping, etc.).
- *
- * @internal
- */
-class Encoder {
- #tape: Tape;
- #state: State;
- #options: EncoderOptions;
- #cache: Record;
-
- /**
- * Creates a new Encoder with the given options.
- *
- * @param options Encoder configuration options.
- */
- constructor(options: EncoderOptions) {
- this.#tape = new Tape();
- this.#state = new State(options);
- this.#options = options;
- this.#cache = {
- "null_bytes": encodeText("null"),
- "true_bytes": encodeText("true"),
- "false_bytes": encodeText("false"),
- "indent_bytes": encodeText(options.indent ?? "\t"),
- "indent_prefix_bytes": options.indentPrefix ? encodeText(options.indentPrefix) : null,
- };
- }
-
- /**
- * Returns a view of the bytes written so far without advancing the output offset.
- *
- * @returns A subarray of the internal tape buffer.
- */
- bytes(): Uint8Array {
- return this.#tape.bytes();
- }
-
- /**
- * Returns the current structural nesting depth.
- *
- * @returns The current nesting depth — `1` at the top level, incremented by each open object or array.
- */
- depth(): number {
- return this.#state.depth();
- }
-
- /**
- * Returns the absolute output byte offset, accumulating across all
- * {@link takeBytes} calls since the last {@link reset}.
- *
- * @returns The absolute output byte offset.
- */
- outputOffset(): number {
- return this.#tape.outputOffset();
- }
-
- /**
- * Clears the tape and reinitializes the structural state.
- */
- reset(): void {
- this.#tape.reset();
- this.#state = new State(this.#options);
- }
-
- /**
- * Generates a JSON Pointer representing a location relative to the current
- * encoding position.
- *
- * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value.
- * @returns A {@link Pointer} representing the absolute path.
- */
- stackPointer(where: 0 | 1 | -1): Pointer {
- return this.#state.stackPointer(where);
- }
-
- /**
- * Extracts the written bytes as a slice and advances the base output offset,
- * preparing the tape for the next chunk (streaming use).
- *
- * @returns A copy of the bytes written since the last `takeBytes` call.
- */
- takeBytes(): Uint8Array {
- return this.#tape.takeBytes();
- }
-
- /**
- * Serializes a single token onto the tape.
- *
- * Automatically inserts structural delimiters (`:` or `,`) and any
- * configured whitespace or indentation before the token. On error, the
- * tape is rolled back to its pre-call length.
- *
- * @param token The {@link Token} to write.
- * @throws {SyntacticError} If the token is structurally invalid at the
- * current position.
- */
- writeToken(token: Token): void {
- const length = this.#tape.length;
- const delimiter = this.#state.needDelimiter(token.kind);
-
- try {
- if (delimiter === ":") {
- this.#tape.appendByte(ASCII.COLON);
- } else if (delimiter === ",") {
- this.#tape.appendByte(ASCII.COMMA);
- }
-
- this.#appendWhitespace(token.kind, delimiter);
-
- switch (token.kind) {
- case KIND.NULL:
- this.#tape.appendBytes(this.#cache.null_bytes!);
- this.#state.appendLiteral();
- break;
- case KIND.TRUE:
- this.#tape.appendBytes(this.#cache.true_bytes!);
- this.#state.appendLiteral();
- break;
- case KIND.FALSE:
- this.#tape.appendBytes(this.#cache.false_bytes!);
- this.#state.appendLiteral();
- break;
- case KIND.STRING: {
- const bytes = this.#encodeText(token.bytes);
-
- this.#tape.appendBytes(bytes);
-
- if (this.#state.needObjectName()) {
- const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
- const parsed = JSON.parse(decoded);
-
- this.#state.setLast(parsed);
- }
-
- this.#state.appendString();
- break;
- }
- case KIND.NUMBER: {
- let bytes = token.bytes;
-
- if (this.#options.canonicalizeRawNumbers) {
- const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
- const parsed = JSON.parse(decoded);
- const encoded = encodeText(String(parsed));
-
- bytes = encoded;
- }
-
- this.#tape.appendBytes(bytes);
- this.#state.appendNumber();
- break;
- }
- case KIND.OBJECT_BEGIN:
- this.#tape.appendByte(ASCII.OPENING_BRACE);
- this.#state.pushObject();
- break;
- case KIND.OBJECT_END:
- this.#tape.appendByte(ASCII.CLOSING_BRACE);
- this.#state.popObject();
- break;
- case KIND.ARRAY_BEGIN:
- this.#tape.appendByte(ASCII.OPENING_BRACKET);
- this.#state.pushArray();
- break;
- case KIND.ARRAY_END:
- this.#tape.appendByte(ASCII.CLOSING_BRACKET);
- this.#state.popArray();
- break;
- }
- } catch (error) {
- this.#tape.truncate(length);
-
- if (error instanceof SyntacticError) {
- throw error;
- }
-
- if (error instanceof SyntaxError) {
- const pointer = this.#state.stackPointer(1).toString();
- const offset = this.#tape.outputOffset();
-
- throw new SyntacticError(error.message, pointer, offset);
- }
-
- throw error;
- }
- }
-
- /**
- * Serializes a complete pre-parsed value onto the tape.
- *
- * Automatically inserts structural delimiters and any configured whitespace
- * before the value. On error, the tape is rolled back to its pre-call length.
- *
- * @param value The {@link Value} to write.
- * @throws {SyntacticError} If the value is structurally invalid at the
- * current position.
- */
- writeValue(value: Value): void {
- const length = this.#tape.length;
- const delimiter = this.#state.needDelimiter(value.kind);
-
- try {
- if (delimiter === ":") {
- this.#tape.appendByte(ASCII.COLON);
- } else if (delimiter === ",") {
- this.#tape.appendByte(ASCII.COMMA);
- }
-
- this.#appendWhitespace(value.kind, delimiter);
-
- let bytes = value.bytes;
-
- if (value.kind === KIND.STRING) {
- bytes = this.#encodeText(bytes);
- } else if (value.kind === KIND.NUMBER && this.#options.canonicalizeRawNumbers) {
- const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
- const parsed = JSON.parse(decoded);
- const encoded = encodeText(String(parsed));
-
- bytes = encoded;
- }
-
- this.#tape.appendBytes(bytes);
-
- switch (value.kind) {
- case KIND.NULL:
- case KIND.TRUE:
- case KIND.FALSE:
- this.#state.appendLiteral();
- break;
- case KIND.STRING: {
- if (this.#state.needObjectName()) {
- const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
- const parsed = JSON.parse(decoded);
-
- this.#state.setLast(parsed);
- }
- this.#state.appendString();
- break;
- }
- case KIND.NUMBER:
- this.#state.appendNumber();
- break;
- case KIND.OBJECT_BEGIN:
- this.#state.pushObject();
- this.#state.popObject();
- break;
- case KIND.ARRAY_BEGIN:
- this.#state.pushArray();
- this.#state.popArray();
- break;
- }
- } catch (error) {
- this.#tape.truncate(length);
-
- if (error instanceof SyntacticError) {
- throw error;
- }
-
- if (error instanceof SyntaxError) {
- const pointer = this.#state.stackPointer(1).toString();
- const offset = this.#tape.outputOffset();
-
- throw new SyntacticError(error.message, pointer, offset);
- }
-
- throw error;
- }
- }
-
- #appendWhitespace(kind: Kind, delimiter: ":" | "," | null): void {
- if (delimiter === ":") {
- if (this.#options.spaceAfterColon) {
- this.#tape.appendByte(ASCII.SPACE);
- }
-
- return;
- }
-
- if (delimiter === "," && this.#options.spaceAfterComma) {
- this.#tape.appendByte(ASCII.SPACE);
- }
-
- if (this.#options.multiline) {
- const depth = this.#state.depth();
-
- if (depth === 1) {
- return;
- }
-
- const isClose = kind === KIND.OBJECT_END || kind === KIND.ARRAY_END;
- const levels = isClose ? depth - 2 : depth - 1;
-
- this.#tape.appendByte(ASCII.LINE_FEED);
-
- if (this.#options.indentPrefix && this.#cache["indent_prefix_bytes"]) {
- this.#tape.appendBytes(this.#cache["indent_prefix_bytes"]);
- }
-
- for (let i = 0; i < levels; i++) {
- if (this.#cache["indent_bytes"]) {
- this.#tape.appendBytes(this.#cache["indent_bytes"]);
- }
- }
- }
- }
-
- #encodeText(bytes: Uint8Array): Uint8Array {
- if (!this.#options.escapeForHTML && !this.#options.escapeForJS) {
- if (!this.#options.allowInvalidUTF8) {
- decodeText(bytes, true);
- }
-
- return bytes;
- }
-
- const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
- const parsed = JSON.parse(decoded);
- let encoded = JSON.stringify(parsed);
-
- if (this.#options.escapeForHTML) {
- encoded = encoded.replace(
- new RegExp("[<>&]", "g"),
- (substring) => {
- return (substring === "<"
- ? UNICODE.OPEN_ANGLED_BRACKET
- : substring === ">"
- ? UNICODE.CLOSE_ANGLED_BRACKET
- : UNICODE.AMPERSAND);
- },
- );
- }
-
- if (this.#options.escapeForJS) {
- encoded = encoded
- .replace(new RegExp("\\u2028", "g"), UNICODE.LINE_SEPARATOR)
- .replace(new RegExp("\\u2029", "g"), UNICODE.PARAGRAPH_SEPARATOR);
- }
-
- return encodeText(encoded);
- }
-}
-
-export default Encoder;
diff --git a/src/modules/entry.ts b/src/modules/entry.ts
index b4d39a8..ec0ecdb 100644
--- a/src/modules/entry.ts
+++ b/src/modules/entry.ts
@@ -1,11 +1,7 @@
import { KIND } from "#src/common/constants";
-import type { Kind } from "#src/types/kind";
+import type { Kind } from "#src/common/types";
-/**
- * Represents a single depth level in the automaton stack, which can be either an object or an array.
- *
- * @internal
- */
+/** Represents one object or array depth level in the automaton stack. */
class Entry {
#type: "object" | "array";
#count: number;
@@ -20,63 +16,34 @@ class Entry {
this.#count = 0;
}
- /**
- * Returns the current count of elements in the entry.
- * - **Array**: the index of the next element.
- * - **Object**: the number of key-value half-steps processed.
- *
- * @returns The current element count.
- */
- count(): number {
+ /** The number of elements processed in this entry. */
+ get count(): number {
return this.#count;
}
- /**
- * Checks if the entry represents a JSON object.
- *
- * @returns `true` if the entry is an object, `false` otherwise.
- */
- isObject(): boolean {
+ /** Checks if the entry represents a JSON object. */
+ get isObject(): boolean {
return this.#type === "object";
}
- /**
- * Checks if the entry represents a JSON array.
- *
- * @returns `true` if the entry is an array, `false` otherwise.
- */
- isArray(): boolean {
+ /** Checks if the entry represents a JSON array. */
+ get isArray(): boolean {
return this.#type === "array";
}
- /**
- * Checks if the entry expects an object name for the next token.
- * This is true when the structure is an object and the current count is even.
- *
- * @returns `true` if the next token must be a string serving as an object name, `false` otherwise.
- */
- needObjectName(): boolean {
- return this.isObject() && this.#count % 2 === 0;
+ /** Checks if the entry expects an implicit colon before the next element. */
+ get needsImplicitColon(): boolean {
+ return this.needsObjectValue;
}
- /**
- * Checks if the entry expects an object value for the next token.
- * This is true when the structure is an object and a key has just been processed (count is odd).
- *
- * @returns `true` if an object value is needed, `false` otherwise.
- */
- needObjectValue(): boolean {
- return this.isObject() && this.#count % 2 === 1;
+ /** Checks if the entry expects an object name for the next token. */
+ get needsObjectName(): boolean {
+ return this.isObject && this.#count % 2 === 0;
}
- /**
- * Checks if the entry needs an implicit colon before the next element,
- * if it's an object expecting a value, the next token must be a colon (`:`).
- *
- * @returns `true` if the entry needs an implicit colon, `false` otherwise.
- */
- needImplicitColon(): boolean {
- return this.needObjectValue();
+ /** Checks if the entry expects an object value for the next token. */
+ get needsObjectValue(): boolean {
+ return this.isObject && this.#count % 2 === 1;
}
/**
@@ -92,22 +59,18 @@ class Entry {
* @returns `true` if the entry needs an implicit comma, `false` otherwise.
*/
needImplicitComma(next: Kind): boolean {
- const isObjectEnd = this.isObject() && next === KIND.OBJECT_END;
- const isArrayEnd = this.isArray() && next === KIND.ARRAY_END;
+ const isObjectEnd = this.isObject && next === KIND.OBJECT_END;
+ const isArrayEnd = this.isArray && next === KIND.ARRAY_END;
- return (!!this.count() && !this.needObjectValue() && !isObjectEnd && !isArrayEnd);
+ return (!!this.count && !this.needsObjectValue && !isObjectEnd && !isArrayEnd);
}
- /**
- * Increases the count of elements in the entry.
- */
+ /** Increases the count of elements in the entry. */
increment(): void {
this.#count++;
}
- /**
- * Decreases the count of elements in the entry.
- */
+ /** Decreases the count of elements in the entry. */
decrement(): void {
this.#count--;
}
diff --git a/src/modules/escaper.ts b/src/modules/escaper.ts
new file mode 100644
index 0000000..f519430
--- /dev/null
+++ b/src/modules/escaper.ts
@@ -0,0 +1,96 @@
+import { UNICODE } from "#src/common/constants";
+import { decodeText, encodeText } from "#src/utils/text";
+
+/** Options for {@link Escaper}. */
+type EscaperOptions = {
+ /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */
+ allowInvalidUTF8: boolean;
+ /** Normalize number tokens to their canonical decimal form. */
+ canonicalizeRawNumbers: boolean;
+ /** Escape `<`, `>`, and `&` for safe embedding in HTML. */
+ escapeForHTML: boolean;
+ /** Escape `\u2028` and `\u2029` for safe embedding in JavaScript string literals. */
+ escapeForJS: boolean;
+};
+
+/** Handles string escaping for HTML and JavaScript contexts and canonicalizes raw numbers for JSON serialization. */
+class Escaper {
+ #options: EscaperOptions;
+ #regexEscapeForHTML: RegExp;
+ #regexLineSeparator: RegExp;
+ #regexParagraphSeparator: RegExp;
+
+ /**
+ * Creates a new Escaper instance with the given options.
+ *
+ * @param options Escaper configuration options.
+ */
+ constructor(options: EscaperOptions) {
+ this.#options = options;
+ this.#regexEscapeForHTML = new RegExp("[<>&]", "g");
+ this.#regexLineSeparator = new RegExp("\\u2028", "g");
+ this.#regexParagraphSeparator = new RegExp("\\u2029", "g");
+ }
+
+ /**
+ * Escapes raw UTF-8 bytes for a JSON string according to the configured HTML and JavaScript options.
+ *
+ * @param bytes The raw UTF-8 bytes of a JSON string.
+ * @returns The escaped UTF-8 bytes.
+ */
+ escapeString(bytes: Uint8Array): Uint8Array {
+ if (!this.#options.escapeForHTML && !this.#options.escapeForJS) {
+ if (!this.#options.allowInvalidUTF8) {
+ decodeText(bytes, true);
+ }
+
+ return bytes;
+ }
+
+ const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
+ const parsed = JSON.parse(decoded);
+ let encoded = JSON.stringify(parsed);
+
+ if (this.#options.escapeForHTML) {
+ encoded = encoded.replace(this.#regexEscapeForHTML, (substring) => {
+ if (substring === "<") {
+ return UNICODE.OPEN_ANGLED_BRACKET;
+ }
+
+ if (substring === ">") {
+ return UNICODE.CLOSE_ANGLED_BRACKET;
+ }
+
+ return UNICODE.AMPERSAND;
+ });
+ }
+
+ if (this.#options.escapeForJS) {
+ encoded = encoded
+ .replace(this.#regexLineSeparator, UNICODE.LINE_SEPARATOR)
+ .replace(this.#regexParagraphSeparator, UNICODE.PARAGRAPH_SEPARATOR);
+ }
+
+ return encodeText(encoded);
+ }
+
+ /**
+ * Canonicalizes raw UTF-8 bytes for a JSON number when canonicalization is enabled.
+ *
+ * @param bytes The raw UTF-8 bytes of a JSON number.
+ * @returns The canonicalized (or unchanged) UTF-8 bytes.
+ */
+ canonicalizeNumber(bytes: Uint8Array): Uint8Array {
+ if (!this.#options.canonicalizeRawNumbers) {
+ return bytes;
+ }
+
+ const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
+ const parsed = JSON.parse(decoded);
+
+ return encodeText(String(parsed));
+ }
+}
+
+export default Escaper;
+export type { EscaperOptions };
diff --git a/src/modules/formatter.ts b/src/modules/formatter.ts
new file mode 100644
index 0000000..0bf88f3
--- /dev/null
+++ b/src/modules/formatter.ts
@@ -0,0 +1,83 @@
+import { ASCII, KIND } from "#src/common/constants";
+import type { Kind } from "#src/common/types";
+import { encodeText } from "#src/utils/text";
+
+/** Options for {@link Formatter}. */
+type FormatterOptions = {
+ /** Indentation string used per nesting level when multiline is enabled. Defaults to a tab. */
+ indent: string;
+ /** Prefix prepended to every indented line when multiline is enabled. */
+ indentPrefix: string;
+ /** Emit each value on its own line with indentation. */
+ multiline: boolean;
+ /** Emit a space after each `:` separator in objects. */
+ spaceAfterColon: boolean;
+ /** Emit a space after each `,` separator in arrays and objects. */
+ spaceAfterComma: boolean;
+};
+
+/** Handles whitespace and indentation formatting for JSON serialization. */
+class Formatter {
+ #indentBytes: Uint8Array;
+ #indentPrefixBytes: Uint8Array;
+ #options: FormatterOptions;
+
+ /**
+ * Creates a new Formatter instance with the given options.
+ *
+ * @param options Formatter configuration options.
+ */
+ constructor(options: FormatterOptions) {
+ this.#indentBytes = encodeText(options.indent);
+ this.#indentPrefixBytes = encodeText(options.indentPrefix);
+ this.#options = options;
+ }
+
+ /**
+ * Computes whitespace chunks for a token or value.
+ *
+ * @param kind The {@link Kind} of token or value being formatted.
+ * @param delimiter The required delimiter (`:`, `,`, or `null`).
+ * @param depth The current structural nesting depth.
+ * @returns Byte chunks to insert before the token or value.
+ */
+ getWhitespace(kind: Kind, delimiter: ":" | "," | null, depth: number): Uint8Array[] {
+ const chunks: Uint8Array[] = [];
+
+ if (delimiter === ":") {
+ if (this.#options.spaceAfterColon) {
+ chunks.push(new Uint8Array([ASCII.SPACE]));
+ }
+
+ return chunks;
+ }
+
+ if (delimiter === "," && this.#options.spaceAfterComma) {
+ chunks.push(new Uint8Array([ASCII.SPACE]));
+ }
+
+ if (this.#options.multiline) {
+ if (depth === 1) {
+ return chunks;
+ }
+
+ const closed = kind === KIND.OBJECT_END || kind === KIND.ARRAY_END;
+ const levels = closed ? depth - 2 : depth - 1;
+
+ chunks.push(new Uint8Array([ASCII.LINE_FEED]));
+
+ if (this.#options.indentPrefix) {
+ chunks.push(this.#indentPrefixBytes);
+ }
+
+ for (let i = 0; i < levels; i++) {
+ chunks.push(this.#indentBytes);
+ }
+ }
+
+ return chunks;
+ }
+}
+
+export default Formatter;
+export type { FormatterOptions };
diff --git a/src/modules/parser.ts b/src/modules/parser.ts
new file mode 100644
index 0000000..b28d066
--- /dev/null
+++ b/src/modules/parser.ts
@@ -0,0 +1,282 @@
+import { KIND } from "#src/common/constants";
+import { SyntacticError } from "#src/common/errors";
+import type { Kind } from "#src/common/types";
+import type Pointer from "#src/modules/pointer";
+import Scanner from "#src/modules/scanner";
+import State from "#src/modules/state";
+import { decodeText } from "#src/utils/text";
+
+/** Options for {@link Parser}. */
+type ParserOptions = {
+ /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */
+ allowDuplicateNames: boolean;
+ /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */
+ allowInvalidUTF8: boolean;
+};
+
+/** Coordinates incremental JSON byte scanning with structural state validation. */
+class Parser {
+ #options: ParserOptions;
+ #scanner: Scanner;
+ #state: State;
+
+ /** Creates a new parser with the supplied decoding options. */
+ constructor(options: ParserOptions) {
+ this.#options = options;
+ this.#scanner = new Scanner();
+ this.#state = new State(options);
+ }
+
+ /** The current structural nesting depth. */
+ get depth(): number {
+ return this.#state.depth;
+ }
+
+ /** The absolute byte offset of the current scanner position. */
+ get inputOffset(): number {
+ return this.#scanner.offset;
+ }
+
+ /** The most recently read object property name. */
+ get lastObjectName(): string {
+ return this.#state.lastObjectName;
+ }
+
+ /** Whether the current object context expects a property name. */
+ get needObjectName(): boolean {
+ return this.#state.needsObjectName;
+ }
+
+ /** The unconsumed bytes currently retained by the scanner. */
+ get unreadBytes(): Uint8Array {
+ return this.#scanner.unreadBytes;
+ }
+
+ /**
+ * Verifies that the input is complete and contains no trailing characters.
+ *
+ * @throws {SyntaxError} If input ends inside a structure or contains trailing non-whitespace bytes.
+ */
+ checkEOF(): void {
+ if (this.#state.depth > 1) {
+ throw new SyntaxError("Unexpected end of input");
+ }
+
+ const offset = this.#scanner.findTrailingOffset();
+
+ if (offset !== undefined) {
+ throw new SyntaxError(`Unexpected trailing characters at position ${offset}`);
+ }
+ }
+
+ /** Marks the input as complete so unterminated final tokens can be diagnosed. */
+ close(): void {
+ this.#scanner.close();
+ }
+
+ /**
+ * Peeks at the next token kind without consuming it.
+ *
+ * @returns The next {@link Kind}, or `undefined` when more input is needed.
+ * @throws {SyntacticError} If the next bytes contain invalid JSON syntax or delimiters.
+ */
+ peekKind(): Kind | undefined {
+ try {
+ if (!this.#scanner.peekNext()) {
+ return undefined;
+ }
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ const pointer = this.#state.stackPointer(0).toString();
+ const offset = this.#scanner.offset;
+
+ throw new SyntacticError(error.message, pointer, offset);
+ }
+
+ throw error;
+ }
+
+ const kind = this.#scanner.kind;
+
+ if (!kind) {
+ const pointer = this.#state.stackPointer(0).toString();
+ const offset = this.#scanner.offset;
+
+ throw new SyntacticError("Invalid character", pointer, offset);
+ }
+
+ const expected = this.#state.requiredDelimiter(kind);
+ const delimiter = this.#scanner.delimiter;
+
+ if (expected !== delimiter) {
+ const pointer = this.#state.stackPointer(0).toString();
+ const offset = this.#scanner.offset;
+
+ throw new SyntacticError("Invalid delimiter", pointer, offset);
+ }
+
+ return kind;
+ }
+
+ /** Appends a chunk of JSON bytes to the input buffer. */
+ push(bytes: Uint8Array): void {
+ this.#scanner.appendBytes(bytes);
+ }
+
+ /**
+ * Consumes the next JSON token.
+ *
+ * @returns The raw token bytes, or `undefined` when more input is needed.
+ * @throws {SyntacticError} If the next token is invalid or violates the current structure.
+ */
+ readToken(): Uint8Array | undefined {
+ const kind = this.peekKind();
+
+ if (!kind) {
+ return;
+ }
+
+ try {
+ const consumed = this.#scanner.consumeToken(!this.#options.allowInvalidUTF8);
+
+ if (!consumed) {
+ return;
+ }
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ const pointer = this.#state.stackPointer(0).toString();
+ const offset = this.#scanner.offset;
+
+ throw new SyntacticError(error.message, pointer, offset);
+ }
+
+ throw error;
+ }
+
+ switch (kind) {
+ case KIND.NULL:
+ case KIND.TRUE:
+ case KIND.FALSE: {
+ this.#state.appendLiteral();
+ break;
+ }
+ case KIND.NUMBER: {
+ this.#state.appendNumber();
+ break;
+ }
+ case KIND.STRING: {
+ if (this.#state.needsObjectName) {
+ const text = decodeText(this.#scanner.span, !this.#options.allowInvalidUTF8);
+ const json = JSON.parse(text);
+
+ this.#state.setLast(json);
+ }
+
+ this.#state.appendString();
+ break;
+ }
+ case KIND.OBJECT_BEGIN: {
+ this.#state.pushObject();
+ break;
+ }
+ case KIND.OBJECT_END: {
+ this.#state.popObject();
+ break;
+ }
+ case KIND.ARRAY_BEGIN: {
+ this.#state.pushArray();
+ break;
+ }
+ case KIND.ARRAY_END: {
+ this.#state.popArray();
+ break;
+ }
+ }
+
+ return this.#scanner.span;
+ }
+
+ /**
+ * Consumes the next complete JSON value.
+ *
+ * @returns The raw value bytes, or `undefined` when more input is needed.
+ * @throws {SyntacticError} If the next value is invalid or violates the current structure.
+ */
+ readValue(): Uint8Array | undefined {
+ if (!this.peekKind() || !this.skipValue()) {
+ return;
+ }
+
+ return this.#scanner.span;
+ }
+
+ /** Resets the scanner and structural state to the initial state. */
+ reset(): void {
+ this.#scanner.reset();
+ this.#state.reset();
+ }
+
+ /**
+ * Consumes the next complete JSON value without returning its bytes.
+ *
+ * @returns `true` when a value was consumed, or `false` when more input is needed.
+ * @throws {SyntacticError} If the next value is invalid or violates the current structure.
+ */
+ skipValue(): boolean {
+ const kind = this.peekKind();
+
+ if (!kind) {
+ return false;
+ }
+
+ try {
+ const isStructural = kind === KIND.OBJECT_BEGIN || kind === KIND.ARRAY_BEGIN;
+ const consumed = isStructural
+ ? this.#scanner.consumeValue()
+ : this.#scanner.consumeToken(!this.#options.allowInvalidUTF8);
+
+ if (!consumed) {
+ return false;
+ }
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ const pointer = this.#state.stackPointer(0).toString();
+ const offset = this.#scanner.offset;
+
+ throw new SyntacticError(error.message, pointer, offset);
+ }
+
+ throw error;
+ }
+
+ if (kind === KIND.STRING) {
+ if (this.#state.needsObjectName) {
+ const decoded = decodeText(this.#scanner.span, !this.#options.allowInvalidUTF8);
+ const json = JSON.parse(decoded);
+
+ this.#state.setLast(json);
+ }
+
+ this.#state.appendString();
+ } else if (kind === KIND.NUMBER) {
+ this.#state.appendNumber();
+ } else {
+ this.#state.appendLiteral();
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns a JSON Pointer for a position relative to the current parser state.
+ *
+ * @param where Relative position: `-1` previous, `0` current, or `1` next.
+ * @returns A {@link Pointer} for the selected position.
+ */
+ stackPointer(where: 0 | 1 | -1 = 1): Pointer {
+ return this.#state.stackPointer(where);
+ }
+}
+
+export default Parser;
+export type { ParserOptions };
diff --git a/src/modules/path.ts b/src/modules/path.ts
index 63861cd..3feb6c4 100644
--- a/src/modules/path.ts
+++ b/src/modules/path.ts
@@ -6,7 +6,7 @@ import type {
Segment,
Selector,
WildcardSelector,
-} from "#src/types/path";
+} from "#src/common/types";
import { decodeText } from "#src/utils/text";
import { consumeNumber, consumeWhitespace } from "#src/utils/wire";
@@ -19,10 +19,9 @@ import { consumeNumber, consumeWhitespace } from "#src/utils/wire";
* - **Descendant Segment**: `..`
* - **Name Selector**: `.name` or `['name']`
* - **Wildcard Selector**: `.*` or `[*]`
- * - **Index Selector (positive)**: `[1]`
- * - **Array Slice Selector (positive)**: `[0:5]` or `[::2]`
+ * - **Index Selector (non-negative)**: `[1]`
+ * - **Array Slice Selector (non-negative)**: `[0:5]` or `[::2]`
*
- * @internal
* @see https://www.rfc-editor.org/rfc/rfc9535
* @example
* ```javascript
@@ -332,11 +331,7 @@ class Path {
}
}
-/**
- * NFA implementation for JSON Path matching.
- *
- * @internal
- */
+/** NFA implementation for JSON Path matching. */
class Matcher {
#segments: Segment[];
#stack: number[];
@@ -444,9 +439,7 @@ class Matcher {
this.#stack.push(next);
}
- /**
- * Pops the last step from the matcher state.
- */
+ /** Pops the last step from the matcher state. */
pop(): void {
this.#stack.pop();
}
diff --git a/src/modules/pointer.ts b/src/modules/pointer.ts
index 2d7cd1c..94174a9 100644
--- a/src/modules/pointer.ts
+++ b/src/modules/pointer.ts
@@ -1,8 +1,4 @@
-/**
- * Represents a JSON Pointer as defined in RFC 6901.
- *
- * @internal
- */
+/** Represents a JSON Pointer and its unescaped reference tokens. */
class Pointer {
#tokens: string[];
@@ -23,9 +19,9 @@ class Pointer {
/**
* Parses a JSON Pointer string into a `Pointer` instance.
*
- * @param value The RFC 6901 compliant pointer string (e.g., `""` or `"/foo/bar"`).
+ * @param value A JSON Pointer string such as `""` or `"/foo/bar"`.
* @returns A parsed `Pointer` object.
- * @throws {TypeError} If the string is not empty and does not start with a slash (`/`).
+ * @throws {TypeError} If the string is not empty and does not start with `/`.
* @example
* ```javascript
* Pointer.parse("").tokens // []
@@ -72,7 +68,7 @@ class Pointer {
}
/**
- * Serializes the Pointer instance back into an RFC 6901 compliant string.
+ * Serializes the pointer back into a JSON Pointer string.
*
* @returns The serialized pointer string, or `""` for the root pointer.
* @example
diff --git a/src/modules/scanner.ts b/src/modules/scanner.ts
new file mode 100644
index 0000000..eaa91df
--- /dev/null
+++ b/src/modules/scanner.ts
@@ -0,0 +1,298 @@
+import { ASCII, KIND } from "#src/common/constants";
+import type { Kind } from "#src/common/types";
+import Cursor from "#src/modules/cursor";
+import Skipper from "#src/modules/skipper";
+import { normalize } from "#src/utils/kind";
+import {
+ consumeFalse,
+ consumeNull,
+ consumeNumber,
+ consumeSimpleNumber,
+ consumeSimpleString,
+ consumeStringResumable,
+ consumeTrue,
+ consumeWhitespace,
+} from "#src/utils/wire";
+
+/** Scans JSON input incrementally and exposes the next token or complete value span. */
+class Scanner {
+ #cursor: Cursor;
+ #skipper: Skipper;
+ #position: number;
+ #checkpoint: number;
+ #start: number;
+ #length: number;
+ #kind: Kind | undefined;
+ #delimiter: ":" | "," | null;
+
+ /** Creates a new scanner with an empty input buffer. */
+ constructor() {
+ this.#cursor = new Cursor();
+ this.#skipper = new Skipper();
+ this.#position = 0;
+ this.#checkpoint = 0;
+ this.#start = 0;
+ this.#length = 0;
+ this.#kind = undefined;
+ this.#delimiter = null;
+ }
+
+ /** The delimiter found immediately before the current token, if any. */
+ get delimiter(): ":" | "," | null {
+ return this.#delimiter;
+ }
+
+ /** The kind of the next token, if one is ready. */
+ get kind(): Kind | undefined {
+ return this.#kind;
+ }
+
+ /** The absolute byte offset of the current scan position. */
+ get offset(): number {
+ return this.#cursor.at(this.#position);
+ }
+
+ /** The raw bytes of the most recently consumed token or value. */
+ get span(): Uint8Array {
+ return this.#cursor.bytes.subarray(this.#start, this.#start + this.#length);
+ }
+
+ /** The bytes that have not yet been consumed. */
+ get unreadBytes(): Uint8Array {
+ return this.#cursor.bytes.subarray(this.#position);
+ }
+
+ /** Appends an input chunk while retaining any incomplete token. */
+ appendBytes(chunk: Uint8Array): void {
+ this.#cursor.append(chunk, this.#start);
+
+ if (this.#position >= this.#start) {
+ this.#position -= this.#start;
+ } else {
+ this.#position = 0;
+ }
+
+ this.#start = 0;
+ }
+
+ /**
+ * Consumes the currently prepared token.
+ *
+ * @param validateUTF8 Whether string tokens must contain valid UTF-8.
+ * @returns `true` when a complete token was consumed, or `false` when more input is needed.
+ * @throws {SyntaxError} If the token contains invalid literal or string syntax.
+ */
+ consumeToken(validateUTF8: boolean): boolean {
+ if (!this.#kind) {
+ return false;
+ }
+
+ let size = 0;
+
+ switch (this.#kind) {
+ case KIND.NULL: {
+ const consumed = consumeNull(this.#cursor.bytes, this.#position);
+ const failed = consumed === 0;
+ const complete = this.#cursor.bytes.length - this.#position >= 4;
+
+ if (failed && complete) {
+ throw new SyntaxError("Invalid literal null");
+ }
+
+ size = consumed;
+ break;
+ }
+ case KIND.TRUE: {
+ const consumed = consumeTrue(this.#cursor.bytes, this.#position);
+ const failed = consumed === 0;
+ const complete = this.#cursor.bytes.length - this.#position >= 4;
+
+ if (failed && complete) {
+ throw new SyntaxError("Invalid literal true");
+ }
+
+ size = consumed;
+ break;
+ }
+ case KIND.FALSE: {
+ const consumed = consumeFalse(this.#cursor.bytes, this.#position);
+ const failed = consumed === 0;
+ const complete = this.#cursor.bytes.length - this.#position >= 5;
+
+ if (failed && complete) {
+ throw new SyntaxError("Invalid literal false");
+ }
+
+ size = consumed;
+ break;
+ }
+ case KIND.NUMBER: {
+ let consumed = consumeSimpleNumber(this.#cursor.bytes, this.#position);
+
+ if (consumed === 0) {
+ consumed = consumeNumber(this.#cursor.bytes, this.#position);
+ }
+
+ if (consumed === 0) {
+ return false;
+ }
+
+ const exhausted = this.#position + consumed === this.#cursor.bytes.length;
+ const active = !this.#cursor.ended;
+
+ if (exhausted && active) {
+ return false;
+ }
+
+ size = consumed;
+ break;
+ }
+ case KIND.STRING: {
+ const resuming = this.#checkpoint !== 0;
+
+ if (!resuming) {
+ const consumed = consumeSimpleString(this.#cursor.bytes, this.#position);
+
+ if (consumed !== 0) {
+ size = consumed;
+ break;
+ }
+ }
+
+ const { completed, consumed } = consumeStringResumable(
+ this.#cursor.bytes,
+ this.#position,
+ this.#checkpoint,
+ validateUTF8,
+ );
+
+ if (!completed) {
+ return (this.#checkpoint = consumed, false);
+ }
+
+ this.#checkpoint = 0;
+ size = consumed;
+ break;
+ }
+ case KIND.OBJECT_BEGIN:
+ case KIND.OBJECT_END:
+ case KIND.ARRAY_BEGIN:
+ case KIND.ARRAY_END: {
+ size = 1;
+ break;
+ }
+ }
+
+ if (size === 0) {
+ return false;
+ }
+
+ this.#start = this.#position;
+ this.#length = size;
+ this.#position += size;
+ this.#kind = undefined;
+ this.#delimiter = null;
+ this.#skipper.reset();
+
+ return true;
+ }
+
+ /**
+ * Consumes the currently prepared complete value as one span.
+ *
+ * @returns `true` when a complete value was consumed, or `false` when more input is needed.
+ */
+ consumeValue(): boolean {
+ if (!this.#kind) {
+ return false;
+ }
+
+ const size = this.#skipper.skip(this.#cursor.bytes, this.#position);
+
+ if (size === 0) {
+ return false;
+ }
+
+ this.#start = this.#position;
+ this.#length = size;
+ this.#position += size;
+ this.#kind = undefined;
+ this.#delimiter = null;
+ this.#skipper.reset();
+
+ return true;
+ }
+
+ /** Marks the input as complete. */
+ close(): void {
+ this.#cursor.close();
+ }
+
+ /**
+ * Finds the first non-whitespace byte after the most recently consumed value.
+ *
+ * @returns The absolute offset of trailing content, or `undefined` when none exists.
+ */
+ findTrailingOffset(): number | undefined {
+ const position = consumeWhitespace(this.#cursor.bytes, this.#start + this.#length);
+
+ if (position < this.#cursor.bytes.length) {
+ return this.#cursor.at(position);
+ }
+
+ return;
+ }
+
+ /**
+ * Scans whitespace and prepares the next token kind without consuming it.
+ *
+ * @returns `true` when a token is ready, or `false` when more input is needed.
+ * @throws {SyntaxError} If the next non-whitespace byte cannot begin a JSON token.
+ */
+ peekNext(): boolean {
+ if (this.#kind) {
+ return true;
+ }
+
+ this.#position = consumeWhitespace(this.#cursor.bytes, this.#position);
+
+ if (this.#position >= this.#cursor.bytes.length) {
+ return false;
+ }
+
+ const current = this.#cursor.bytes[this.#position];
+
+ if (current === ASCII.COLON || current === ASCII.COMMA) {
+ this.#delimiter = String.fromCharCode(current) as ":" | ",";
+ this.#position++;
+ this.#position = consumeWhitespace(this.#cursor.bytes, this.#position);
+
+ if (this.#position >= this.#cursor.bytes.length) {
+ return false;
+ }
+ }
+
+ const next = this.#cursor.bytes[this.#position];
+ const kind = normalize(next);
+
+ if (!kind) {
+ throw new SyntaxError(`Invalid character '${String.fromCharCode(next)}'`);
+ }
+
+ return (this.#kind = kind, true);
+ }
+
+ /** Resets the scanner and clears all retained input state. */
+ reset(): void {
+ this.#cursor.reset();
+ this.#skipper.reset();
+ this.#position = 0;
+ this.#checkpoint = 0;
+ this.#start = 0;
+ this.#length = 0;
+ this.#kind = undefined;
+ this.#delimiter = null;
+ }
+}
+
+export default Scanner;
diff --git a/src/modules/serializer.ts b/src/modules/serializer.ts
new file mode 100644
index 0000000..09a8368
--- /dev/null
+++ b/src/modules/serializer.ts
@@ -0,0 +1,276 @@
+import { ASCII, KIND } from "#src/common/constants";
+import { SyntacticError } from "#src/common/errors";
+import type { Kind } from "#src/common/types";
+import Escaper from "#src/modules/escaper";
+import Formatter from "#src/modules/formatter";
+import type Pointer from "#src/modules/pointer";
+import State from "#src/modules/state";
+import Tape from "#src/modules/tape";
+import { decodeText, encodeText } from "#src/utils/text";
+
+/** Options for {@link Serializer}. */
+type SerializerOptions = {
+ /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */
+ allowDuplicateNames: boolean;
+ /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */
+ allowInvalidUTF8: boolean;
+ /** Normalize number tokens to their canonical decimal form. */
+ canonicalizeRawNumbers: boolean;
+ /** Escape `<`, `>`, and `&` for safe embedding in HTML. */
+ escapeForHTML: boolean;
+ /** Escape `\u2028` and `\u2029` for safe embedding in JavaScript string literals. */
+ escapeForJS: boolean;
+ /** Indentation string used per nesting level when multiline is enabled. Defaults to a tab. */
+ indent: string;
+ /** Prefix prepended to every indented line when multiline is enabled. */
+ indentPrefix: string;
+ /** Emit each value on its own line with indentation. */
+ multiline: boolean;
+ /** Emit a space after each `:` separator in objects. */
+ spaceAfterColon: boolean;
+ /** Emit a space after each `,` separator in arrays and objects. */
+ spaceAfterComma: boolean;
+};
+
+/** Low-level JSON serializer that writes tokens and values onto an internal {@link Tape}, enforcing RFC 8259 structural rules and applying optional formatting and escaping. */
+class Serializer {
+ #options: SerializerOptions;
+ #escaper: Escaper;
+ #formatter: Formatter;
+ #state: State;
+ #tape: Tape;
+ #trueBytes: Uint8Array;
+ #falseBytes: Uint8Array;
+ #nullBytes: Uint8Array;
+
+ /**
+ * Creates a new Serializer instance with the given options.
+ *
+ * @param options Serializer configuration options.
+ */
+ constructor(options: SerializerOptions) {
+ this.#options = options;
+ this.#escaper = new Escaper(options);
+ this.#formatter = new Formatter(options);
+ this.#state = new State(options);
+ this.#tape = new Tape();
+ this.#trueBytes = encodeText(KIND.TRUE);
+ this.#falseBytes = encodeText(KIND.FALSE);
+ this.#nullBytes = encodeText(KIND.NULL);
+ }
+
+ /** The current structural nesting depth. */
+ get depth(): number {
+ return this.#state.depth;
+ }
+
+ /** The absolute output byte offset accumulated across all {@link takeBytes} calls since creation. */
+ get outputOffset(): number {
+ return this.#tape.outputOffset;
+ }
+
+ /**
+ * Generates a JSON Pointer representing a location relative to the current
+ * serialization position.
+ *
+ * @param where Relative position: `-1` previous, `0` current, or `1` next.
+ * @returns A {@link Pointer} for the selected position.
+ */
+ stackPointer(where: 0 | 1 | -1): Pointer {
+ return this.#state.stackPointer(where);
+ }
+
+ /** Resets the serializer to its initial state, clearing the output buffer and all structural state. */
+ reset(): void {
+ this.#state.reset();
+ this.#tape.reset();
+ }
+
+ /**
+ * Extracts the written bytes as a slice and advances the base output offset,
+ * preparing the tape for the next chunk (streaming use).
+ *
+ * @returns A copy of the bytes written since the last `takeBytes` call.
+ */
+ takeBytes(): Uint8Array {
+ return this.#tape.takeBytes();
+ }
+
+ /**
+ * Serializes a single token onto the tape.
+ *
+ * Automatically inserts structural delimiters (`:` or `,`) and any
+ * configured whitespace or indentation before the token. On error, the
+ * tape is rolled back to its pre-call length.
+ *
+ * @param kind The {@link Kind} of token to write.
+ * @param bytes Raw UTF-8 bytes of the token.
+ * @throws {SyntacticError} If the token is invalid at the current position.
+ */
+ writeToken(kind: Kind, bytes: Uint8Array): void {
+ const length = this.#tape.length;
+ const delimiter = this.#state.requiredDelimiter(kind);
+
+ try {
+ if (delimiter === ":") {
+ this.#tape.appendByte(ASCII.COLON);
+ } else if (delimiter === ",") {
+ this.#tape.appendByte(ASCII.COMMA);
+ }
+
+ for (const bytes of this.#formatter.getWhitespace(kind, delimiter, this.#state.depth)) {
+ this.#tape.appendBytes(bytes);
+ }
+
+ switch (kind) {
+ case KIND.NULL:
+ this.#tape.appendBytes(this.#nullBytes);
+ this.#state.appendLiteral();
+ break;
+ case KIND.TRUE:
+ this.#tape.appendBytes(this.#trueBytes);
+ this.#state.appendLiteral();
+ break;
+ case KIND.FALSE:
+ this.#tape.appendBytes(this.#falseBytes);
+ this.#state.appendLiteral();
+ break;
+ case KIND.STRING: {
+ const escaped = this.#escaper.escapeString(bytes);
+
+ this.#tape.appendBytes(escaped);
+
+ if (this.#state.needsObjectName) {
+ const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
+ const parsed = JSON.parse(decoded);
+
+ this.#state.setLast(parsed);
+ }
+
+ this.#state.appendString();
+ break;
+ }
+ case KIND.NUMBER: {
+ const escaped = this.#escaper.canonicalizeNumber(bytes);
+
+ this.#tape.appendBytes(escaped);
+ this.#state.appendNumber();
+ break;
+ }
+ case KIND.OBJECT_BEGIN:
+ this.#tape.appendByte(ASCII.OPENING_BRACE);
+ this.#state.pushObject();
+ break;
+ case KIND.OBJECT_END:
+ this.#tape.appendByte(ASCII.CLOSING_BRACE);
+ this.#state.popObject();
+ break;
+ case KIND.ARRAY_BEGIN:
+ this.#tape.appendByte(ASCII.OPENING_BRACKET);
+ this.#state.pushArray();
+ break;
+ case KIND.ARRAY_END:
+ this.#tape.appendByte(ASCII.CLOSING_BRACKET);
+ this.#state.popArray();
+ break;
+ }
+ } catch (error) {
+ this.#tape.truncate(length);
+
+ if (error instanceof SyntacticError) {
+ throw error;
+ }
+
+ if (error instanceof SyntaxError) {
+ const pointer = this.#state.stackPointer(1).toString();
+ const offset = this.#tape.outputOffset;
+
+ throw new SyntacticError(error.message, pointer, offset);
+ }
+
+ throw error;
+ }
+ }
+
+ /**
+ * Serializes a complete pre-parsed value onto the tape.
+ *
+ * Automatically inserts structural delimiters and any configured whitespace
+ * before the value. On error, the tape is rolled back to its pre-call length.
+ *
+ * @param kind The {@link Kind} of value to write.
+ * @param bytes Raw UTF-8 bytes of the complete value.
+ * @throws {SyntacticError} If the value is invalid at the current position.
+ */
+ writeValue(kind: Kind, bytes: Uint8Array): void {
+ const length = this.#tape.length;
+ const delimiter = this.#state.requiredDelimiter(kind);
+
+ try {
+ if (delimiter === ":") {
+ this.#tape.appendByte(ASCII.COLON);
+ } else if (delimiter === ",") {
+ this.#tape.appendByte(ASCII.COMMA);
+ }
+
+ for (const bytes of this.#formatter.getWhitespace(kind, delimiter, this.#state.depth)) {
+ this.#tape.appendBytes(bytes);
+ }
+
+ if (kind === KIND.STRING) {
+ bytes = this.#escaper.escapeString(bytes);
+ } else if (kind === KIND.NUMBER) {
+ bytes = this.#escaper.canonicalizeNumber(bytes);
+ }
+
+ this.#tape.appendBytes(bytes);
+
+ switch (kind) {
+ case KIND.NULL:
+ case KIND.TRUE:
+ case KIND.FALSE:
+ this.#state.appendLiteral();
+ break;
+ case KIND.STRING: {
+ if (this.#state.needsObjectName) {
+ const decoded = decodeText(bytes, !this.#options.allowInvalidUTF8);
+ const parsed = JSON.parse(decoded);
+
+ this.#state.setLast(parsed);
+ }
+ this.#state.appendString();
+ break;
+ }
+ case KIND.NUMBER:
+ this.#state.appendNumber();
+ break;
+ case KIND.OBJECT_BEGIN:
+ this.#state.pushObject();
+ this.#state.popObject();
+ break;
+ case KIND.ARRAY_BEGIN:
+ this.#state.pushArray();
+ this.#state.popArray();
+ break;
+ }
+ } catch (error) {
+ this.#tape.truncate(length);
+
+ if (error instanceof SyntacticError) {
+ throw error;
+ }
+
+ if (error instanceof SyntaxError) {
+ const pointer = this.#state.stackPointer(1).toString();
+ const offset = this.#tape.outputOffset;
+
+ throw new SyntacticError(error.message, pointer, offset);
+ }
+
+ throw error;
+ }
+ }
+}
+
+export default Serializer;
+export type { SerializerOptions };
diff --git a/src/modules/skipper.ts b/src/modules/skipper.ts
new file mode 100644
index 0000000..1cc9410
--- /dev/null
+++ b/src/modules/skipper.ts
@@ -0,0 +1,80 @@
+import { ASCII } from "#src/common/constants";
+
+/** Skips complete JSON values while preserving progress across input chunks. */
+class Skipper {
+ #depth: number;
+ #inString: boolean;
+ #inStringEscape: boolean;
+ #offset: number;
+
+ /** Creates a new value skipper. */
+ constructor() {
+ this.#depth = 0;
+ this.#inString = false;
+ this.#inStringEscape = false;
+ this.#offset = 0;
+ }
+
+ /** Resets the skipper to its initial state. */
+ reset(): void {
+ this.#depth = 0;
+ this.#inString = false;
+ this.#inStringEscape = false;
+ this.#offset = 0;
+ }
+
+ /**
+ * Scans for the end of the JSON value beginning at `start`.
+ *
+ * @param bytes The input bytes to scan.
+ * @param start The position at which the value begins.
+ * @returns The value length, or `0` when more input is needed.
+ */
+ skip(bytes: Uint8Array, start: number): number {
+ let current = start + this.#offset;
+
+ while (current < bytes.length) {
+ const byte = bytes[current];
+
+ if (this.#inString) {
+ if (this.#inStringEscape) {
+ this.#inStringEscape = false;
+ } else if (byte === ASCII.BACKSLASH) {
+ this.#inStringEscape = true;
+ } else if (byte === ASCII.QUOTE) {
+ this.#inString = false;
+ }
+
+ current++;
+ continue;
+ }
+
+ switch (byte) {
+ case ASCII.QUOTE: {
+ this.#inString = true;
+ break;
+ }
+ case ASCII.OPENING_BRACE:
+ case ASCII.OPENING_BRACKET: {
+ this.#depth++;
+ break;
+ }
+ case ASCII.CLOSING_BRACE:
+ case ASCII.CLOSING_BRACKET: {
+ this.#depth--;
+ break;
+ }
+ }
+
+ current++;
+
+ if (this.#depth === 0) {
+ return (this.#offset = 0, current - start);
+ }
+ }
+
+ return (this.#offset = current - start, 0);
+ }
+}
+
+export default Skipper;
diff --git a/src/modules/stack.ts b/src/modules/stack.ts
index 262195a..7afbc65 100644
--- a/src/modules/stack.ts
+++ b/src/modules/stack.ts
@@ -1,14 +1,8 @@
-/**
- * A specialized stack for tracking the current property names of nested JSON objects.
- *
- * @internal
- */
+/** A specialized stack for tracking the current property names of nested JSON objects. */
class ObjectNameStack {
#names: Array;
- /**
- * Creates a new ObjectNameStack instance.
- */
+ /** Creates a new ObjectNameStack instance. */
constructor() {
this.#names = [];
}
@@ -29,7 +23,7 @@ class ObjectNameStack {
}
/**
- * Retrieves the property name of the deepest (currently active) object context.
+ * Retrieves the property name of the deepest active object context.
*
* @returns The current property name, or an empty string if the stack is empty.
*/
@@ -37,20 +31,21 @@ class ObjectNameStack {
return this.#names[this.#names.length - 1] ?? "";
}
- /**
- * Pushes a new, empty name context onto the stack when entering a new JSON object.
- */
+ /** Pushes a new, empty name context when entering a JSON object. */
pushObject(): void {
this.#names.push("");
}
- /**
- * Pops the deepest name context from the stack when exiting a JSON object.
- */
+ /** Pops the deepest name context when exiting a JSON object. */
popObject(): void {
this.#names.pop();
}
+ /** Resets the stack and clears all tracked names. */
+ reset(): void {
+ this.#names.length = 0;
+ }
+
/**
* Sets the active property name for the current object context.
*
@@ -61,35 +56,15 @@ class ObjectNameStack {
}
}
-/**
- * A specialized stack for tracking the uniqueness of property names within nested JSON objects.
- *
- * @internal
- */
+/** A specialized stack for tracking property-name uniqueness within nested JSON objects. */
class ObjectNamespaceStack {
#namespaces: Array>;
- /**
- * Creates a new ObjectNamespaceStack instance.
- */
+ /** Creates a new ObjectNamespaceStack instance. */
constructor() {
this.#namespaces = [];
}
- /**
- * Pushes a new, empty namespace Set when entering a new JSON object.
- */
- pushObject(): void {
- this.#namespaces.push(new Set());
- }
-
- /**
- * Pops and discards the namespace Set when exiting a JSON object.
- */
- popObject(): void {
- this.#namespaces.pop();
- }
-
/**
* Attempts to insert a property name into the current namespace Set.
*
@@ -107,6 +82,21 @@ class ObjectNamespaceStack {
return true;
}
+
+ /** Pushes a new, empty namespace set when entering a JSON object. */
+ pushObject(): void {
+ this.#namespaces.push(new Set());
+ }
+
+ /** Pops and discards the namespace set when exiting a JSON object. */
+ popObject(): void {
+ this.#namespaces.pop();
+ }
+
+ /** Resets the stack and clears all tracked namespaces. */
+ reset(): void {
+ this.#namespaces.length = 0;
+ }
}
export { ObjectNamespaceStack, ObjectNameStack };
diff --git a/src/modules/state.ts b/src/modules/state.ts
index 1de2b43..b2cb63e 100644
--- a/src/modules/state.ts
+++ b/src/modules/state.ts
@@ -1,32 +1,53 @@
+import type { Kind } from "#src/common/types";
import Automaton from "#src/modules/automaton";
import Pointer from "#src/modules/pointer";
import { ObjectNamespaceStack, ObjectNameStack } from "#src/modules/stack";
-import type { Kind } from "#src/types/kind";
-import type { BaseOptions } from "#src/types/options";
-
-/**
- * The central coordinator for the decoding/encoding state.
- *
- * @internal
- */
+
+/** Options for {@link State}. */
+type StateOptions = {
+ /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */
+ allowDuplicateNames: boolean;
+};
+
+/** Coordinates syntax validation and location tracking for decoding and encoding. */
class State {
#automaton: Automaton;
#names: ObjectNameStack;
#namespaces: ObjectNamespaceStack;
- #options: BaseOptions;
+ #options: StateOptions;
/**
* Creates a new State coordinator.
*
* @param options Decoder/encoder configuration options.
*/
- constructor(options: BaseOptions) {
+ constructor(options: StateOptions) {
this.#automaton = new Automaton();
this.#names = new ObjectNameStack();
this.#namespaces = new ObjectNamespaceStack();
this.#options = options;
}
+ /** The current structural nesting depth. */
+ get depth(): number {
+ return this.#automaton.depth;
+ }
+
+ /** The last object name processed in the current context. */
+ get lastObjectName(): string {
+ return this.#names.getLast();
+ }
+
+ /** Checks if the current context expects an object name. */
+ get needsObjectName(): boolean {
+ return this.#automaton.last.needsObjectName;
+ }
+
+ /** Checks if the current context expects an object value. */
+ get needsObjectValue(): boolean {
+ return this.#automaton.last.needsObjectValue;
+ }
+
/**
* Asserts and appends a generic literal value to the current context.
*
@@ -36,9 +57,7 @@ class State {
this.#automaton.appendLiteral();
}
- /**
- * Appends a string value to the current context.
- */
+ /** Appends a string value to the current context. */
appendString(): void {
this.#automaton.appendString();
}
@@ -52,57 +71,21 @@ class State {
this.#automaton.appendNumber();
}
- /**
- * Returns the current nesting depth of the structural state.
- *
- * @returns The current nesting depth — `1` at the top level, incremented by each open object or array.
- */
- depth(): number {
- return this.#automaton.depth();
- }
-
/**
* Determines if a delimiter (`:` or `,`) is required before the next token.
*
- * @param kind The kind of the next incoming token.
- * @returns `":"` if a colon is needed, `","` if a comma is needed, or `null` if no delimiter is expected.
- */
- needDelimiter(kind: Kind): ":" | "," | null {
- return this.#automaton.needDelimiter(kind);
- }
-
- /**
- * Checks if the current context expects an object key.
- *
- * @returns `true` if the next token must be a string serving as an object name, `false` otherwise.
- */
- needObjectName(): boolean {
- return this.#automaton.last.needObjectName();
- }
-
- /**
- * Checks if the current context expects an object value.
- *
- * @returns `true` if an object value is needed, `false` otherwise.
+ * @param kind The kind of the next token.
+ * @returns `":"` for a colon, `","` for a comma, or `null`.
*/
- needObjectValue(): boolean {
- return this.#automaton.last.needObjectValue();
- }
-
- /**
- * Retrieves the name of the currently active object property.
- *
- * @returns The current object property name, or an empty string if not inside an object.
- */
- lastObjectName(): string {
- return this.#names.getLast();
+ requiredDelimiter(kind: Kind): ":" | "," | null {
+ return this.#automaton.requiredDelimiter(kind);
}
/**
* Pushes a new array structure, synchronizing the underlying automaton.
*
- * @throws {SyntaxError} If the parent context requires an object name.
- * @throws {RangeError} If the maximum nesting depth is exceeded.
+ * @throws {SyntaxError} If an object name is required.
+ * @throws {RangeError} If maximum nesting depth is exceeded.
*/
pushArray(): void {
this.#automaton.pushArray();
@@ -111,7 +94,7 @@ class State {
/**
* Pops the current array structure.
*
- * @throws {SyntaxError} If the current context is not an array, or if it is prematurely closed.
+ * @throws {SyntaxError} If the context is not an array or closes prematurely.
*/
popArray(): void {
this.#automaton.popArray();
@@ -121,8 +104,8 @@ class State {
* Pushes a new object structure.
* Synchronizes the syntax automaton, name stack, and namespace stack.
*
- * @throws {SyntaxError} If the parent context requires an object name.
- * @throws {RangeError} If the maximum nesting depth is exceeded.
+ * @throws {SyntaxError} If an object name is required.
+ * @throws {RangeError} If maximum nesting depth is exceeded.
*/
pushObject(): void {
this.#automaton.pushObject();
@@ -133,7 +116,7 @@ class State {
/**
* Pops the current object structure, cleaning up the associated name and namespace tracking.
*
- * @throws {SyntaxError} If the current context is not an object, or if it is prematurely closed while expecting a value.
+ * @throws {SyntaxError} If the context is not an object or closes before a value.
*/
popObject(): void {
this.#automaton.popObject();
@@ -141,11 +124,20 @@ class State {
this.#namespaces.popObject();
}
+ /**
+ * Resets the entire state, clearing the automaton, name stack, and namespace stack.
+ */
+ reset(): void {
+ this.#automaton.reset();
+ this.#names.reset();
+ this.#namespaces.reset();
+ }
+
/**
* Sets the name for the current object property and validates it against duplicates.
*
* @param name The object key being processed.
- * @throws {SyntaxError} If the name already exists in the current object and `allowDuplicateNames` is false.
+ * @throws {SyntaxError} If the name is a duplicate and duplicates are disallowed.
*/
setLast(name: string): void {
this.#names.setLast(name);
@@ -163,8 +155,8 @@ class State {
* Dynamically generates a JSON Pointer (RFC 6901) representing a specific location
* in the JSON structure relative to the current state.
*
- * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value.
- * @returns A `Pointer` instance representing the absolute path.
+ * @param where Relative position: `-1` previous, `0` current, or `1` next.
+ * @returns A `Pointer` for the selected position.
* @example
* ```javascript
* // Object { "a": [...] } — one element at "/a/0" has just been written.
@@ -179,29 +171,29 @@ class State {
const tokens: string[] = [];
let depth = 0;
- for (let index = 1; index < this.#automaton.depth(); index++) {
+ for (let index = 1; index < this.#automaton.depth; index++) {
const entry = this.#automaton.getEntry(index);
let delta = -1;
- if (index === this.#automaton.depth() - 1) {
- const isEmpty = where < 0 && entry.count() === 0;
- const isNotInObject = where === 0 && !entry.needObjectValue();
- const isExpectingName = where > 0 && entry.needObjectName();
+ if (index === this.#automaton.depth - 1) {
+ const isEmpty = where < 0 && entry.count === 0;
+ const isNotInObject = where === 0 && !entry.needsObjectValue;
+ const isExpectingName = where > 0 && entry.needsObjectName;
if (isEmpty || isNotInObject || isExpectingName) {
return new Pointer(tokens);
}
- if (where > 0 && entry.isArray()) {
+ if (where > 0 && entry.isArray) {
delta = 0;
}
}
- if (entry.isObject()) {
+ if (entry.isObject) {
tokens.push(this.#names.getObjectName(depth));
depth++;
} else {
- tokens.push(String(entry.count() + delta));
+ tokens.push(String(entry.count + delta));
}
}
@@ -210,3 +202,4 @@ class State {
}
export default State;
+export type { StateOptions };
diff --git a/src/modules/tape.ts b/src/modules/tape.ts
index 8ad2304..c383b80 100644
--- a/src/modules/tape.ts
+++ b/src/modules/tape.ts
@@ -1,16 +1,10 @@
-/**
- * A dynamic, pre-allocated buffer used as the primary write destination for the Encoder.
- *
- * @internal
- */
+/** A dynamic, pre-allocated buffer used as the primary write destination for the encoder. */
class Tape {
#baseOffset: number;
#length: number;
#bytes: Uint8Array;
- /**
- * Creates a new Tape instance with an initial buffer size of 256 bytes.
- */
+ /** Creates a new Tape instance with an initial buffer size of 256 bytes. */
constructor() {
this.#baseOffset = 0;
this.#length = 0;
@@ -22,6 +16,16 @@ class Tape {
return this.#length;
}
+ /** The underlying byte buffer, including any unwritten capacity. */
+ get bytes(): Uint8Array {
+ return this.#bytes.subarray(0, this.#length);
+ }
+
+ /** The absolute offset of the first byte in the buffer, relative to the start of the stream. */
+ get outputOffset(): number {
+ return this.#baseOffset + this.#length;
+ }
+
/**
* Appends a single byte (0-255) to the buffer.
* Doubles the underlying capacity if the buffer is full.
@@ -53,37 +57,16 @@ class Tape {
this.#length += bytes.length;
}
- /**
- * Returns a view of the currently written bytes without advancing the output offset.
- *
- * @returns A subarray of the written bytes.
- */
- bytes(): Uint8Array {
- return this.#bytes.subarray(0, this.#length);
- }
-
- /**
- * Returns the absolute output byte offset, accumulating across all
- * {@link takeBytes} calls since the last {@link reset}.
- *
- * @returns The absolute output byte offset.
- */
- outputOffset(): number {
- return this.#baseOffset + this.#length;
- }
-
- /**
- * Resets the buffer, clearing all written bytes and resetting the base offset.
- */
+ /** Resets the buffer, clearing all written bytes and resetting the base offset. */
reset(): void {
this.#length = 0;
this.#baseOffset = 0;
}
/**
- * Extracts the currently written bytes and prepares the Tape for the next chunk of data.
- * This method advances the base offset and resets the length pointer to 0,
- * allowing the internal buffer to be safely overwritten (Buffer Reuse).
+ * Extracts the currently written bytes and prepares the tape for the next chunk of data.
+ * This advances the base offset and resets the length pointer to `0`, allowing the internal
+ * buffer to be safely overwritten.
*
* @returns A copy (slice) of the bytes written so far.
*/
diff --git a/src/types/kind.ts b/src/types/kind.ts
deleted file mode 100644
index bef6c5c..0000000
--- a/src/types/kind.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { KIND } from "#src/common/constants";
-
-/**
- * A union type of all possible JSON token kind values, corresponding to the
- * string discriminants in {@link KIND}.
- *
- * @public
- */
-export type Kind = typeof KIND[keyof typeof KIND];
diff --git a/src/types/options.ts b/src/types/options.ts
deleted file mode 100644
index 0b77233..0000000
--- a/src/types/options.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Base options for {@link DecoderOptions} and {@link EncoderOptions}.
- *
- * @internal
- */
-export type BaseOptions = {
- /** Allow duplicate object key names. By default, duplicate names throw a `SyntacticError`. */
- allowDuplicateNames?: boolean;
- /** Allow invalid UTF-8 byte sequences. By default, invalid sequences throw a `TypeError`. */
- allowInvalidUTF8?: boolean;
-};
-
-/**
- * Options for {@link Decoder}.
- *
- * @internal
- */
-export type DecoderOptions = BaseOptions;
-
-/**
- * Options for {@link Encoder}.
- *
- * @internal
- */
-export type EncoderOptions = {
- /** Escape `<`, `>`, and `&` for safe embedding in HTML. */
- escapeForHTML?: boolean;
- /** Escape `\u2028` and `\u2029` for safe embedding in JavaScript string literals. */
- escapeForJS?: boolean;
- /** Normalize number tokens to their canonical decimal form. */
- canonicalizeRawNumbers?: boolean;
- /** Emit a space after each `:` separator in objects. */
- spaceAfterColon?: boolean;
- /** Emit a space after each `,` separator in arrays and objects. */
- spaceAfterComma?: boolean;
- /** Emit each value on its own line with indentation. */
- multiline?: boolean;
- /** Indentation string used per nesting level when multiline is enabled. Defaults to two spaces. */
- indent?: string;
- /** Prefix prepended to every indented line when multiline is enabled. */
- indentPrefix?: string;
-} & BaseOptions;
diff --git a/src/utils/kind.ts b/src/utils/kind.ts
index 7f0fedf..66c54b6 100644
--- a/src/utils/kind.ts
+++ b/src/utils/kind.ts
@@ -1,35 +1,45 @@
import { ASCII, KIND } from "#src/common/constants";
-import type { Kind } from "#src/types/kind";
-
-const NORM_KIND: Record = {
- [ASCII.LOWER_CASE_N]: KIND.NULL,
- [ASCII.LOWER_CASE_F]: KIND.FALSE,
- [ASCII.LOWER_CASE_T]: KIND.TRUE,
- [ASCII.QUOTE]: KIND.STRING,
- [ASCII.OPENING_BRACE]: KIND.OBJECT_BEGIN,
- [ASCII.CLOSING_BRACE]: KIND.OBJECT_END,
- [ASCII.OPENING_BRACKET]: KIND.ARRAY_BEGIN,
- [ASCII.CLOSING_BRACKET]: KIND.ARRAY_END,
- [ASCII.MINUS]: KIND.NUMBER,
- [ASCII.DIGIT_0]: KIND.NUMBER,
- [ASCII.DIGIT_1]: KIND.NUMBER,
- [ASCII.DIGIT_2]: KIND.NUMBER,
- [ASCII.DIGIT_3]: KIND.NUMBER,
- [ASCII.DIGIT_4]: KIND.NUMBER,
- [ASCII.DIGIT_5]: KIND.NUMBER,
- [ASCII.DIGIT_6]: KIND.NUMBER,
- [ASCII.DIGIT_7]: KIND.NUMBER,
- [ASCII.DIGIT_8]: KIND.NUMBER,
- [ASCII.DIGIT_9]: KIND.NUMBER,
-};
+import type { Kind } from "#src/common/types";
/**
- * Normalizes a byte to its corresponding kind.
- * @param byte The byte to normalize.
- * @returns The corresponding kind.
+ * Maps a leading JSON byte to its token kind.
+ *
+ * @param byte The byte to map.
+ * @returns The matching {@link Kind}, or `undefined` when the byte cannot begin a JSON token.
*/
function normalize(byte: number): Kind | undefined {
- return NORM_KIND[byte];
+ switch (byte) {
+ case ASCII.LOWER_CASE_N:
+ return KIND.NULL;
+ case ASCII.LOWER_CASE_F:
+ return KIND.FALSE;
+ case ASCII.LOWER_CASE_T:
+ return KIND.TRUE;
+ case ASCII.QUOTE:
+ return KIND.STRING;
+ case ASCII.OPENING_BRACE:
+ return KIND.OBJECT_BEGIN;
+ case ASCII.CLOSING_BRACE:
+ return KIND.OBJECT_END;
+ case ASCII.OPENING_BRACKET:
+ return KIND.ARRAY_BEGIN;
+ case ASCII.CLOSING_BRACKET:
+ return KIND.ARRAY_END;
+ case ASCII.MINUS:
+ case ASCII.DIGIT_0:
+ case ASCII.DIGIT_1:
+ case ASCII.DIGIT_2:
+ case ASCII.DIGIT_3:
+ case ASCII.DIGIT_4:
+ case ASCII.DIGIT_5:
+ case ASCII.DIGIT_6:
+ case ASCII.DIGIT_7:
+ case ASCII.DIGIT_8:
+ case ASCII.DIGIT_9:
+ return KIND.NUMBER;
+ default:
+ return undefined;
+ }
}
export { normalize };
diff --git a/src/utils/text.ts b/src/utils/text.ts
index fa544ca..77d9241 100644
--- a/src/utils/text.ts
+++ b/src/utils/text.ts
@@ -3,19 +3,25 @@ const decoder = new TextDecoder("utf-8");
const fatalDecoder = new TextDecoder("utf-8", { fatal: true });
/**
- * Utility functions for encoding and decoding text.
- * @param input The input string to encode.
- * @returns A Uint8Array containing the encoded text.
+ * Encodes a string as UTF-8 bytes.
+ *
+ * @param input The string to encode.
+ * @returns The encoded UTF-8 bytes.
*/
function encodeText(input: string): Uint8Array {
return encoder.encode(input);
}
/**
- * Utility functions for encoding and decoding text in UTF-8.
- * @param input The input Uint8Array to decode.
- * @param fatal Whether to use a fatal decoder.
+ * Decodes UTF-8 bytes into a string.
+ *
+ * By default, invalid byte sequences are replaced according to the platform
+ * `TextDecoder` behavior. Set `fatal` to `true` to reject invalid sequences.
+ *
+ * @param input The UTF-8 bytes to decode.
+ * @param fatal Whether invalid UTF-8 should throw instead of being replaced.
* @returns The decoded string.
+ * @throws {TypeError} If `fatal` is `true` and `input` contains invalid UTF-8.
*/
function decodeText(input: Uint8Array, fatal?: boolean): string {
return fatal ? fatalDecoder.decode(input) : decoder.decode(input);
diff --git a/src/utils/wire.ts b/src/utils/wire.ts
index 7e3d517..bfdac75 100644
--- a/src/utils/wire.ts
+++ b/src/utils/wire.ts
@@ -2,20 +2,24 @@ import { ASCII } from "#src/common/constants";
import { decodeText } from "#src/utils/text";
/**
- * Compares two strings by UTF-16 code unit order, as required by RFC 8785 §3.2.3.
+ * Compares two strings by UTF-16 code unit order.
+ *
* @param a The first string to compare.
* @param b The second string to compare.
- * @returns A negative number if a < b, positive if a > b, or 0 if equal.
+ * @returns A negative number if `a` sorts before `b`, a positive number if it sorts after `b`, or `0` when both strings are equal.
*/
function compareUTF16(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/**
- * Consumes whitespace characters from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume whitespace from.
- * @param position The position in the bytes to start consuming.
- * @returns The new position after consuming whitespace.
+ * Advances past JSON whitespace starting at a byte position.
+ *
+ * Only space, tab, line feed, and carriage return are treated as whitespace.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @returns The first position that does not contain JSON whitespace.
*/
function consumeWhitespace(bytes: Uint8Array, position: number): number {
while (position < bytes.length) {
@@ -41,10 +45,11 @@ function consumeWhitespace(bytes: Uint8Array, position: number): number {
}
/**
- * Consumes the "null" literal from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @returns The number of bytes consumed if the "null" literal is found, otherwise 0.
+ * Matches the `null` literal at a byte position.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @returns `4` when `null` starts at `position`, otherwise `0`.
*/
function consumeNull(bytes: Uint8Array, position: number): number {
if (bytes.length - position >= 4) {
@@ -62,10 +67,11 @@ function consumeNull(bytes: Uint8Array, position: number): number {
}
/**
- * Consumes the "true" literal from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @returns The number of bytes consumed if the "true" literal is found, otherwise 0.
+ * Matches the `true` literal at a byte position.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @returns `4` when `true` starts at `position`, otherwise `0`.
*/
function consumeTrue(bytes: Uint8Array, position: number): number {
if (bytes.length - position >= 4) {
@@ -83,10 +89,11 @@ function consumeTrue(bytes: Uint8Array, position: number): number {
}
/**
- * Consumes the "false" literal from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @returns The number of bytes consumed if the "false" literal is found, otherwise 0.
+ * Matches the `false` literal at a byte position.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @returns `5` when `false` starts at `position`, otherwise `0`.
*/
function consumeFalse(bytes: Uint8Array, position: number): number {
if (bytes.length - position >= 5) {
@@ -105,11 +112,16 @@ function consumeFalse(bytes: Uint8Array, position: number): number {
}
/**
- * Consumes a string literal from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @param validateUTF8 Whether to validate the string as UTF-8.
- * @returns The number of bytes consumed if a string literal is found, otherwise 0.
+ * Consumes a complete JSON string literal from a byte position.
+ *
+ * Escapes, control characters, and optionally UTF-8 validity are checked by
+ * the underlying resumable scanner. An incomplete or invalid string returns
+ * `0`.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @param validateUTF8 Whether to reject invalid UTF-8 sequences.
+ * @returns The number of bytes in the string literal, or `0` when it is incomplete or invalid.
*/
function consumeString(bytes: Uint8Array, position: number, validateUTF8 = true): number {
const result = consumeStringResumable(bytes, position, 0, validateUTF8);
@@ -118,12 +130,17 @@ function consumeString(bytes: Uint8Array, position: number, validateUTF8 = true)
}
/**
- * Consumes a string literal from the given position, supporting resumption from a previous offset.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @param offset The relative number of bytes already scanned in previous chunks.
- * @param validateUTF8 Whether to validate the string as UTF-8.
- * @returns An object containing the number of bytes consumed (or the scan offset if incomplete) and whether the string is complete.
+ * Scans a JSON string literal and supports resuming from a previous chunk.
+ *
+ * When the string is incomplete, `consumed` is the relative scan offset to
+ * use when continuing with the next chunk. When the string is complete,
+ * `consumed` is its total byte length from `position`.
+ *
+ * @param bytes The current input chunk to scan.
+ * @param position The position at which the string starts.
+ * @param offset The relative scan offset returned for a previous incomplete chunk.
+ * @param validateUTF8 Whether to reject invalid UTF-8 sequences.
+ * @returns The scan result, including the consumed byte count and completion status. An invalid start or control character returns `{ consumed: 0, completed: false }`.
*/
function consumeStringResumable(
bytes: Uint8Array,
@@ -177,10 +194,14 @@ function consumeStringResumable(
}
/**
- * Consumes a number literal from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @returns The number of bytes consumed if a number literal is found, otherwise 0.
+ * Scans a JSON number literal from a byte position.
+ *
+ * The result is the length of the valid number prefix. Delimiter validation is
+ * performed by the parser after this helper returns.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @returns The length of the number literal, or `0` when the bytes do not begin a valid number.
*/
function consumeNumber(bytes: Uint8Array, position: number): number {
if (position >= bytes.length) {
@@ -279,10 +300,11 @@ function consumeNumber(bytes: Uint8Array, position: number): number {
}
/**
- * Consumes a simple string literal (without escape sequences) from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @returns The number of bytes consumed if a simple string literal is found, otherwise 0.
+ * Scans a JSON string literal that contains no escapes.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @returns The length of the string literal, or `0` when it is incomplete or contains an escape, control character, or non-ASCII byte.
*/
function consumeSimpleString(bytes: Uint8Array, position: number): number {
if (position >= bytes.length || bytes[position] !== ASCII.QUOTE) {
@@ -309,10 +331,11 @@ function consumeSimpleString(bytes: Uint8Array, position: number): number {
}
/**
- * Consumes a simple number literal (without fractional or exponential parts) from the given position in the Uint8Array bytes.
- * @param bytes The Uint8Array bytes to consume from.
- * @param position The position in the bytes to start consuming.
- * @returns The number of bytes consumed if a simple number literal is found, otherwise 0.
+ * Scans an integer JSON number without a sign, fraction, or exponent.
+ *
+ * @param bytes The input bytes to scan.
+ * @param position The position at which to start scanning.
+ * @returns The length of the integer, or `0` when the bytes do not begin a simple number or are followed immediately by a fraction or exponent.
*/
function consumeSimpleNumber(bytes: Uint8Array, position: number): number {
if (position >= bytes.length) {
diff --git a/tests/integration/decoder.test.ts b/tests/integration/decoder.test.ts
index 4ee2820..3144960 100644
--- a/tests/integration/decoder.test.ts
+++ b/tests/integration/decoder.test.ts
@@ -9,8 +9,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read null", () => {
const json = JSON.stringify(null);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const token = decoder.readToken();
@@ -21,8 +22,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read true", () => {
const json = JSON.stringify(true);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const token = decoder.readToken();
@@ -34,8 +36,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read false", () => {
const json = JSON.stringify(false);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const token = decoder.readToken();
@@ -47,8 +50,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read a number", () => {
const json = JSON.stringify(42);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const token = decoder.readToken();
@@ -60,8 +64,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read a string", () => {
const json = JSON.stringify("hello");
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const token = decoder.readToken();
@@ -73,8 +78,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read array tokens in order", () => {
const json = JSON.stringify([1, 2]);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
assertEquals(decoder.readToken()?.kind, KIND.ARRAY_BEGIN);
@@ -87,8 +93,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read object tokens in order", () => {
const json = JSON.stringify({ a: 1 });
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
assertEquals(decoder.readToken()?.kind, KIND.OBJECT_BEGIN);
@@ -103,8 +110,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read a scalar value", () => {
const json = JSON.stringify(42);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const value = decoder.readValue();
@@ -116,8 +124,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read a nested array as one value", () => {
const json = JSON.stringify([1, 2]);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const value = decoder.readValue();
@@ -129,8 +138,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read a nested object as one value", () => {
const json = JSON.stringify({ a: 1 });
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const value = decoder.readValue();
@@ -142,8 +152,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should read elements inside an outer array one at a time", () => {
const json = JSON.stringify([1, "two", [3]]);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
@@ -169,8 +180,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should not advance the read position", () => {
const json = JSON.stringify(42);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
assertEquals(decoder.peekKind(), KIND.NUMBER);
@@ -189,8 +201,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should skip a scalar and allow reading the next value", () => {
const json = JSON.stringify([1, 2]);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
decoder.skipValue();
@@ -201,8 +214,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should skip a nested structure and allow reading the next value", () => {
const json = JSON.stringify([[1, 2], 3]);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
@@ -320,8 +334,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("[scenario] options", async (test) => {
await test.step("should reject duplicate names by default", () => {
const encoded = encodeText('{"a":1,"a":2}');
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
decoder.readToken();
@@ -332,8 +347,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should allow duplicate names when configured", () => {
const encoded = encodeText('{"a":1,"a":2}');
- const decoder = new JSONTextDecoder(encoded, { allowDuplicateNames: true });
+ const decoder = new JSONTextDecoder({ allowDuplicateNames: true });
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
decoder.readToken();
@@ -349,8 +365,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should not throw when input is fully consumed", () => {
const json = JSON.stringify(42);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
decoder.checkEOF();
@@ -358,8 +375,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should throw when trailing characters remain", () => {
const encoded = encodeText("42 extra");
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
@@ -371,26 +389,28 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should return 0 before reading anything", () => {
const json = JSON.stringify(42);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
- assertEquals(decoder.inputOffset(), 0);
+ assertEquals(decoder.inputOffset, 0);
});
await test.step("should advance after each token is read", () => {
const json = JSON.stringify([1, 2]);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
- assertEquals(decoder.inputOffset(), 1);
+ assertEquals(decoder.inputOffset, 1);
decoder.readToken();
- assertEquals(decoder.inputOffset(), 2);
+ assertEquals(decoder.inputOffset, 2);
});
});
@@ -398,30 +418,33 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should return all bytes before any token is read", () => {
const json = JSON.stringify(42);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
- assertEquals(decoder.unreadBytes(), encoded);
+ assertEquals(decoder.unreadBytes, encoded);
});
await test.step("should return remaining bytes after reading a token", () => {
const json = JSON.stringify([1, 2]);
const encoded = encodeText(json);
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
- assertEquals(decoder.unreadBytes(), encodeText("1,2]"));
+ assertEquals(decoder.unreadBytes, encodeText("1,2]"));
});
});
await test.step("[scenario] malformed JSON", async (test) => {
await test.step("should throw on an unterminated string", () => {
const encoded = encodeText('"hello');
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
@@ -430,8 +453,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should throw SyntacticError on an invalid character", () => {
const encoded = encodeText("@invalid");
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const error = assertThrows(() => decoder.readToken(), SyntacticError);
@@ -442,21 +466,23 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should throw SyntacticError for a malformed null literal", () => {
const encoded = encodeText("nulx");
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
const error = assertThrows(() => decoder.readToken(), SyntacticError);
assertEquals(error.offset, 0);
assertEquals(error.pointer.toString(), "");
- assertEquals(error.message, "invalid literal null at offset 0");
+ assertEquals(error.message, "Invalid literal null at offset 0");
});
await test.step("should include pointer path in SyntacticError within an object", () => {
const encoded = encodeText('{"a": @}');
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
decoder.readToken();
@@ -469,8 +495,9 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
await test.step("should throw on mismatched closing bracket", () => {
const encoded = encodeText("[1,2}");
- const decoder = new JSONTextDecoder(encoded);
+ const decoder = new JSONTextDecoder();
+ decoder.push(encoded);
decoder.end();
decoder.readToken();
decoder.readToken();
diff --git a/tests/integration/encoder.test.ts b/tests/integration/encoder.test.ts
index cf02275..cd3bd15 100644
--- a/tests/integration/encoder.test.ts
+++ b/tests/integration/encoder.test.ts
@@ -1,7 +1,7 @@
+import Token from "#src/api/token";
+import Value from "#src/api/value";
import { SyntacticError } from "#src/common/errors";
import { JSONTextEncoder } from "#src/index";
-import Token from "#src/modules/token";
-import Value from "#src/modules/value";
import { decodeText, encodeText } from "#src/utils/text";
import { assertEquals, assertThrows } from "#std/assert";
@@ -122,7 +122,7 @@ Deno.test("[integration] JSONTextEncoder", async (test) => {
await test.step("should return 0 before any writes", () => {
const encoder = new JSONTextEncoder();
- assertEquals(encoder.outputOffset(), 0);
+ assertEquals(encoder.outputOffset, 0);
});
await test.step("should advance after each token is written", () => {
@@ -130,10 +130,10 @@ Deno.test("[integration] JSONTextEncoder", async (test) => {
encoder.writeToken(Token.fromText("["));
- assertEquals(encoder.outputOffset(), 1);
+ assertEquals(encoder.outputOffset, 1);
encoder.writeToken(Token.fromNumber(1));
- assertEquals(encoder.outputOffset(), 2);
+ assertEquals(encoder.outputOffset, 2);
});
});
diff --git a/tests/integration/stream-decoder.test.ts b/tests/integration/stream-decoder.test.ts
index e5df894..f0aaf5b 100644
--- a/tests/integration/stream-decoder.test.ts
+++ b/tests/integration/stream-decoder.test.ts
@@ -1,5 +1,5 @@
+import type Token from "#src/api/token";
import { JSONTextDecoderStream } from "#src/index";
-import type Token from "#src/modules/token";
import { encodeText } from "#src/utils/text";
import { assertEquals, assertRejects } from "#std/assert";
diff --git a/tests/integration/stream-encoder.test.ts b/tests/integration/stream-encoder.test.ts
index 4595670..58a1e51 100644
--- a/tests/integration/stream-encoder.test.ts
+++ b/tests/integration/stream-encoder.test.ts
@@ -1,5 +1,5 @@
+import Token from "#src/api/token";
import { JSONTextEncoderStream } from "#src/index";
-import Token from "#src/modules/token";
import { decodeText } from "#src/utils/text";
import { assertEquals, assertRejects } from "#std/assert";
diff --git a/tests/state.test.ts b/tests/state.test.ts
index a6848bd..cadaf31 100644
--- a/tests/state.test.ts
+++ b/tests/state.test.ts
@@ -5,40 +5,40 @@ import { assertEquals, assertExists, assertThrows } from "#std/assert";
Deno.test("[module] state", async (test) => {
await test.step("[function] constructor", async (test) => {
await test.step("should initialize state with default options", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
assertExists(state);
});
await test.step("should start at depth 1", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
- assertEquals(state.depth(), 1);
+ assertEquals(state.depth, 1);
});
await test.step("should not need object name initially", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
- assertEquals(state.needObjectName(), false);
+ assertEquals(state.needsObjectName, false);
});
await test.step("should not need object value initially", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
- assertEquals(state.needObjectValue(), false);
+ assertEquals(state.needsObjectValue, false);
});
});
await test.step("[function] appendLiteral", async (test) => {
await test.step("should not throw in array context", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.appendLiteral();
});
await test.step("should not throw when appending object value", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
@@ -46,7 +46,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should throw when object name is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
@@ -56,42 +56,42 @@ Deno.test("[module] state", async (test) => {
await test.step("[function] appendString", async (test) => {
await test.step("should not throw in array context", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.appendString();
});
await test.step("should not throw when object name is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
- assertEquals(state.needObjectValue(), true);
+ assertEquals(state.needsObjectValue, true);
});
await test.step("should not throw when object value is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
state.appendString();
- assertEquals(state.needObjectName(), true);
+ assertEquals(state.needsObjectName, true);
});
});
await test.step("[function] appendNumber", async (test) => {
await test.step("should not throw in array context", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.appendNumber();
});
await test.step("should not throw when appending object value", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
@@ -99,7 +99,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should throw when object name is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
@@ -109,204 +109,204 @@ Deno.test("[module] state", async (test) => {
await test.step("[function] depth", async (test) => {
await test.step("should return 1 initially", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
- assertEquals(state.depth(), 1);
+ assertEquals(state.depth, 1);
});
await test.step("should increase by 1 after pushArray", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
- assertEquals(state.depth(), 2);
+ assertEquals(state.depth, 2);
});
await test.step("should increase by 1 after pushObject", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
- assertEquals(state.depth(), 2);
+ assertEquals(state.depth, 2);
});
await test.step("should decrease by 1 after popArray", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.popArray();
- assertEquals(state.depth(), 1);
+ assertEquals(state.depth, 1);
});
await test.step("should decrease by 1 after popObject", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.popObject();
- assertEquals(state.depth(), 1);
+ assertEquals(state.depth, 1);
});
await test.step("should track multiple levels of nesting", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.pushObject();
- assertEquals(state.depth(), 3);
+ assertEquals(state.depth, 3);
});
});
- await test.step("[function] needDelimiter", async (test) => {
+ await test.step("[function] requiredDelimiter", async (test) => {
await test.step("should return null initially", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
- assertEquals(state.needDelimiter(KIND.STRING), null);
+ assertEquals(state.requiredDelimiter(KIND.STRING), null);
});
await test.step("should return null for empty nested array", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
- assertEquals(state.needDelimiter(KIND.STRING), null);
+ assertEquals(state.requiredDelimiter(KIND.STRING), null);
});
await test.step("should return comma after first item in nested array", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.appendString();
- assertEquals(state.needDelimiter(KIND.STRING), ",");
+ assertEquals(state.requiredDelimiter(KIND.STRING), ",");
});
await test.step("should return null before array end even with items", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.appendString();
- assertEquals(state.needDelimiter(KIND.ARRAY_END), null);
+ assertEquals(state.requiredDelimiter(KIND.ARRAY_END), null);
});
await test.step("should return colon after object name", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
- assertEquals(state.needDelimiter(KIND.STRING), ":");
+ assertEquals(state.requiredDelimiter(KIND.STRING), ":");
});
await test.step("should return comma before second object name", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
state.appendString();
- assertEquals(state.needDelimiter(KIND.STRING), ",");
+ assertEquals(state.requiredDelimiter(KIND.STRING), ",");
});
await test.step("should return null before object end even with items", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
state.appendString();
- assertEquals(state.needDelimiter(KIND.OBJECT_END), null);
+ assertEquals(state.requiredDelimiter(KIND.OBJECT_END), null);
});
await test.step("should return null at root level even with items", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.appendString();
- assertEquals(state.needDelimiter(KIND.STRING), null);
+ assertEquals(state.requiredDelimiter(KIND.STRING), null);
});
});
await test.step("[function] needObjectName", async (test) => {
await test.step("should return false in array context", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
- assertEquals(state.needObjectName(), false);
+ assertEquals(state.needsObjectName, false);
});
await test.step("should return true immediately after pushObject", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
- assertEquals(state.needObjectName(), true);
+ assertEquals(state.needsObjectName, true);
});
await test.step("should return false after appending object name", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
- assertEquals(state.needObjectName(), false);
+ assertEquals(state.needsObjectName, false);
});
await test.step("should return true again after appending object value", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
state.appendString();
- assertEquals(state.needObjectName(), true);
+ assertEquals(state.needsObjectName, true);
});
});
await test.step("[function] needObjectValue", async (test) => {
await test.step("should return false in array context", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
- assertEquals(state.needObjectValue(), false);
+ assertEquals(state.needsObjectValue, false);
});
await test.step("should return false immediately after pushObject", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
- assertEquals(state.needObjectValue(), false);
+ assertEquals(state.needsObjectValue, false);
});
await test.step("should return true after appending object name", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
- assertEquals(state.needObjectValue(), true);
+ assertEquals(state.needsObjectValue, true);
});
await test.step("should return false after appending object value", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
state.appendString();
- assertEquals(state.needObjectValue(), false);
+ assertEquals(state.needsObjectValue, false);
});
});
await test.step("[function] pushArray", async (test) => {
await test.step("should not throw in array context", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
});
await test.step("should not throw when object value is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
@@ -314,7 +314,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should throw when object name is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
@@ -322,7 +322,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should throw when max nesting depth is exceeded", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
for (let i = 0; i < MAX_NESTING_DEPTH; i++) {
state.pushArray();
@@ -334,22 +334,22 @@ Deno.test("[module] state", async (test) => {
await test.step("[function] popArray", async (test) => {
await test.step("should restore depth after pushArray", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.popArray();
- assertEquals(state.depth(), 1);
+ assertEquals(state.depth, 1);
});
await test.step("should throw at root level", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
assertThrows(() => state.popArray(), SyntaxError);
});
await test.step("should throw when inside an object", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
@@ -359,13 +359,13 @@ Deno.test("[module] state", async (test) => {
await test.step("[function] pushObject", async (test) => {
await test.step("should not throw in array context", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
});
await test.step("should not throw when object value is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
@@ -374,7 +374,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should throw when object name is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
@@ -382,7 +382,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should throw when max nesting depth is exceeded", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
for (let i = 0; i < MAX_NESTING_DEPTH; i++) {
state.pushArray();
@@ -394,16 +394,16 @@ Deno.test("[module] state", async (test) => {
await test.step("[function] popObject", async (test) => {
await test.step("should restore depth after pushObject", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.popObject();
- assertEquals(state.depth(), 1);
+ assertEquals(state.depth, 1);
});
await test.step("should throw when inside an array", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
@@ -411,13 +411,13 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should throw at root level", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
assertThrows(() => state.popObject(), SyntaxError, "mismatching } for object");
});
await test.step("should throw when object value is pending", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.appendString();
@@ -428,14 +428,14 @@ Deno.test("[module] state", async (test) => {
await test.step("[function] setLast", async (test) => {
await test.step("should not throw for a unique name", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.setLast("key");
});
await test.step("should throw for a duplicate name in the same object", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.setLast("key");
@@ -456,7 +456,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should allow same name in different nested objects", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.setLast("key");
@@ -470,7 +470,7 @@ Deno.test("[module] state", async (test) => {
await test.step("[function] stackPointer", async (test) => {
await test.step("should return empty pointer at root level", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
assertEquals(state.stackPointer(-1).toString(), "");
assertEquals(state.stackPointer(0).toString(), "");
@@ -478,7 +478,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should return empty pointer for empty nested array", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
@@ -487,7 +487,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should return next index for empty nested array", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
@@ -495,7 +495,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should point to last written index in array", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushArray();
state.appendString();
@@ -505,7 +505,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should point to key when object value is expected", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.setLast("key");
@@ -517,7 +517,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should return empty pointer after completing object pair", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.setLast("key");
@@ -529,7 +529,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should escape special characters in key names", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.setLast("a/b~c");
@@ -539,7 +539,7 @@ Deno.test("[module] state", async (test) => {
});
await test.step("should build pointer across nested structures", () => {
- const state = new State({});
+ const state = new State({ allowDuplicateNames: false });
state.pushObject();
state.setLast("a");
diff --git a/tests/token.test.ts b/tests/token.test.ts
index 517b2f8..a687379 100644
--- a/tests/token.test.ts
+++ b/tests/token.test.ts
@@ -1,5 +1,5 @@
+import Token from "#src/api/token";
import { KIND } from "#src/common/constants";
-import Token from "#src/modules/token";
import { encodeText as e } from "#src/utils/text";
import { assertEquals, assertThrows } from "#std/assert";
diff --git a/tests/value.test.ts b/tests/value.test.ts
index 4023538..de19951 100644
--- a/tests/value.test.ts
+++ b/tests/value.test.ts
@@ -1,5 +1,5 @@
+import Value from "#src/api/value";
import { KIND } from "#src/common/constants";
-import Value from "#src/modules/value";
import { encodeText as e } from "#src/utils/text";
import { assertEquals, assertFalse, assertThrows } from "#std/assert";
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 623f472..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "compilerOptions": {
- "target": "esnext",
- "module": "esnext",
- "lib": ["ESNext", "DOM"],
- "paths": { "#src/*": ["./src/*"] },
- "moduleResolution": "bundler",
- "verbatimModuleSyntax": true,
- "moduleDetection": "force",
- "skipLibCheck": true,
- "noEmit": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true,
- "erasableSyntaxOnly": true
- },
- "include": ["src", "web"]
-}
diff --git a/vite.config.ts b/vite.config.ts
deleted file mode 100644
index 489fd95..0000000
--- a/vite.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { resolve } from "node:path";
-import dts from "unplugin-dts/vite";
-import { defineConfig } from "vite";
-
-export default defineConfig(({ mode }) => {
- if (mode === "lib") {
- return {
- publicDir: false,
- plugins: [dts({ include: ["src"], outDirs: "dist", entryRoot: "src" })],
- build: {
- lib: {
- entry: resolve("./src/index.ts"),
- fileName: "index",
- formats: ["es"],
- },
- sourcemap: "hidden",
- },
- };
- }
-
- return {
- server: { host: true },
- };
-});
diff --git a/web/main.ts b/web/main.ts
deleted file mode 100644
index 91a329f..0000000
--- a/web/main.ts
+++ /dev/null
@@ -1 +0,0 @@
-console.log("Hello, world");