v2.3.1
Added
-
Persistent table sizing: columns, rows, and cells (#209). Table dimensions are now first-class, immutable document state instead of
width: 100%guesswork. Column widths live in a per-table logical-column vector (columnWidthsPx) and row minimum heights on eachtable_row(minHeightPx), resolved against a centralized logical grid (TableGrid) socolspan/rowspannever leak child-array coordinates. Storing structured data required extending the block attribute value model explicitly (BlockAttrValuenow permits an immutable flat primitive array, with deep-equality helpers) rather than an ad-hoc delimiter string, and a new invertibleMoveNodeStepkeeps rowspan-owner relocation atomic. Every path funnels through one clamped, validated apply: pointer drag with a live dimension indicator,Escapeto cancel, and a single history entry per drag; focusableseparatorcontrols with documented small and large keyboard steps; an accessible precise-size dialog (a focus-trappedaria-modaldialog that returns focus to the invoking control on close); and a strongly typed publicTableSizingServicewith selection-oriented and explicit cell/row/column/range targets (getSize/setSize/resetSizeand their selection variants). Dimensions survive undo/redo,getJSON()/setJSON(), HTML export and import in both the inline and the zero-inline-style CSP modes (semantic<colgroup>/<col>widths plus row metadata), clipboard fragments (whole tables, complete rows or columns, and unambiguous rectangles), Markdown (preserved through the raw-HTML fallback, or a defined reset to automatic layout when the fallback is disabled), and print and static output. Structural row and column insert and delete splice the dimension metadata atomically and invert exactly. Imported values are strictly parsed, bounded (1 to 10000 px), and sanitized, so nocalc(),var(), percentage, or CSS-injection value ever reaches node attributes. Direct resizing is RTL-correct and disabled in read-only editors, which also reject every sizing command without mutating state.TableConfiggains documented minimum-dimension, keyboard-step, anddirectResizeoptions; all labels, hints, and announcements ship in the English locale plus the eight bundled translations; and the Starlight table documentation covers the public API, configuration, pointer and keyboard interaction, automatic reset, dimension semantics, and HTML/Markdown behavior. -
List items are now block containers: full multi-block list fidelity (#194).
list_itemis promoted from an inline-only leaf to a hybrid leaf/container block, mirroring the blockquote container work (#136). The common single-paragraph item stays a leaf (existing documents and JSON are untouched), while an item holding several blocks (a second paragraph, a fenced or indented code block, a blockquote, a heading, a thematic break) is a container whose children are regular blocks, withlistType/indent/checkedkept as attributes. Sibling nesting stays flat-with-indent, andlist_itemdeliberately cannot nest insidelist_item, so there is exactly one canonical representation. Markdown import now follows CommonMark's content-column semantics instead of the oldfloor(columns / 2)heuristic: every item owns a marker-relative content column, following lines indented to it are the item's own region (dedented and tokenized recursively, so all block types compose inside items for free), markers indented four or more columns are indented code or lazy text per the spec, lazy continuation folds textually into the open paragraph (including through nested containers such as> 1. > text), and dedenting normalizes leading tabs by absolute column so tab stops survive. The Markdown serializer emits container children indented to the item's real content column (a1.parent pads three columns,-two; the checklist task marker stays on the first line only) and emits a thematic break inside an item as***so it cannot re-parse as a setext underline. This closed all 12 pinned flat-list deviations in the official spec gate and moved the multi-block list-item examples from "out of model" into gate A: the CommonMark coverage floors ratchet from 523 to 568 passing and from 42 down to 9 remaining out-of-model examples (7 raw-inline-HTML, 2 residual list shapes that cannot keep item-relative order in the flat sibling model). HTML interop is symmetric:getContentHTMLserializes container items through the generic container recursion,setContentHTMLand paste parse a multi-block<li>into a container (single plain paragraphs unwrap back to leaves, nested<ul>/<ol>still hoist into flat siblings), and multi-block<li>clipboard HTML routes through the container-aware DocumentParser like blockquotes do. Editing is consistent with the blockquote container: Enter splits the current child paragraph inside the item, Enter on an empty trailing child exits into a fresh sibling item (Enter-Enter continues the list), Backspace at the start of the first child un-lists the whole item into its blocks, and undo restores structural changes via ordinary step inversion. Tab / Shift-Tab,Mod-Enterchecklist toggling, the toggle commands, and the toolbar active states all resolve the owning item from inside a child block; the list input rules refuse to fire inside an item so the schema invariant holds. Accessibility is unchanged: the<li>keepsrole="listitem"andaria-level, and the checklist checkbox marker stays the first child ahead of the block children. -
Markdown import now supports indented (4-space / tab) code blocks (#195). A run of lines indented by at least four columns (tab-aware, 4-column tab stops) imports as a
code_block, closing a genuine CommonMark gap. Interior blank lines are preserved, indentation beyond the first four columns stays part of the code, and a partially consumed tab re-expands into its remaining spaces. Per CommonMark, an indented line never interrupts an open paragraph (it is lazy continuation), and list-shaped or table-shaped lines keep their existing meaning. Directly after a list item, indented content is list continuation in CommonMark; since #194 it becomes the item's own block content (an indented run four columns past the item's content column imports as a code block inside the item). -
GFM extended autolinks on Markdown import (#199). Bare
http:///https:///ftp://URLs,www.domains,mailto:/xmpp:addresses, and bareuser@host.tldemails in running text now become links, following the GFM autolink extension rules rather than a naive URL regex: valid-domain checks (at least one period, no underscore in the last two segments), trailing-punctuation trimming, closing-paren balancing, and trailing entity-reference stripping. URL and scheme matching runs on the raw source inside the inline parser, before emphasis resolution, so URLs with_or~in their path never fragment into delimiter runs; bare emails resolve in a post-pass over the finished inline AST, so delimiter characters in the local part (a_b@x.io) reassemble first. Explicit[text](url)syntax and code spans always win, autolinks never nest inside links, and the whole feature is off underflavor: 'commonmark'. All scans are linear-time (D1); the ReDoS fuzz suite gains adversarial autolink, email, and entity-run cases after it caught a quadratic email regex during development (replaced by an@-anchored bounded scan) and a quadratic adjacent-text merge (replaced by a single compaction pass). -
The Markdown conformance gate now runs the official CommonMark + GFM spec (#200). The curated fixture set is no longer the only gate:
MarkdownSpecConformance.test.tsloads the official CommonMark 0.31.2spec.json(652 examples) plus the GFM spec's extension examples (24: tables, task lists, strikethrough, autolinks) as dev-only fixtures and enforces the two hard gates from D1. Gate A compares every in-scope example against the structure implied by the spec's expected HTML via a normalized structural AST (CommonMark examples parse underflavor: 'commonmark'); 100% of the in-scope set must pass. Out-of-scope material is explicit, never silent: the raw-HTML sections run under gate B (thehtmlFallbackseam), converter-detected out-of-model constructs run under gate B too, and the documented deviations are pinned in a skip list that a dedicated test forces to only ever shrink (initially 23; #194 removed the 12 flat-list pins in the same release, leaving 11). Gate B asserts every out-of-scope or skipped example parses without throwing and reaches a serialization fixpoint (serialize, re-parse, serialize again, byte-identical), so nothing outside the matrix is ever dropped or corrupted by a round-trip. Closing the gap to the official spec surfaced and fixed a series of parser defects: entities and backslash escapes now decode inside link destinations, titles, reference definitions, and fence info strings (uncommon named entities decode through the environment's DOM instead of bundling the ~2200-name HTML5 table); link reference definitions support destinations and titles on following lines, definitions inside blockquotes (the emptied container survives), Unicode case-folded label matching (ẞmatchesSS), and full-reference labels containing brackets; blockquotes and list items support lazy paragraph continuation; empty list items (-alone) and marker chains (- - foo) parse; empty items and ordered items not starting at 1 no longer interrupt paragraphs; a backtick fence with a backtick in its info string reads as a code span, an indented fence strips its body indentation, and an unclosed fence at end of input no longer gains a phantom blank line;### ###is an empty heading;�decodes to U+FFFD and to a real no-break space; GFM tables absorb lazy continuation rows per the spec; emphasis flanking classifies Unicode punctuation and symbols (so*€*xstays literal); and captured raw inline HTML normalizes interior newlines so the serialization fixpoint holds. The Markdown chunk's own size-limit budget rose from 15 to 18 KB for the added grammar; the D13 core-chunk guard is untouched and still passes, so the base bundle did not grow.
Fixed
- Range deletion across a container boundary no longer discards unselected content (#194 follow-up). Deleting (or typing/pasting over) a selection that runs from a paragraph into the middle of a multi-block
list_item, or the reverse, wiped the entire container root instead of only the selected span, silently destroying the item's remaining text and its untouched later children. The shared cross-root deletion path removed a composite root wholesale whenever a selection endpoint landed inside it, correct only when the endpoint sat at the container's true edge. It now trims: the from-root keeps everything before the selection start, the to-root keeps everything after the selection end (recursively, so nested containers such asblockquote > list_item > paragraphare handled), root blocks strictly between the endpoints are removed, and the boundary blocks merge only when both roots are leaves. A container is still removed wholesale when the boundary is at its very first or last position (so the whole container is genuinely selected) or when it is a structured container such as atable, whose rows must keep equal cell counts and so cannot be trimmed into a valid shape. The defect pre-existed forblockquotecontainers (#136) and became reachable for lists when #194 madelist_itema container. Undo restores the original document via ordinary step inversion. Covered by unit tests over bothlist_itemandblockquotein both directions, both-composite, depth-2 nesting, container-edge wholesale removal, and an undo round-trip, plus a real-browser range-delete regression test. setJSONno longer crashes the reconciler on documents whose text or inline nodes omit themarksfield (#197 follow-up). Inline nodes gained a requiredmarksarray in #197, and the view layer reads it unconditionally, butsetJSONpassed external JSON into the state verbatim. A document persisted before that change (or any hand-written JSON withoutmarks) threwCannot read properties of undefined (reading 'length')deep in the reconciler and left the editor blank. The JSON boundary now establishes the model invariant:setEditorJSONwalks the document (containers included) and defaults a missingmarksarray to empty on text and inline nodes, leaving already-valid nodes and documents untouched by identity. Covered by unit tests for the flat, inline-node, and nested-container cases; the pre-#197 formula screenshot fixture exercises the path end-to-end.- Two trailing spaces before a newline now import as a hard break (#193). CommonMark's second hard-break form was silently downgraded to a soft break, a real loss on externally authored Markdown (notectl's own export uses the backslash form, so its round-trips were unaffected). The block tokenizer now preserves trailing spaces on interior paragraph lines, and the inline parser turns two or more of them before a newline into a
hard_break, stripping the line-end whitespace either way. The backslash form keeps working, and the two-space form round-trips through export (which emits the backslash form). - A linked inline image no longer loses its link on Markdown import (#197). Importing
[](url)promoted the image to a standalone blockimageand dropped the link destination entirely, a silent data loss that violated the #192 "never silently drop a construct" principle. Inline nodes now carry marks just like text nodes do (a newmarksfield onInlineNode), so a linked inline image keeps itslinkmark and stays inline inside its paragraph instead of being promoted. The fix is symmetric across the whole pipeline: the Markdown and HTML importers attach the surrounding marks to the inline node, the standalone-image promotion no longer fires for a marked image, and the Markdown and HTML exporters wrap the inline image in its link so the round-trip is lossless in both directions (getContentMarkdown/getContentHTMLand back). The editor renders the inline image wrapped in its mark elements (e.g. inside an<a>). The conformance gate gains a fixture for the linked-image construct. Toggling a link onto an inline image through the toolbar or a command is a separate follow-up; marks on inline nodes are currently produced by import only. - Emphasis and links now compose correctly on Markdown import (#198). Two related failures in the inline tokenizer broke the common "bold link" constructs. A link inside emphasis (
**[t](/u)**) duplicated its visible text, so it rendered as "tt": the link wrapper was inserted into the sibling list byparseCloseBracketand then appended a second time by the parse loop, corrupting its sibling pointers so a later emphasisunlinkleft the wrapper reachable both as a child and as a top-level sibling, flattening it twice. Emphasis inside link text ([**t**](/u)) silently dropped the bold:processEmphasisran after the bracket contents had already been detached into the wrapper's children array, so the delimiter-stack walk had no linked siblings left to wrap, yet the delimiter literals were still consumed. Both are fixed at the root: the parser now tracks the output list tail as parser state so a self-linking link or image wrapper is never re-appended, and emphasis inside a bracket is resolved while its contents are still linked, before they are moved into the wrapper. The conformance gate gains fixtures for a link inside emphasis, emphasis inside a link, emphasis spanning a link boundary, and emphasis inside an image alt (which still collapses to plain alt text). - Print reset guards now neutralize
clip-path,clip, andcontent-visibilityhost hiding (#203). The page and host reset declarations that keep printed output visible against hostile host CSS coveredvisibility,overflow,transform,filter, andcontain, but not three other hide-capable properties. A host page using an sr-only or visually-hidden utility (clip: rect(0,0,0,0),clip-path: inset(50%)) on an ancestor of the editor, or a@media print { body * { clip-path: inset(50%) } }rule, could still blank or clip the printed editor even though the analogous visibility case was guarded.clip: auto,clip-path: none, andcontent-visibility: visibleare now part of bothPAGE_RESET_DECLARATIONSandHOST_RESET_DECLARATIONS, emitted!importantinside thenotectl-printlayer so they beat even!importanthost rules. Covered by unit tests next to the existing reset tests and a real-browser regression test that hides the page viaclip-pathandcontent-visibility. - A hoisted cross-origin
@importnow keeps the enclosing stylesheet's media, layer, andsupports()context (#204). Stylesheets whose rules are unreadable (cross-origin) are re-referenced from the print document as hoisted@importstatements. The hoisted statement carried only the import rule's ownmedia/layer()/supports(), not the context of the stylesheet or import chain that contained it, so a media-scoped cross-origin sheet (a<style media="screen">or<link media>holding the import) applied unconditionally in print, and a cross-origin sheet reached through a readable@import url(a.css) layer(x) supports(...)landed in the wrong cascade layer and ignored the parent conditions. The copier now threads an accumulated context down through the sheet and every import level and emits it on the hoisted statement: media conditions are ANDed withand, nested cascade layers concatenate asnotectl-host.parent.child, and multiplesupports()conditions combine as parenthesizedand. Anonymous import layers are still named lazily so the shared counter only advances on a real hoist. Covered by unit tests for each context kind (sheet-level media, ANDed media, a full layer/supports/media chain) and a real-browser test that a media-gated cross-origin import stays inactive when its media does not match.
Full changelog: https://github.com/Samyssmile/notectl/blob/main/CHANGELOG.md