Changelog
July 20, 2026 · View on GitHub
All notable changes to flexdoc are documented here.
This project uses semantic versioning; while pre-1.0, breaking
changes bump the minor version (see docs/publishing.md).
0.4.0 (2026-07-20)
This is an intentional pre-1.0 API break; per the pre-1.0 rule it is a minor release,
not a 0.3.x patch. The release-gate review, validation evidence, and full
breaking-change catalog with migrations are in
docs/project/review/senior-engineering-review-v0.4-release-2026-07.md.
Added
- Native TextRef integration. Strict
DocRef/TextRefvalues now identify whole documents, spans, points, and semantic sections with canonical JSON and reversibletextref:0.1URIs.FlexDoc.references()maps every locatable public document value, resolves references with typed outcomes, retrieves structured source context, and renders deterministic human/LLM-readable annotation views. Span quote evidence is optional: callers can select a context-wide size policy or override individual spans, while compact exact-less spans remain safely bound to one source hash. - Consumer-owned TextRef annotations.
TextAnnotationand the one-documentAnnotationSetsidecar round-trip strict JSON and safe YAML.DocGraph/v0.2carries required document identity and source hash in every graph, with optional typed annotations in the same schema. The protocol and renderer add no dependencies. - Cross-language logical word metrics.
TextUnit.wordsnow measures normalized word-equivalent volume across natural language, CJK text, source code, URLs, and other punctuation-dense content.TextUnit.raw_wordsandraw_word_count()preserve literal whitespace-delimited counting, whilelogical_word_count()exposes the dependency-free normalized primitive.
Changed
-
DocGraph is v0.2 and self-identifying.
FlexDoc.graph()andbuild_doc_graph()now require a consumer-owneddocumentlocator and always returnDocGraph/v0.2; annotations optionally populate that same model, and the debug helpersdoc_graph_yaml()/dump_views()take the samedocumentargument. In the serialized graph,source.documentis required and the unqualifiedsource.sha256field is replaced bysource.source_hash, whose algorithm-qualified value is shared with TextRef. All DocGraph models are now strict (unknown fields rejected, strict types, frozen instances) and validate node-reference integrity, span/text consistency, and annotation bounds. -
TextUnit.wordschanges from raw to logical semantics. It matches the raw count for ordinary non-wide prose averaging 3–6 characters per word, but differs for wide/fullwidth scripts, longer or shorter average word lengths, URLs, code, and other symbolic content. Callers requiring the exact previous whitespace-split behavior must useTextUnit.raw_words; there is no separateTextUnit.logical_wordsmember. Size summaries, section-tree defaults, and debug-reportwordsfields use the logical measure, and aggregate counts are rounded only after the full text is measured. -
Approximate token estimates now scale logical words.
estimate_tokens()usesTOKENS_PER_LOGICAL_WORD(default1.6) instead of the formerCHARS_PER_TOKEN/chars_per_tokenAPI. This is still a model-family heuristic; use the target provider's tokenizer for exact counts or hard context limits. -
Reading-time guidance is language-robust. Pass logical word counts to
format_read_time(); the default rate corresponds to roughly 450 CJK characters per minute under the default wide-character weight.
0.3.0 (2026-07-11)
Fixes from the 2026-07 pre-promotion design review
(docs/project/review/senior-engineering-review-flexdoc-2026-07.md). These changes
alter documented behavior, so per the pre-1.0 rule this is a minor release, not a
0.2.x patch.
Fixed
- CRLF input no longer corrupts the structural views. marko computes block positions
against LF-only text, so
\rin the input desynchronized every structural span (blocks(),sections(),base_blocks(),links(),prose_text(), the node table) fromsource_text, silently garbling content and violating the base-block cover invariant.from_textnow normalizes\r\nand lone\rto\nand retains the normalized string assource_text, so all layers share one offset space. Callers anchoring offsets to an external CRLF original must normalize it the same way first. - Markdown constructs inside frontmatter can no longer swallow the body. The shared
parse previously included the frontmatter region, so e.g. a YAML block scalar
containing a code fence opened a fenced block spanning the rest of the document,
leaving
blocks()empty. The frontmatter region is now blanked out of the shared parse (offsets preserved); frontmatter remains a non-content region. Link extraction now reuses that blanked parse instead of reparsing the body for frontmatter documents. resolve()no longer guesses on ambiguous quotes. Per the spec’s error posture (§11), aSpanRefquote that occurs multiple times with no disambiguating prefix/suffix (or a tied context score) now resolves toNoneinstead of silently anchoring to the first occurrence. A context-free position hint also cannot select a duplicate, because no context or source identity proves which occurrence was intended. A zero-width quote (exact="") also resolves toNoneon both the fast and slow paths.collect(overlaps=...)treats empty intervals as empty. A degenerate[x, x)region (or node span) now overlaps nothing, matching half-open interval semantics; point queries use(x, x + 1).- Render helpers harden their HTML output.
render_node_attrsattribute-escapesnode.id, andwrap_with_node_attrsvalidates the tag name (raisingValueError), matching the validation in theflexdoc.htmltag helpers.
Changed
- The dependency lock is refreshed under the 14-day cool-off. The cutoff is
2026-06-26, expired per-package exceptions are removed, and the audit group resolves
fixed
pipandmsgpackversions, so CI runspip-auditwithout advisory ignores. graph()accepts any set.FlexDoc.graph()andbuild_doc_graph()annotateinclude/detailascollections.abc.Set, so plainsetliterals type-check (matchingcollect()); behavior is unchanged.TextUnitis aStrEnum, matching every other public enum, soTextUnit.words == "words"now holds. Source-compatible for enum-member access; onlystr()/equality-with-string behavior changes.- Recursive
collect()includes inline descendants by default. Theinlineparameter is now tri-state: omission follows recursive traversal or an explicit inline-kind filter,inline=Falseexcludes inline nodes, andinline=Trueincludes them for any query. Callers that need the previous block-only recursive result must passinline=False. - Cached structural views are mutation-safe.
Blockis now frozen,Block.childrenandTableInfo.alignmentsare tuples, andsections()returns recursively isolated section/paragraph copies. Code constructing blocks directly must pass child tuples; code comparing or building table metadata must use alignment tuples. Mutation of a returned section remains local to that view and does not persist to a later call. SpanRefowns its public resolution API. Callref.resolve(source_text)orref.resolve_and_update(source_text)on the root-exported type. The genericresolveandresolve_and_updatenames are no longer promoted fromflexdoc.docs; update package-level imports and calls to use the methods.- Paragraph heading metadata is property-based.
Paragraph.heading_levelandParagraph.heading_titlenow matchParagraph.block_typeandBlock.heading_level. Remove()from calls to the two former methods. - The navigable-link constant is accurately named. Import
NAVIGABLE_LINK_FORMSinstead ofTRUE_LINK_FORMS; no compatibility alias is retained. flexdoc.docsnow promotes the document model only. Word-token/search and diff/mapping names are no longer re-exported. Import them fromflexdoc.docs.wordtoks,search_tokens,token_diffs, ortoken_mappinginstead. The current Chopdiff integration already uses these owning-module paths.- Frontmatter delimiters tolerate trailing horizontal whitespace. Opening and
closing
---lines may end in spaces or tabs while remaining verbatim infrontmatter; leading whitespace still disqualifies a delimiter, and an unclosed opening remains a thematic break. - The OS-independent classifier is backed by macOS CI. Ubuntu still covers every
supported Python version, and Python 3.13 now runs the full lint/test gate on
macos-latestas a representative second platform. - Local release preparation is tag-aware. The runbook fetches tags before building
and verifies candidate wheel metadata from an isolated local tag, preventing a tagless
clone from silently producing a
0.0.1.devNrelease artifact. - Section sizing no longer constructs temporary documents.
FlexDocandSectionnow share private paragraph aggregation for everyTextUnitand size summary; public results and signatures are unchanged.
Remaining pre-1.0 design decisions and future mechanisms are collected in
docs/project/specs/active/plan-2026-07-09-flexdoc-stabilization-roadmap.md.
0.2.0 (2026-06-14)
Correctness fixes and a completed inline/heading/link surface for the document-metrics
use case (docs/project/specs/done/plan-2026-06-13-metrics-use-case.md, issue #6). As
a preview-stage library this takes the cleanest shape with no compatibility shims; the
API additions below include breaking signature changes (see Changed).
Fixed
node_table()/collect()/graph()no longer raise on valid Markdown. Inline elements were discovered over the whole source and parented by start offset, so backtick pairing across a block boundary (an empty fence next to inline backticks) produced an inline span escaping its parent block and raised a layer-nesting error. Inline discovery is now scoped per leaf content block, so an inline node can never straddle a block boundary; links/images/definitions are parented by full containment.sections()/toc()recover every headingblocks()finds, and own their content correctly. Headings were re-derived from the blank-line paragraph view, dropping tight headings and headings preceded by a non-blank line (e.g. an HTML-comment marker), and section content was bucketed from that same view, so a heading glued to its body lost the body. Sections now derive entirely from the structural block tree—the heading set and each section’s own content (own_paragraphs()/blocks()/ sizes) come from the section’s source region—so tight and marker-preceded headings own exactly their content.- Section spans nest correctly even when a blank-line paragraph straddles a later
heading (e.g. an embedded
---block marko reads as a setext heading): each section spans from its heading to the next same-or-higher heading (trimmed), which nests by construction. Byte-identical to the prior span for well-formed documents. - Reference-definition nodes attach to their block. A
link_ref_defspan included the line’s trailing newline and escaped the containing paragraph, leaving the node unparented so a block-scopedcollect()missed it; spans are now trimmed like every structural block.
Added
- Heading metadata on the structural block:
Block.heading_info(HeadingInfowith parser-authoritativelevelandtitle) and theBlock.heading_levelconvenience;HeadingInfois exported fromflexdoc.docs. The node table reads heading level from it. - Typed link forms:
LinkForm(inline/reference/autolink/bare_url/image/reference_definition) andLink.link_form.FlexDoc.links(link_forms=…)selects any forms (default: navigable links only), andFlexDoc.images()is a convenience for image access. Reference definitions ([id]: url) are surfaced asNodeKind.link_ref_defnodes and vialinks(link_forms={LinkForm.reference_definition}). FlexDoc.prose_text(): prose-only text for editorial linting and prose metrics — prose blocks (paragraphs/headings, and table cells wheninclude_tables=True) with inline code and footnote refs dropped, links/images replaced by their text/alt, inline-HTML tags dropped (wrapped text kept), and heading/blockquote/list markers and reference-definition lines stripped; from verbatim source slices (line wrapping preserved, never reflowed) so spacing like a spaced em-dash is kept exactly.FlexDoc.block_at_offset(): the innermost structuralBlockcontaining an offset (the structural counterpart ofparagraph_at_offset; the name, freed in 0.1.0, now correctly returns aBlock).- Test-suite hardening: adversarial corpus documents (
inline_pathology,heading_edges,link_taxonomy); cross-projection invariants tyingtoc()to the heading blocks, inline nesting on the query surface, and link-form accounting; and a dogfood test that parses every Markdown file in the repo and asserts the invariants. See the spec’s “Why These Bugs Escaped the Tests” analysis.
Changed
These are breaking, made cleanly (no aliases) given the preview status:
Linkgains a requiredlink_form: LinkFormfield. DirectLink(...)construction must pass it.block_links()returns all link-like constructs (navigable links, images, and reference definitions), each with alink_form; previously it returned navigable links only.FlexDoc.links()filters to navigable links by default, so its default result is unchanged.collect()returns inline-kind nodes withoutrecursive=True. An inline-kind request (e.g.collect(kinds={NodeKind.link})) now widens the candidate set instead of silently returning[]—matching the documented behavior.
0.1.0 (2026-06-12)
First release.
Added
-
Initial flexdoc package, extracted from chopdiff as its own standalone distribution. This is the document/markdown layer—
FlexDoc, paragraphs/sentences, the block tree and block types, sections, the node table,collect(),DocGraph,SpanRef, token diffs/mappings, word tokenization, html-in-md, and read-time/token estimation—with no dependency on chopdiff’s diff and windowed-transform machinery.The import roots are
flexdoc.docs,flexdoc.html, andflexdoc.util. Parse behavior is unchanged from theflexdoc.*modules that previously shipped inside the chopdiff wheel; this release packages them independently. Seedocs/project/specs/active/plan-2026-06-11-flexdoc-extraction.md. -
A deliberate root API: the working set is importable from the package root —
FlexDoc,DocGraph,Detail,SpanRef,BlockType,NodeKind,Layer,TextUnit—designed against the known downstream users and pinned by contract tests. The render helpers for source-linked HTML (render_node_attrs,wrap_with_node_attrs,parse_source_span_attr) are public inflexdoc.docs. -
DocGraph paragraph view:
Views.paragraphsjoinstoc/blocks/links/sentencesin the serialized projection.
Changed (Relative to the Modules as Shipped in Chopdiff)
The first standalone release also refines the API surface (the pre-publish design
review, docs/project/review/senior-engineering-review-flexdoc-standalone-2026-06.md);
these are intentional hard cuts with no compatibility aliases:
TextDocis renamedFlexDoc—the package’s single entry point, named for the model it carries (all layered projections hang off it). It is importable from the package root:from flexdoc import FlexDoc. The module isflexdoc.docs.flex_doc(waschopdiff.docs.text_doc), and the design of record is nowdocs/flexdoc-spec.md(wastextdoc-spec.md).collect()is fully keyword-only and the deprecated aliases are gone: usesubtree_of=(wasscope=, previously also positional) andwithin=(wascontains=).- Editing-view methods are named in paragraph terms, so “block” always means the
structural layer:
FlexDoc.paragraph_at_offset(wasblock_at_offset),FlexDoc.iter_paragraphs(wasiter_blocks),Section.own_paragraphs/subtree_paragraphs(wereown_blocks/subtree_blocks). - The export surface is settled:
flexdoc.docsnow exportsCodeInfo/TableInfo/ListInfo,resolve/resolve_and_update,parse_blocks/walk_blocks/block_type_for, andDEFAULT_INCLUDE;flexdoc.htmlexportshtml_p,html_tag,escape_attribute,tag_wrapper, andidentity_wrapper. Link extraction is public asflexdoc.docs.links.block_links. Node.attrsvalues are JSON-typed (AttrValue), validated atDocGraphserialization, and node-id assignment order is pinned and tested for cross-language ports; layer nesting guarantees (LAYER_NESTING) are enforced at node-table build.- Internally, the former
text_doc.pywas split intoflex_doc.py,paragraphs.py,links.py, andsections.py(package imports fromflexdoc.docsare unaffected), andsections()is now cached like the other derived views.
Migration from chopdiff in one pass: chopdiff.docs.TextDoc → flexdoc.FlexDoc (or
flexdoc.docs.FlexDoc), chopdiff.docs.* → flexdoc.docs.*, plus the method renames
above.