Packages

A pure Elixir library for structured document representation with rich text support

Current section

Files

Jump to
quillon CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
## v0.3.0
The release that makes a consumer's own node and mark types first-class. A type
introduced through `extra_types:` or a custom schema could previously be stored and
loaded but not edited - every offset and formatting operation crashed on it. It now
works throughout, and two consumers extending the same schema compose instead of
overwriting each other.
### Upgrading from v0.2.0
Versions 0.2.1 and 0.2.2 were never published, so their entries below are included in
this upgrade. Three changes are worth checking before you bump:
- **Offsets changed for documents containing non-text nodes.** A node whose children
are blocks now measures **1** rather than the sum of its children, so a paragraph
containing such a node reports a different length. This is the fix that makes a
footnote's private text stop counting as body text, but any offset you persisted
against the old model needs recomputing. Documents of plain text and marks are
unaffected.
- **`Schema.merge/2` no longer replaces.** Groups union and specs merge. If you relied
on replacement to *narrow* a schema - handing `merge/2` a shorter group list or a
smaller spec to remove members - build that schema from scratch instead. Overriding
a key still works; only removing one does not.
- **Two new validation error atoms**, `:malformed_node` and `:malformed_mark`. Additive,
but an exhaustive `case` over `Quillon.Schema.Validator.error_type/0` needs new
branches. Input that previously raised `FunctionClauseError` from `validate/1` now
returns `{:error, errors}`, so a `rescue` around it may become dead code.
### Bug Fixes
- **Fix crashes on custom node types across the transform layer** — the transform layer assumed every child of a `:paragraph` or `:heading` was a text node, so a document holding a node type introduced through `extra_types:` raised `FunctionClauseError`. Fixed in five places:
- `Position.node_length/1` matched text nodes only, and every offset- and mark-based operation funnels through it — `total_length/1`, `offset_to_position/2`, `position_to_offset/2`, `apply_mark/4`, `remove_mark/4`, `toggle_mark/4`, `range_has_mark?/4`, `split_at_offset/2` and all of `Quillon.Commands`.
- `Split.split_at_offset/2` had no clause for a non-text target.
- `range_has_mark?/4` and the two mark-range helpers behind `apply_mark/4` and `remove_mark/4` matched text nodes only.
- `Normalize.empty_text?/1` and `mergeable?/2` did the same, so a children list containing any other node raised before the merge predicate was reached. Both now report `false` for a non-text node.
- **Fix schema validation crashing on malformed input**`validate/1` promises `{:ok, node} | {:error, errors}` and `validate!/1` documents `ArgumentError`, but a term that was not a `{type, attrs, children}` tuple raised `FunctionClauseError` from `validate_content/5` or `validate_node/3`. A caller rescuing `ArgumentError` around `validate!/1` still crashed. Malformed input is now reported, not raised.
- **Fix `clear_formatting/3` leaving custom marks behind** — it iterated `Types.all_marks()`, a fixed list, so a mark registered through a schema survived a clear even though `remove_mark/4` removed it fine. Pass a schema to `clear_formatting/4` to clear every mark that schema defines. `clear_formatting/3` is unchanged for the built-in marks.
- **Fix schema validation crashing on malformed marks** — a mark that was neither an atom nor a `{type, attrs}` pair raised `FunctionClauseError` from `get_mark_type/1` or `BadMapError` from `validate_mark_attrs/4`, and a `marks` attribute that was not a list raised `Protocol.UndefinedError`. All are now reported. A malformed mark no longer suppresses errors for its well-formed siblings.
### New Features
- **`:malformed_node` validation error** — a term that is not a well-formed node tuple is reported at its path, whether it appears as a child, nested at any depth, or as the document root. Covers non-map attrs and non-list children as well, matching the shape `Quillon.Node.node?/1` accepts.
- **Schema-driven node categories**`Quillon.block?/2`, `inline?/2` and `container?/2` answer from a schema's groups, so a consumer's own node type is categorised like a built-in one. The single-argument forms answer from the default schema and are unchanged. `Schema.default()` gains a `:container` group so all three categories derive uniformly.
- **`:malformed_mark` validation error** — a mark whose shape the library cannot read is reported at its node's path. This is distinct from `:unknown_mark`: a custom mark type registered through a schema or `extra_marks:` is well-formed and still validates, it is only unknown when unregistered.
### Behaviour
- **`Schema.merge/2` composes instead of replacing.** An override declares only what it changes and the rest of the base survives, so two consumers extending the same type or group coexist rather than the second wiping out the first. Groups union their members; specs merge key by key, so supplying only `attrs` keeps `content`, `group` and `marks`; `attrs` merge entry by entry; an individual attr spec replaces. An explicit key still overwrites, which is how a type is narrowed (`content: nil`, `marks: [:bold]`). A merge cannot *remove* a key the base declared — build the schema from scratch for that.
- **A node can declare `atomic: true`** to occupy exactly one position in its parent. This settles the two cases structure cannot: a childless node, which otherwise measures 0 and is unaddressable — so a footnote marker or inline chip can now be selected — and a node holding custom blocks, which otherwise reads as transparent because an unregistered type is not a known block type. The declaration takes precedence over the inference; without it, nothing changes.
- A custom node **holds its own text flow when its children are blocks**, and joins its parent's when they are inline. `Position.own_flow?/1` derives this from the document - nothing is declared. A node with its own flow measures 1, so a sentence containing a footnote measures what a reader sees plus one position for the marker, rather than counting the footnote's private text as body text. Marks and `range_has_mark?/4` stop at its boundary.
- A custom node whose children are inline is **transparent to offset math**: `node_length/1` sums its children, so text wrapped in a custom node stays addressable and offsets computed against rendered text line up.
- Marks applied to a range **recurse into a custom node's children**, rewriting only `:text` descendants. The node's own type and attrs are never touched.
- A custom node is **not splittable**. An offset landing inside one is clamped rather than split, so its text is left alone rather than half-marked.
- `range_has_mark?/4` counts text nested inside a custom node and ignores the node itself, so toggling a fully marked mixed range still removes rather than adds.
- A custom node passes through normalization untouched while the text around it still merges.
### Documentation
- `Transform.normalize/1` and `Normalize.normalize_block/1` document that they accept only `:paragraph` and `:heading`, which is narrower than the names suggest.
- The guides were audited against the code and corrected throughout - node types that did not exist, schema defaults that were wrong in a dozen places, and two examples that raised when run.
### Internal
- `find_node_at_offset/2`'s spec declared the found node as a text node, though the function returns a node of any type. No behaviour change; the stale spec made a live branch look unreachable to Dialyzer.
### Why
`extra_types:` in `Quillon.JSON` is an explicit promise that the AST carries node types the library does not know about. The transform layer did not keep it: such a document could be stored and loaded, but not edited. Consumers model extra structure as nodes rather than marks — a scanned line carrying a bounding box and page number, so the fact survives the text operations a reviewer performs on it — and every formatting command crashed on it.
Reported while integrating with quillon_live, and again by Sinag, an archive of scanned Philippine legislative documents.
## v0.2.2
### Bug Fixes
- **Fix `to_json` crash on non-mark list attrs**`encode_value/1` treated all list attrs as mark lists, crashing with `FunctionClauseError` on string lists, map lists, or nested maps. Now only the `:marks` key gets mark-specific encoding; all other values use generic recursive encoding that correctly handles lists, maps, booleans, nil, and atoms.
## v0.2.1
### New Features
- **Extensible JSON decoding** - `from_json/2` and `from_json!/2` now accept options to extend valid types:
- `schema:` — validate types against a custom `Quillon.Schema` (recommended)
- `extra_types:` — list of additional node type atoms to accept
- `extra_marks:` — list of additional mark type atoms to accept
- Default `from_json/1` behavior unchanged — only built-in types accepted
### Why
Consumers extending Quillon with custom node types (e.g., form builders with `:form`, `:text_input`) could not delegate JSON deserialization to Quillon because `from_json/1` hardcoded type validation against `Types.node_types()`. Now they can.
## v0.2.0
### New Features
- **Layout properties** - Optional Tailwind-inspired layout tokens on all block nodes: `align`, `width`, `spacing`, `indent`, `valign`
- **Styling properties** - Optional styling tokens: `font_size`, `font_weight`, `line_height`, `color`, `background`, `border`, `border_color`, `border_style`, `opacity`, `shadow`, `rounded`
- **Row node** - New `:row` flex container node with `justify`, `items`, `wrap`, `gap` attrs
- **Grid node** - New `:grid` container node with `columns`, `gap` attrs
- All properties use constrained value sets (atoms), not arbitrary CSS values
- Full JSON round-trip support for layout/style attrs
- Factory functions accept layout/style options via keyword lists
### Backward Compatibility
- All layout/style properties are optional - existing code is fully compatible
- No breaking changes to existing API
## v0.1.0
- Initial release
- Document AST with tuple-based nodes `{type, attrs, children}`
- Node types: document, paragraph, heading, divider, blockquote, callout, code_block, image, video, bullet_list, ordered_list, table
- Marks system: bold, italic, underline, strike, code, subscript, superscript, link, highlight, font_color, mention
- Formatting commands with character-range operations
- Path-based tree traversal and manipulation
- Schema validation with content expressions
- JSON serialization/deserialization