@quillmark/wasm 0.98.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +222 -0
- package/LICENSE +13 -0
- package/README.md +63 -60
- package/backends/pdfform/wasm.d.ts +276 -134
- package/backends/pdfform/wasm_bg.js +93 -102
- package/backends/pdfform/wasm_bg.wasm +0 -0
- package/backends/typst/wasm.d.ts +276 -134
- package/backends/typst/wasm_bg.js +93 -102
- package/backends/typst/wasm_bg.wasm +0 -0
- package/core/wasm.d.ts +103 -84
- package/core/wasm_bg.js +76 -82
- package/core/wasm_bg.wasm +0 -0
- package/package.json +2 -2
- package/runtime/runtime.d.ts +120 -71
- package/runtime/runtime.js +341 -89
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,227 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.99.0 - 2026-08-01
|
|
4
|
+
|
|
5
|
+
The 1.0.0 API freeze lands ahead of the tag, and the content codec closes its
|
|
6
|
+
last open gaps. All breaking changes are covered by
|
|
7
|
+
`docs/migrations/0.98-to-0.99.md`. Stored documents are unaffected: a `0.98` blob
|
|
8
|
+
loads byte-identically and `0.99` writes the same bytes for the same content.
|
|
9
|
+
|
|
10
|
+
- refactor(core,content,pdf,pdfform)!: the public API opens. 75 public types take
|
|
11
|
+
`#[non_exhaustive]` — nothing in the workspace carried it before — so an
|
|
12
|
+
exhaustive `match` needs a `_` arm and a struct literal gives way to `new` plus
|
|
13
|
+
`with_*` setters. `Backend` is sealed, `OutputFormat::ALL` and
|
|
14
|
+
`Content::RESERVED_{MARK_TYPES,LINE_KINDS,CONTAINERS}` become slices, and
|
|
15
|
+
`RenderOptions { .., ..Default::default() }` becomes
|
|
16
|
+
`RenderOptions::default().with_output_format(fmt)`. Four stay exhaustive and
|
|
17
|
+
say so: the storage DTOs, frozen per schema version, plus
|
|
18
|
+
`quillmark_pdf::FieldType`, `KnownIslandType`, and `Fidelity`, where an
|
|
19
|
+
out-of-crate `_` arm is silent (a field that draws nothing, an island dropped
|
|
20
|
+
from the projection, a fidelity rung nothing warns about). The rules are
|
|
21
|
+
canonized in `prose/canon/COMPATIBILITY.md` (#1090, #1103). See
|
|
22
|
+
`docs/migrations/0.98-to-0.99.md`
|
|
23
|
+
- refactor(core)!: the YAML engine leaves the public API. `QuillValue::from_yaml_str`
|
|
24
|
+
and `QuillConfig::schema_yaml` return `quillmark_core::YamlError` instead of
|
|
25
|
+
`serde_saphyr` types, so a `0.0.x` dependency release is no longer a break to
|
|
26
|
+
`quillmark-core`. The message is sanitized (the engine's own Rust API names are
|
|
27
|
+
stripped), and `from_yaml_str` gains the `MAX_YAML_DEPTH` budget its siblings
|
|
28
|
+
already carried (#1099, #1101). See `docs/migrations/0.98-to-0.99.md`
|
|
29
|
+
- fix(content)!: the reserved-name rule reaches the wire. `attrs` beside a
|
|
30
|
+
built-in discriminator resolved to the built-in and dropped the payload in
|
|
31
|
+
silence; the authored lane now refuses it on all four axes, where a host writes
|
|
32
|
+
it. Reading never got stricter — a blob from before a promotion still opens.
|
|
33
|
+
A table cell keeps its own unknown keys too, canonicalization now rewriting it
|
|
34
|
+
in place rather than minting a fresh `{text, marks}` (#1084, #1085, #1086,
|
|
35
|
+
#1092). See `docs/migrations/0.98-to-0.99.md`
|
|
36
|
+
- fix(content)!: opaque payload depth is bounded at `MAX_JSON_DEPTH` (128) on the
|
|
37
|
+
`Value` lane, where an unbounded one took the WASM module down with a
|
|
38
|
+
stack-overflow trap rather than a catchable error. The WASM guard sits on the
|
|
39
|
+
JS side of the boundary, since `serde_wasm_bindgen` recurses while building the
|
|
40
|
+
value, and covers every door that takes opaque host JSON: `install` and
|
|
41
|
+
`applyChange`, plus `makeCard`'s field values and `insertCard`'s payload items
|
|
42
|
+
(#1093). See `docs/migrations/0.98-to-0.99.md`
|
|
43
|
+
- fix(content)!: island `loss` becomes the fifth open set. An unrecognized class
|
|
44
|
+
round-trips verbatim instead of being rewritten to `unrepresentable`, so merely
|
|
45
|
+
opening a document no longer moves its content hash. `Loss` opens on the island
|
|
46
|
+
`type` axis' terms rather than the block axes': it becomes an opaque string
|
|
47
|
+
wrapper with `LOSSLESS` / `DEGRADED` / `UNREPRESENTABLE` consts, one value per
|
|
48
|
+
wire string, so a built-in's name has no second spelling and needs no
|
|
49
|
+
reserved-name rule. `Fidelity` is the closed view `Loss::fidelity` returns, and
|
|
50
|
+
is where a consumer switches; `Loss` consequently loses its `Copy` derive
|
|
51
|
+
(#1091, #1142). See `docs/migrations/0.98-to-0.99.md`
|
|
52
|
+
- refactor(core,typst,pdf)!: workspace-internal seams leave the published
|
|
53
|
+
surface. `quillmark-pdf`'s `reader`/`writer` modules and `quillmark_typst::emit`
|
|
54
|
+
become `#[doc(hidden)]`; the op-wire encoders emit an unknown's `attrs` in
|
|
55
|
+
caller key order, the redundant per-encoder sort having been dropped (canonical
|
|
56
|
+
content bytes are unchanged, the terminal sort still running) (#1095). See
|
|
57
|
+
`docs/migrations/0.98-to-0.99.md`
|
|
58
|
+
- fix(wasm)!: a `Quill` or `Document` from a second copy of `@quillmark/wasm` is
|
|
59
|
+
refused everywhere, as a `QuillmarkError` coded `runtime::foreign_handle` that
|
|
60
|
+
names the cause and hints `npm ls @quillmark/wasm`. 0.98 half-worked there:
|
|
61
|
+
`Engine` was duck-typed, so a quill from copy A rendered on an engine from copy
|
|
62
|
+
B at a per-copy clone cache nobody could see, while `Document.equals`,
|
|
63
|
+
`Quill.validate`, `Quill.resolve` and the typed writer met wasm-bindgen's bare
|
|
64
|
+
`expected instance of Document` at a value that *is* a `Document`. The check
|
|
65
|
+
covers `Engine` (`render`, `open`, `supportedFormats`, `supportsCanvas`),
|
|
66
|
+
`LiveSession.apply`, the writer and reader binds, and the three by-reference
|
|
67
|
+
core methods; a value that is not a handle at all keeps its own
|
|
68
|
+
`runtime::not_a_document` / `runtime::not_a_quill`. Nothing changes for a
|
|
69
|
+
one-copy install (#1132, #1136). See `docs/migrations/0.98-to-0.99.md`
|
|
70
|
+
- feat(wasm): `isUnknownLine` / `isUnknownContainer` / `isUnknownMark` /
|
|
71
|
+
`isUnknownIsland` answer known-vs-unknown on each open set, so a consumer no
|
|
72
|
+
longer enumerates built-in names in its own source. `ContentLineKind` is
|
|
73
|
+
re-exported from the package entry point, so a `setKind` op type-checks without
|
|
74
|
+
a cast
|
|
75
|
+
- feat(python): the Tier-1 gaps close. `doc.card(i)`, `doc.card_index_by_id(id)`,
|
|
76
|
+
and `doc.seed_overlay(kind)` are the single-card, `$id`, and seed reads WASM
|
|
77
|
+
already had, and the wheel ships `py.typed` plus stubs, so mypy and Pyright see
|
|
78
|
+
real signatures where the surface used to resolve to `Any` (#1011)
|
|
79
|
+
- fix(typst): four quill-load defects — a skipped asset, an unparseable
|
|
80
|
+
`typst.toml`, a skipped package file, a declared-but-absent entrypoint —
|
|
81
|
+
become `RenderResult` warnings (`typst::path_skipped`,
|
|
82
|
+
`typst::package_manifest`, `typst::package_entrypoint_missing`) instead of
|
|
83
|
+
`eprintln!` that wasm32 has nowhere to print (#1102)
|
|
84
|
+
- fix(wasm): the npm package states the license the workspace actually grants.
|
|
85
|
+
`package.json` declared `MIT OR Apache-2.0` where every Rust crate, the
|
|
86
|
+
workspace manifest, and the only `LICENSE` file in the tree are `Apache-2.0`,
|
|
87
|
+
and the package shipped no license text at all: `build-wasm.sh` copied
|
|
88
|
+
`LICENSE-MIT` and `LICENSE-APACHE`, neither of which exists. It now copies
|
|
89
|
+
`LICENSE`, or refuses to produce a package
|
|
90
|
+
- ci: the release gates the tag actually needs. New `package` (builds every
|
|
91
|
+
publishable crate from its own archive and asserts each ships its `LICENSE`),
|
|
92
|
+
`msrv` (holds `rust-version` to something true), and `audit` (bare `cargo
|
|
93
|
+
audit` over the lockfile) jobs; the workspace moves to edition 2024 and
|
|
94
|
+
declares MSRV 1.92. The `semver` job is dropped — it compared the tree's
|
|
95
|
+
unbumped version against itself — and `COMPATIBILITY.md` names the writer and
|
|
96
|
+
reviewer as what holds the promise instead (#1105, #1106, #1107, #1108)
|
|
97
|
+
- ci: the rustdoc gate covers the whole workspace. A bare `cargo doc` walks
|
|
98
|
+
default-members and never lints a crate outside it — the blind spot that let
|
|
99
|
+
the `Delta` links rot on the WASM surface and four more in the published
|
|
100
|
+
`quillmark-content`. `--workspace` needs no `--exclude` and covers the next
|
|
101
|
+
such crate on the day it lands
|
|
102
|
+
- test(fuzz): the four JSON decode lanes the bindings expose gain coverage
|
|
103
|
+
(#1104)
|
|
104
|
+
- docs(canon): `COMPATIBILITY.md` states the crate-API promise — what
|
|
105
|
+
`#[non_exhaustive]` does and does not buy, when to mark an enum, and what no
|
|
106
|
+
attribute sweep catches
|
|
107
|
+
- docs(all): the em-dash leaves comments and prose, folded to a colon, comma,
|
|
108
|
+
semicolon, or parentheses across ~2900 sites. `dense-prose` banned it while
|
|
109
|
+
every exemplar it named used it; the corpus now matches the rule. A handful of
|
|
110
|
+
diagnostic and CLI message strings repunctuate with it (`edit::body_only`,
|
|
111
|
+
`validation::must_fill`, the pdfform bind errors, `--help`); codes, severities,
|
|
112
|
+
and paths are unchanged. The character stays where it is the subject rather
|
|
113
|
+
than punctuation: the WinAnsi encoding table, the YAML en/em-dash fixtures, and
|
|
114
|
+
`docs/migrations/` (#1135)
|
|
115
|
+
- chore(core): `serde-saphyr` moves to `1.0`. The two call sites that built
|
|
116
|
+
`Options`/`SerializerOptions` with struct-literal-plus-`..Default::default()`
|
|
117
|
+
now go through the crate's own `options!`/`ser_options!`/`budget!` macros,
|
|
118
|
+
which the 1.0 release requires since both structs are `#[non_exhaustive]`.
|
|
119
|
+
`serde_saphyr` types stay out of `quillmark-core`'s public API (see the YAML
|
|
120
|
+
engine entry above), so nothing downstream moves
|
|
121
|
+
|
|
122
|
+
<!-- seed: commits since v0.98.0, confirm the entries above cover them, then delete this comment
|
|
123
|
+
- chore(core): move the time format descriptions to parse_borrowed::<3>
|
|
124
|
+
- build(deps): bump lopdf from 0.42.0 to 0.44.0
|
|
125
|
+
- build(deps): bump thiserror in the cargo group across 1 directory
|
|
126
|
+
- build(deps): bump taiki-e/install-action
|
|
127
|
+
- chore(core): bump serde-saphyr to 1.0
|
|
128
|
+
- Split lint back out of test
|
|
129
|
+
- Link every shipped artifact in CI, and carry v0.99.0's notes forward
|
|
130
|
+
- dense-prose pass over the CI scope
|
|
131
|
+
- Cut CI to one job per shipped surface
|
|
132
|
+
- Stop the release from pushing to a branch that takes only PRs
|
|
133
|
+
- Fix three claims the independent review falsified
|
|
134
|
+
- Correct the release notes' exhaustive-type count
|
|
135
|
+
- dense-prose pass over the review's scope
|
|
136
|
+
- Close the two payload doors the depth guard missed
|
|
137
|
+
- Ship the license the workspace actually grants
|
|
138
|
+
- Correct the release docs against the tree they describe
|
|
139
|
+
- CLAUDE.md: tighten the Tests section to house voice
|
|
140
|
+
- CLAUDE.md: drop `uv run` from the Python binding flow
|
|
141
|
+
- CLAUDE.md: gate local binding builds, name the cheap profiles
|
|
142
|
+
- chore(release): v0.99.0
|
|
143
|
+
- Split serial.rs's fixed-point claim into the two it conflated
|
|
144
|
+
- Make Fidelity the one place a loss class is spelled
|
|
145
|
+
- Make an island's loss class injective instead of guarded
|
|
146
|
+
- dense-prose and simplify passes over the handle-check work
|
|
147
|
+
- Keep the colon out of embedded YAML values
|
|
148
|
+
- Sweep the em-dash out of comments and prose
|
|
149
|
+
- Refuse a core handle from a second copy, everywhere
|
|
150
|
+
- Replace the em-dash ban with the rule the exemplars follow
|
|
151
|
+
- dense-prose pass over the foreign-handle prose
|
|
152
|
+
- Extend the policy to the writer/reader lane, and draw the read/write line
|
|
153
|
+
- Keep the patched method names, and stop over-claiming in the warning
|
|
154
|
+
- Tolerate foreign core handles on the by-reference methods
|
|
155
|
+
- Correct the quill file-size cap doc and MiB units
|
|
156
|
+
- Collapse the rustdoc gate onto --workspace
|
|
157
|
+
- dense-prose pass over the release-readiness fixes
|
|
158
|
+
- Close the release-readiness gaps found against v0.98.0
|
|
159
|
+
- Drop the semver job
|
|
160
|
+
- Trim the Python typing work to its load-bearing parts
|
|
161
|
+
- Revert "Record KnownIslandType::ALL's slice shape in the 0.99 guide"
|
|
162
|
+
- Record KnownIslandType::ALL's slice shape in the 0.99 guide
|
|
163
|
+
- Close the Python Tier-1 gaps: single-card reads and a typed surface
|
|
164
|
+
- Drop the top-level-await plugin the vite 8 test config cannot load
|
|
165
|
+
- Shape the reserved-name lists as slices
|
|
166
|
+
- Shape the island ALL as a slice; list the 0.99 guide as published
|
|
167
|
+
- Prune rottable duplication from CLAUDE.md
|
|
168
|
+
- Adopt the editor's dense-prose rewrite; compress CLAUDE.md
|
|
169
|
+
- build(deps): bump the cargo group across 1 directory with 12 updates
|
|
170
|
+
- build(deps): bump the actions group across 1 directory with 11 updates
|
|
171
|
+
- build(deps): bump tsify from 0.4.5 to 0.5.6
|
|
172
|
+
- build(deps): bump toml from 0.8.23 to 1.1.3+spec-1.1.0
|
|
173
|
+
- build(deps): bump similar from 2.7.0 to 3.1.1
|
|
174
|
+
- build(deps): bump serde-saphyr from 0.0.23 to 0.0.29
|
|
175
|
+
- Run the semver job on release PRs, not on every one
|
|
176
|
+
- Drop the dead check-cfg rustflag that crashed the semver job
|
|
177
|
+
- docs: dense-prose pass over the new test comments
|
|
178
|
+
- dense-prose pass over the branch
|
|
179
|
+
- Gate the compatibility promise mechanically, and write it down
|
|
180
|
+
- test(bindings): cover the WASM and Python exports nothing exercised
|
|
181
|
+
- Close the struct half of the 1.0.0 non_exhaustive sweep, and seal Backend
|
|
182
|
+
- test(pdfform): drive unbound text/checkbox/choice through the fixture
|
|
183
|
+
- Move the workspace to edition 2024
|
|
184
|
+
- build(deps-dev): bump vitest in /crates/bindings/wasm
|
|
185
|
+
- build(deps-dev): bump typescript in /crates/bindings/wasm
|
|
186
|
+
- prose: drop the readiness proposal and SECURITY.md
|
|
187
|
+
- ci: drop the Windows matrix from the test job
|
|
188
|
+
- Finish the coerce_value_strict cleanup in the fuzz crate
|
|
189
|
+
- dense-prose pass across the code and docs
|
|
190
|
+
- Revert the wasm job to `npm install`
|
|
191
|
+
- Trim CI to the checks v1.0.0 actually needs
|
|
192
|
+
- Cut the two CI costs that buy nothing
|
|
193
|
+
- Clear the RUSTSEC vulnerabilities the audit job actually found
|
|
194
|
+
- Pin the tree to LF so the Windows runner reads what the test wrote
|
|
195
|
+
- Cleanup pass: cut the machinery the fix did not need
|
|
196
|
+
- prose: record what landed against the v1.0.0 readiness findings
|
|
197
|
+
- fuzz: cover the four JSON decode lanes the bindings expose
|
|
198
|
+
- Declare and hold an MSRV
|
|
199
|
+
- Ship the license text, verify the packages, scan the dependencies
|
|
200
|
+
- Open the engine crates' public enums ahead of the 1.0.0 freeze
|
|
201
|
+
- Hide the workspace-internal seams from the published surface
|
|
202
|
+
- typst: quill-load defects become session warnings, not stderr
|
|
203
|
+
- core: own the YAML boundary error, and budget the third entry point
|
|
204
|
+
- prose: index the v1.0.0 readiness findings against their issues
|
|
205
|
+
- prose: v1.0.0 production-readiness review
|
|
206
|
+
- Drop issue markers; dense-prose the unreleased 0.99 guide
|
|
207
|
+
- dense-prose pass over the PR 1097 scope
|
|
208
|
+
- docs: record the op-wire key-order change in the unreleased guide
|
|
209
|
+
- content: carry the `<u>` bit on the event, not in a shared set
|
|
210
|
+
- content: one key sorter for the canonical form
|
|
211
|
+
- content: bound opaque JSON payload depth on the Value lane
|
|
212
|
+
- content: make a vocabulary promotion carry its own legacy form
|
|
213
|
+
- content: simplify pass over the codec-gap changes
|
|
214
|
+
- content: house voice on the codec-gap comments
|
|
215
|
+
- content: close the three v1.0.0 codec gaps before the freeze
|
|
216
|
+
- content: cut the enumerated halves of the two open-set tests
|
|
217
|
+
- content: prune tests that assert what a neighbour already pins
|
|
218
|
+
- content: fold the authored-lane scan into the shared cell walk
|
|
219
|
+
- content: split the wire lanes on reserved-name reuse (#1084, #1085, #1086)
|
|
220
|
+
- docs: trim the migration index to a routing table; drop duplicate links
|
|
221
|
+
- refactor(typst): re-export span_scan/extract instead of forwarding
|
|
222
|
+
-->
|
|
223
|
+
|
|
224
|
+
|
|
3
225
|
## v0.98.0 - 2026-07-28
|
|
4
226
|
|
|
5
227
|
Five breaking changes, all covered by `docs/migrations/0.97-to-0.98.md`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2025 Nibs
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -10,16 +10,16 @@ Use Quillmark in browsers/Node.js with explicit in-memory trees (`Map<string, Ui
|
|
|
10
10
|
|
|
11
11
|
The package exposes **one import surface**:
|
|
12
12
|
|
|
13
|
-
- `@quillmark/wasm` (the root)
|
|
13
|
+
- `@quillmark/wasm` (the root), the **canonical API**: `Quill`, `Document`, and
|
|
14
14
|
an `Engine` that renders them.
|
|
15
15
|
|
|
16
16
|
`Quill` and `Document` are re-exported verbatim from the internal Typst-less
|
|
17
17
|
core build, so editor/validation code (`Quill.fromTree`,
|
|
18
|
-
`Document.fromMarkdown`) loads only that small core binary
|
|
18
|
+
`Document.fromMarkdown`) loads only that small core binary: no backend is
|
|
19
19
|
loaded until you render. The `Engine` hides everything else: each backend
|
|
20
20
|
(`typst`, `pdfform`) is a separate, private WASM binary with its own linear
|
|
21
21
|
memory, lazily loaded on the first render. The Engine clones a `Quill` /
|
|
22
|
-
`Document` into the backend's memory as data and frees the clones
|
|
22
|
+
`Document` into the backend's memory as data and frees the clones: you never
|
|
23
23
|
hold a backend object or cross a memory boundary yourself.
|
|
24
24
|
|
|
25
25
|
## Build
|
|
@@ -28,8 +28,8 @@ hold a backend object or cross a memory boundary yourself.
|
|
|
28
28
|
bash scripts/build-wasm.sh
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
The script builds three variants
|
|
32
|
-
(default features), and the Typst-free pdfform backend (`pdfform` feature)
|
|
31
|
+
The script builds three variants: the core (no backend), the Typst backend
|
|
32
|
+
(default features), and the Typst-free pdfform backend (`pdfform` feature):
|
|
33
33
|
each with `--target bundler` and `--weak-refs` enabled (see
|
|
34
34
|
[Lifecycle](#lifecycle)).
|
|
35
35
|
|
|
@@ -66,7 +66,7 @@ const result = await engine.render(quill, parsed, { format: "pdf" });
|
|
|
66
66
|
|
|
67
67
|
### `new Engine(options?)`
|
|
68
68
|
Create the render dispatcher. Routes each quill to its backend by
|
|
69
|
-
`quill.backendId`, lazily loads that backend binary, and renders
|
|
69
|
+
`quill.backendId`, lazily loads that backend binary, and renders: cloning the
|
|
70
70
|
quill/document into the backend's memory and freeing the clones internally.
|
|
71
71
|
`render`, `open`, `supportedFormats`, and `supportsCanvas` are **async** (the
|
|
72
72
|
first call may load a backend). Pass `{ backends }` to register or override
|
|
@@ -78,16 +78,16 @@ the backend id.
|
|
|
78
78
|
|
|
79
79
|
**Capability probes are always free.** `supportedFormats` and `supportsCanvas`
|
|
80
80
|
depend only on `quill.backendId`, and answer from the descriptor's required
|
|
81
|
-
`formats`/`canvas` manifest
|
|
81
|
+
`formats`/`canvas` manifest: never loading the multi-MB backend binary and
|
|
82
82
|
never cloning the quill. Use them as non-failing pre-render probes.
|
|
83
83
|
|
|
84
84
|
### `Quill.fromTree(tree)`
|
|
85
|
-
Build + validate a `Quill` from an in-memory tree. Pure
|
|
85
|
+
Build + validate a `Quill` from an in-memory tree. Pure: the declared backend
|
|
86
86
|
is resolved at render time, not here. Loads no backend binary.
|
|
87
87
|
|
|
88
88
|
### `new Document(quillRef)`
|
|
89
89
|
A blank document: a main card carrying only `$quill`, an empty body, and no
|
|
90
|
-
composable cards
|
|
90
|
+
composable cards: the programmatic blank canvas. Absent fields resolve at
|
|
91
91
|
render time (schema `default`, else type-empty zero), so nothing the caller
|
|
92
92
|
did not set reaches the output. Build it up with `storeFields` / `insertCard`.
|
|
93
93
|
For an example-filled starter use `quill.seedDocument()`. Throws on an
|
|
@@ -96,19 +96,19 @@ invalid quill reference.
|
|
|
96
96
|
### `Document.fromMarkdown(markdown)`
|
|
97
97
|
Parse markdown to a parsed document. Throws a JS `Error` (with `.diagnostics`
|
|
98
98
|
attached, see [Errors](#errors)) on any parse failure, including a missing
|
|
99
|
-
root `$quill` metadata line, malformed YAML, and inputs over the 10
|
|
99
|
+
root `$quill` metadata line, malformed YAML, and inputs over the 10 MiB
|
|
100
100
|
`parse::input_too_large` limit.
|
|
101
101
|
|
|
102
102
|
### `doc.toMarkdown()`
|
|
103
103
|
Emit canonical Quillmark Markdown. Type-fidelity round-trip safe:
|
|
104
104
|
`Document.fromMarkdown(doc.toMarkdown())` returns a document equal to `doc`
|
|
105
105
|
under [`doc.equals`](#docequalsother). The output is **not** guaranteed
|
|
106
|
-
byte-equal to the original source
|
|
106
|
+
byte-equal to the original source: YAML quoting, key ordering, and
|
|
107
107
|
whitespace are normalised. Use `equals` (not string comparison) to test
|
|
108
108
|
semantic equality.
|
|
109
109
|
|
|
110
110
|
### `doc.toJson()`
|
|
111
|
-
Serialize the document to a versioned storage DTO
|
|
111
|
+
Serialize the document to a versioned storage DTO: a JSON **string**
|
|
112
112
|
carrying a `schema` version. Use this (not `toMarkdown`) to persist a
|
|
113
113
|
document across a process restart or crate upgrade: the wire format is
|
|
114
114
|
frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
|
|
@@ -116,11 +116,11 @@ frozen per `schema` version, whereas Markdown syntax evolves. Parse-time
|
|
|
116
116
|
|
|
117
117
|
The string is produced inside the module by `serde_json`; the JS `JSON`
|
|
118
118
|
global is not involved. It is standard JSON text, so callers may
|
|
119
|
-
`JSON.parse` it to inspect it
|
|
119
|
+
`JSON.parse` it to inspect it, but it is intended as an opaque blob you
|
|
120
120
|
persist and hand back.
|
|
121
121
|
|
|
122
122
|
`toJson()` is **deterministic**: a `Document` that is `equals` to another
|
|
123
|
-
serializes to a byte-identical string
|
|
123
|
+
serializes to a byte-identical string: across repeated calls, and across
|
|
124
124
|
any crate upgrade that keeps the same `schema` version (every release does until
|
|
125
125
|
the `Document` model changes; see [Storage compatibility](#storage-compatibility-across-versions)).
|
|
126
126
|
Field order is fixed and object key order is preserved, so content hashes
|
|
@@ -145,7 +145,7 @@ not a valid storage DTO. Use it to branch on format without a heuristic or
|
|
|
145
145
|
`try`/`catch` as control flow:
|
|
146
146
|
|
|
147
147
|
```ts
|
|
148
|
-
// "JSON canonical, Markdown fallback"
|
|
148
|
+
// "JSON canonical, Markdown fallback": no exceptions, no string sniffing
|
|
149
149
|
const doc = Document.tryFromJson(content) ?? Document.fromMarkdown(content);
|
|
150
150
|
```
|
|
151
151
|
|
|
@@ -156,14 +156,14 @@ genuinely malformed Markdown.
|
|
|
156
156
|
|
|
157
157
|
The `schema` value (`quillmark/document@0.93.0`) is the **model version**,
|
|
158
158
|
not the running crate version. It is a hand-set constant, bumped only when
|
|
159
|
-
the `Document` model itself changes
|
|
159
|
+
the `Document` model itself changes, so every `0.93.x` patch release reads
|
|
160
160
|
and writes that same value.
|
|
161
161
|
|
|
162
162
|
- **Upgrading is safe.** A newer build always reads documents written by an
|
|
163
163
|
older one. Each schema version's wire format is frozen and never changes;
|
|
164
164
|
when the model does change, the new build ships a migration that converts
|
|
165
165
|
old payloads on `fromJson`. A document you commit as your canonical
|
|
166
|
-
on-disk format keeps loading across crate upgrades
|
|
166
|
+
on-disk format keeps loading across crate upgrades: there is no need to
|
|
167
167
|
pin old wasm to read old data.
|
|
168
168
|
- **Downgrading is not.** `fromJson` rejects an *unknown* (i.e. newer)
|
|
169
169
|
`schema` version rather than guessing at a format it predates. Don't feed
|
|
@@ -178,13 +178,13 @@ if (v && v !== Document.currentSchemaVersion()) {
|
|
|
178
178
|
}
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
-
`schemaVersionOf` does not validate the payload
|
|
181
|
+
`schemaVersionOf` does not validate the payload: it only reads the
|
|
182
182
|
`schema` field, returning `undefined` for non-JSON, non-objects, or
|
|
183
183
|
payloads that don't carry one. Use it to distinguish "wrong version" from
|
|
184
184
|
"corrupt" when `fromJson` throws.
|
|
185
185
|
|
|
186
186
|
In short: persist the `toJson` string, upgrade freely, never downgrade. The
|
|
187
|
-
full design
|
|
187
|
+
full design (including how migrations are added) is in
|
|
188
188
|
`prose/canon/DOCUMENT_STORAGE.md`.
|
|
189
189
|
|
|
190
190
|
### `doc.equals(other)`
|
|
@@ -201,7 +201,7 @@ Use this to validate indices before calling card mutators (`removeCard`,
|
|
|
201
201
|
|
|
202
202
|
### `quill.validate(doc)`
|
|
203
203
|
|
|
204
|
-
Returns `Diagnostic[]
|
|
204
|
+
Returns `Diagnostic[]`: the document validated against the quill schema,
|
|
205
205
|
without invoking the backend. An empty array means the document is valid.
|
|
206
206
|
Each diagnostic carries the canonical `validation::*` `code`, `path`, and
|
|
207
207
|
`hint`. Includes the non-fatal `validation::must_fill` warning for each
|
|
@@ -214,14 +214,14 @@ const errors = diagnostics.filter(d => d.severity === "error");
|
|
|
214
214
|
```
|
|
215
215
|
|
|
216
216
|
To render a form editor, read field definitions from `quill.schema` (walk
|
|
217
|
-
`fields` in key order
|
|
218
|
-
values from the `Document` payload
|
|
217
|
+
`fields` in key order: declaration order is display order) and the authored
|
|
218
|
+
values from the `Document` payload: there is no separate form-view projection.
|
|
219
219
|
|
|
220
220
|
### `quill.seedDocument()`
|
|
221
221
|
|
|
222
222
|
Returns a starter `Document` seeded from the schema: each field's `example:`
|
|
223
223
|
is committed and every other field is left absent (the render layer fills
|
|
224
|
-
`default:` → type-empty zero). Illustration-first
|
|
224
|
+
`default:` → type-empty zero). Illustration-first: a field with both an
|
|
225
225
|
`example` and a `default` renders its example. Use as the initial state for a
|
|
226
226
|
"new document" editor.
|
|
227
227
|
|
|
@@ -243,7 +243,7 @@ doc.insertCard({ kind: "note" }, 0); // insert at index 0
|
|
|
243
243
|
```
|
|
244
244
|
|
|
245
245
|
Reads and writes are two aligned shapes. A read `Card` always has `body:
|
|
246
|
-
Content` (canonical content, never a raw string)
|
|
246
|
+
Content` (canonical content, never a raw string): no narrowing, no guessing
|
|
247
247
|
whether the body was normalized. The write shape `CardInput` widens `body` to
|
|
248
248
|
`Content | string` (a markdown string imports to the content) and makes every
|
|
249
249
|
field but `kind` optional. Every `Card` is a valid `CardInput`, so `insertCard`
|
|
@@ -251,8 +251,8 @@ still takes exactly what `cards` / `removeCard` / `seedCard` return.
|
|
|
251
251
|
Build a fresh card from a flat field map with
|
|
252
252
|
`Document.makeCard(kind, fields?, body?)`.
|
|
253
253
|
|
|
254
|
-
**One address for the whole surface.** Reads and writes navigate by an `Addr
|
|
255
|
-
`{ card?, field? }`, absent `card` = main, absent `field` = body
|
|
254
|
+
**One address for the whole surface.** Reads and writes navigate by an `Addr`:
|
|
255
|
+
`{ card?, field? }`, absent `card` = main, absent `field` = body, and a bare
|
|
256
256
|
string is shorthand for `{ field }`. So `doc.storeField("qty", 3)` targets the
|
|
257
257
|
main card's `qty`, `doc.storeField({ card: 2, field: "qty" }, 3)` a composable
|
|
258
258
|
card's. Reads are total over the field axis (`getStored` → `undefined`, `isFill` → `false` for
|
|
@@ -263,10 +263,10 @@ markdown is read through `quill.reader(doc).get(field)`). Card-scoped verbs take
|
|
|
263
263
|
`CardAddr` (`{ card? }`) first: `doc.getExt({ card: 2 })`, and the batch below.
|
|
264
264
|
|
|
265
265
|
Batch mutation: `doc.storeFields({}, {...})` / `doc.storeFields({ card: index }, {...})`
|
|
266
|
-
apply a whole object atomically
|
|
266
|
+
apply a whole object atomically: on any invalid field nothing is applied and
|
|
267
267
|
the thrown error carries one diagnostic per offending field (`path` = field
|
|
268
268
|
name). The address is first (never shape-overloaded, since `card` is a legal
|
|
269
|
-
field name), and parses strictly
|
|
269
|
+
field name), and parses strictly: a stray key throws rather than silently
|
|
270
270
|
reading as `{}`. The main card is `{}`, or **`MAIN_CARD_ADDR`** (from
|
|
271
271
|
`@quillmark/wasm/runtime`), a frozen alias that spells the intent:
|
|
272
272
|
`doc.storeFields(MAIN_CARD_ADDR, {...})`.
|
|
@@ -277,12 +277,12 @@ A `Document` holds only a `$quill` *reference*, not the resolved schema, so type
|
|
|
277
277
|
writes go through the schema-bound writer while the quill-free opaque store sits
|
|
278
278
|
on `Document` itself (**store** = verbatim, **set** = typed):
|
|
279
279
|
|
|
280
|
-
- **`quill.writer(doc)
|
|
280
|
+
- **`quill.writer(doc)`: the typed door whenever a quill is in hand.** Bind the
|
|
281
281
|
schema once and issue bare `set` / `setAll` / `setBody` / `reviseField` /
|
|
282
282
|
`addCard` / `card(i)`. Each resolves the field's schema `type`, coerces the
|
|
283
283
|
value to its canonical form (`"3"` → `3`, a markdown string → a richtext
|
|
284
284
|
content), and **fails now** on a mismatch instead of at render. A name the schema
|
|
285
|
-
does not declare throws `UnknownField` rather than falling to the opaque store
|
|
285
|
+
does not declare throws `UnknownField` rather than falling to the opaque store:
|
|
286
286
|
on the typed path an undeclared name is a typo, not a fallback. The batch form
|
|
287
287
|
(`setAll`) is all-or-nothing: an undeclared name aborts the whole write and its
|
|
288
288
|
per-field diagnostics name every offending field, so a whole-form submit
|
|
@@ -290,21 +290,21 @@ on `Document` itself (**store** = verbatim, **set** = typed):
|
|
|
290
290
|
carries the quill-taking `_commitField` / `_commitFields` / `_addCard` /
|
|
291
291
|
`_reviseField` ABI the writer delegates to, hidden from the `.d.ts`.)
|
|
292
292
|
|
|
293
|
-
- **`store
|
|
293
|
+
- **`store*`: the deliberate quill-free primitive.** `doc.storeField(addr, value)`
|
|
294
294
|
/ `doc.storeFields(cardAddr, {...})` (and `storeFill`) validate only the field
|
|
295
295
|
name/depth/kind and store the value verbatim, no quill required. Reach for it
|
|
296
296
|
on purpose when you *want* the opaque store: quill-agnostic storage/migration
|
|
297
297
|
infra that has no bundle and must write regardless of a drifted schema;
|
|
298
298
|
store-now-validate-later editors holding in-progress input that `commit`
|
|
299
299
|
would reject; or verbatim passthrough of fields the schema doesn't own. It is
|
|
300
|
-
the lower layer, not a lighter `commit
|
|
300
|
+
the lower layer, not a lighter `commit`: a typo'd field name stores silently
|
|
301
301
|
and only surfaces at `quill.validate` / render.
|
|
302
302
|
|
|
303
303
|
Per-keystroke cost is the same either way (both mutate the in-memory `Document`
|
|
304
304
|
in place; no seam is crossed), so steering to the writer buys the type check for
|
|
305
305
|
free.
|
|
306
306
|
|
|
307
|
-
#### `DocumentWriter` / `CardWriter
|
|
307
|
+
#### `DocumentWriter` / `CardWriter`: bind the quill once
|
|
308
308
|
|
|
309
309
|
`quill.writer(doc)` binds the quill's schema to the document once, so a form
|
|
310
310
|
editor or MCP writer that holds both issues bare verbs (the writer forwards to
|
|
@@ -315,25 +315,25 @@ const ed = quill.writer(doc); // Rust `quill.writer(doc)`
|
|
|
315
315
|
ed.set("subject", "Q3 results"); // strict-committed to the schema type
|
|
316
316
|
ed.setAll({ qty: "3", subject: "Q3" }); // all-or-nothing batch
|
|
317
317
|
ed.reviseField("subject", "Q3 **results**"); // typed AND anchor-preserving; returns a Delta
|
|
318
|
-
ed.set("titel", "x"); // throws UnknownField
|
|
318
|
+
ed.set("titel", "x"); // throws UnknownField: a typo, not a fallback
|
|
319
319
|
ed.card(2).set("body", "**note**"); // composable card, resolved by its $kind
|
|
320
320
|
```
|
|
321
321
|
|
|
322
322
|
`DocumentWriter` / `CardWriter` are pure JS holding references to your existing
|
|
323
|
-
`quill` and `doc
|
|
323
|
+
`quill` and `doc`: no WASM handle of their own, nothing to `free()`. `card(i)`
|
|
324
324
|
is lazy: it never throws; an out-of-range index throws `IndexOutOfRange` at the
|
|
325
325
|
write.
|
|
326
326
|
|
|
327
327
|
### `engine.render(quill, parsed, opts?)` vs. `engine.open(quill, parsed)`
|
|
328
328
|
|
|
329
|
-
Use **`engine.render`** for one-shot exports (PDF/SVG/PNG)
|
|
329
|
+
Use **`engine.render`** for one-shot exports (PDF/SVG/PNG): compiles, emits
|
|
330
330
|
artifacts, done. Use **`LiveSession`** (returned by `engine.open`) for
|
|
331
331
|
reactive previews: the session is a persistent compiler. `paint` / `render` /
|
|
332
332
|
`regions` / `fieldAt` read its current compile without recompiling, and `apply(doc)`
|
|
333
333
|
recompiles in place on each edit, returning a `ChangeSet` whose `dirtyPages`
|
|
334
|
-
tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional
|
|
334
|
+
tells you which pages to repaint (`dirty ∩ visible`). Apply is transactional:
|
|
335
335
|
on throw, every read keeps serving the last-good compile. Don't open a session
|
|
336
|
-
per export, and don't re-open per edit
|
|
336
|
+
per export, and don't re-open per edit: `apply` instead.
|
|
337
337
|
|
|
338
338
|
### `engine.render(quill, parsed, opts?)`
|
|
339
339
|
Render a pre-parsed `Document` against `quill`. Throws an
|
|
@@ -344,7 +344,7 @@ declared backend.
|
|
|
344
344
|
Open once, render all or selected pages (`opts.pages`).
|
|
345
345
|
|
|
346
346
|
The session also exposes `pageCount`, `backendId`, `supportsCanvas`,
|
|
347
|
-
`warnings` (non-fatal diagnostics of the current compile
|
|
347
|
+
`warnings` (non-fatal diagnostics of the current compile: set at `open`,
|
|
348
348
|
refreshed by each committed `apply`),
|
|
349
349
|
`apply(doc)` for in-place recompiles, `pageSize(page)`, and
|
|
350
350
|
`paint(ctx, page, opts?)` for canvas previews. See below.
|
|
@@ -361,7 +361,7 @@ render a "no pages to preview" UI without relying on the throw.
|
|
|
361
361
|
`OffscreenCanvasRenderingContext2D` (Worker), skipping PNG/SVG byte
|
|
362
362
|
round-trips.
|
|
363
363
|
|
|
364
|
-
The painter owns `canvas.width` / `canvas.height
|
|
364
|
+
The painter owns `canvas.width` / `canvas.height`: it sizes the backing
|
|
365
365
|
store itself. Consumers own `canvas.style.*` (or the layout system that
|
|
366
366
|
sets them) and read `layoutWidth` / `layoutHeight` from the returned
|
|
367
367
|
`PaintResult`.
|
|
@@ -390,16 +390,16 @@ canvas.style.height = `${result.layoutHeight}px`;
|
|
|
390
390
|
clamped page renders soft at the same `canvas.style` size.
|
|
391
391
|
- `paint` writes the whole backing store with `putImageData`, which
|
|
392
392
|
ignores the 2D context transform, `globalAlpha`, and clip. Give each
|
|
393
|
-
visible page its own
|
|
393
|
+
visible page its own `<canvas>` element: you cannot composite two pages,
|
|
394
394
|
a sub-rect, or a context transform through `paint`.
|
|
395
|
-
- `paint` is always a full repaint
|
|
395
|
+
- `paint` is always a full repaint: setting the backing-store width /
|
|
396
396
|
height clears it. No `clearRect` required. Each call re-rasterizes from
|
|
397
397
|
scratch (no per-page raster cache), so keep a page's canvas alive while
|
|
398
398
|
it stays near the viewport rather than pooling one canvas across pages:
|
|
399
399
|
an idle canvas retains its pixels for free, whereas reusing a canvas on
|
|
400
400
|
scroll re-runs a full render.
|
|
401
401
|
- `pageCount` and `pageSize(page)` are stable for the session's
|
|
402
|
-
lifetime (immutable snapshot)
|
|
402
|
+
lifetime (immutable snapshot): cache them.
|
|
403
403
|
- Worker support: pass an `OffscreenCanvasRenderingContext2D` and the
|
|
404
404
|
same call signature works. `layoutWidth` / `layoutHeight` are
|
|
405
405
|
informational in that mode (no CSS layout box); fold everything into
|
|
@@ -415,14 +415,14 @@ canvas.style.height = `${result.layoutHeight}px`;
|
|
|
415
415
|
|
|
416
416
|
A field's *cell* is inferred from whether its schema declares a `default:`:
|
|
417
417
|
|
|
418
|
-
- **Unendorsed** (no `default:`)
|
|
418
|
+
- **Unendorsed** (no `default:`): `quill.blueprint` renders the
|
|
419
419
|
`!must_fill` marker in the value cell (carrying the field's `example` as a
|
|
420
420
|
suggested value when one exists). An absent Unendorsed field zero-fills
|
|
421
421
|
silently. A `!must_fill` marker left in the document is non-fatal: it emits
|
|
422
422
|
the `validation::must_fill` warning and still renders. Partial documents
|
|
423
423
|
are accepted; `engine.render(quill, doc)` only throws for malformed
|
|
424
424
|
input.
|
|
425
|
-
- **Endorsed** (with `default:`)
|
|
425
|
+
- **Endorsed** (with `default:`): `quill.blueprint` renders the
|
|
426
426
|
default value with a type-only `# <type>` annotation (shippable as-is),
|
|
427
427
|
and the default is used when the document omits the field.
|
|
428
428
|
|
|
@@ -431,7 +431,7 @@ document emits the non-fatal `validation::must_fill` warning.
|
|
|
431
431
|
|
|
432
432
|
### Errors
|
|
433
433
|
|
|
434
|
-
Every method that can fail throws a **`QuillmarkError
|
|
434
|
+
Every method that can fail throws a **`QuillmarkError`**: a JS `Error` with
|
|
435
435
|
`.diagnostics` attached. The type and a guard are exported from the root:
|
|
436
436
|
|
|
437
437
|
```ts
|
|
@@ -443,17 +443,17 @@ try {
|
|
|
443
443
|
if (isQuillmarkError(e)) {
|
|
444
444
|
for (const d of e.diagnostics) console.error(d.severity, d.message);
|
|
445
445
|
} else {
|
|
446
|
-
throw e; // not a quillmark failure
|
|
446
|
+
throw e; // not a quillmark failure: programming error, re-throw
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
449
|
```
|
|
450
450
|
|
|
451
|
-
`QuillmarkError` is a **structural interface, not a class
|
|
451
|
+
`QuillmarkError` is a **structural interface, not a class**: the WASM layer
|
|
452
452
|
throws a real `Error` and attaches the property, so there is no constructor to
|
|
453
453
|
`instanceof` against; narrow with `isQuillmarkError` (which also works on
|
|
454
454
|
errors from any build or WASM instance in the page).
|
|
455
455
|
|
|
456
|
-
`diagnostics` is always non-empty
|
|
456
|
+
`diagnostics` is always non-empty: length 1 for most failures, length N for
|
|
457
457
|
backend compilation errors. `message` is derived from `diagnostics`
|
|
458
458
|
(`diagnostics[0].message` for single-diagnostic errors; an aggregate
|
|
459
459
|
`"<N> error(s): <first.message>"` summary for compilation failures).
|
|
@@ -461,15 +461,20 @@ backend compilation errors. `message` is derived from `diagnostics`
|
|
|
461
461
|
Read `err.diagnostics[0]` for the primary diagnostic; iterate the array for
|
|
462
462
|
compilation failures. The same shape applies to every throw site:
|
|
463
463
|
|
|
464
|
-
- `Document.fromMarkdown
|
|
465
|
-
errors, `parse::input_too_large` for inputs > 10
|
|
466
|
-
- `Document` mutators (`storeField`, the writer's `set`, etc.)
|
|
464
|
+
- `Document.fromMarkdown`: parse errors (missing root `$quill` metadata, YAML
|
|
465
|
+
errors, `parse::input_too_large` for inputs > 10 MiB).
|
|
466
|
+
- `Document` mutators (`storeField`, the writer's `set`, etc.): mutator
|
|
467
467
|
failures carry a namespaced `edit::*` `code` on `diagnostics[0]`
|
|
468
468
|
(`edit::invalid_field_name`, `edit::unknown_field`, `edit::index_out_of_range`,
|
|
469
469
|
`edit::field_conform`, …). Route on `diagnostics[0].code`, never on message
|
|
470
470
|
text.
|
|
471
|
-
- `engine.render` / `session.render
|
|
471
|
+
- `engine.render` / `session.render`: backend compilation failures and
|
|
472
472
|
validation errors.
|
|
473
|
+
- Any method taking a `Quill` or `Document`: a handle from a *second* copy of
|
|
474
|
+
`@quillmark/wasm` is refused with `runtime::foreign_handle`, hinting `npm ls
|
|
475
|
+
@quillmark/wasm`. Two copies are two WASM memories and two `Quill`/`Document`
|
|
476
|
+
classes; dedupe to one. A value that is not a handle at all keeps its own
|
|
477
|
+
`runtime::not_a_document` / `runtime::not_a_quill`.
|
|
473
478
|
|
|
474
479
|
### Lifecycle
|
|
475
480
|
|
|
@@ -480,17 +485,15 @@ teardown hook for callers that want deterministic release.
|
|
|
480
485
|
|
|
481
486
|
`engine.render` and `engine.open` read the `quill` and `doc` handles
|
|
482
487
|
synchronously, before their first await, so freeing a handle as soon as the
|
|
483
|
-
call returns
|
|
484
|
-
{ doc.free(); }
|
|
488
|
+
call returns: `try { return engine.render(quill, doc); } finally
|
|
489
|
+
{ doc.free(); }`: is safe even on the first render, while the backend
|
|
485
490
|
binary is still loading.
|
|
486
491
|
|
|
487
492
|
The package floor is Node 22+ (`engines: { node: ">=22" }`) and current
|
|
488
493
|
evergreen browsers; `--weak-refs` itself only needs Node 14.6+. The `using`
|
|
489
|
-
sugar
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
For environments where `using` (the [explicit resource management][erm]
|
|
493
|
-
proposal) hasn't landed, use an explicit `try` / `finally`:
|
|
494
|
+
sugar ([explicit resource management][erm]) needs Node 24 and is optional.
|
|
495
|
+
Where it hasn't landed, an explicit `try` / `finally` runs on the Node 22
|
|
496
|
+
floor:
|
|
494
497
|
|
|
495
498
|
```ts
|
|
496
499
|
const session = await engine.open(quill, doc);
|