Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/partial-markdown/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## 0.5.8 — 2026-07-11

### Changed

- **Push-style projection now has one canonical root and event graph.** Events
reference the same public node objects exposed through `parser.root`, without
synthetic negative-ID nodes or partial delimiter text leaking into the live
tree. Matching projected nodes retain their object identity when committed.
- **Event ordering is deterministic.** Replaced nodes complete before new nodes
are created, followed by updates, with stable tree-order traversal within
each phase.
- **Public numeric IDs identify live nodes.** IDs are nonnegative and unique
among live nodes, and continuing nodes keep their IDs. During an explicit
same-operation grammar reinterpretation, a replacement may inherit the
retired node's ID only after the old incarnation completes. Independent
parser instances need not assign matching IDs.

### Fixed

- **Display-math projections update the canonical node immediately.** Streaming
display-math text is visible on the existing public node as each chunk
arrives, while that same object proceeds to completion.

### Performance

- Added incremental fast-path guards for long plain-text and punctuation-heavy
open lines, including leading-pipe input, to prevent repeated full-line
projection work while preserving structural fallback when Markdown becomes
decisive.

## 0.5.7 — 2026-07-09

### Fixed
Expand Down
44 changes: 36 additions & 8 deletions packages/partial-markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ renderers.

## Mental Model

The parser builds a Markdown node tree. Nodes are mutated in place as more input
arrives. Each node has:
The parser builds one canonical public Markdown node graph. Nodes are mutated in
place as more input arrives, including optimistic open-line projections. Each
node has:

- `id`: stable numeric identity for the lifetime of the parser.
- `id`: nonnegative numeric identity that is unique among live nodes.
- `type`: Markdown node type.
- `status`: `pending`, `streaming`, or `complete`.
- `parent`: parent node or `null`.
Expand All @@ -88,6 +89,11 @@ linkDefinitions: Map<string, LinkDefinition>
Citation and link-reference definitions are lifted out of the visible block
tree and stored on the root.

Continuing nodes keep their IDs. During an explicit same-operation grammar
reinterpretation, a replacement may inherit the retired node's ID only after
the old incarnation completes. Independent parser instances need not assign
matching IDs, so do not use numeric IDs as a cross-parser identity contract.

## Push-Style API

Use the push-style API for streaming UIs and long-lived node references.
Expand All @@ -105,7 +111,7 @@ for await (const chunk of llmStream) {

for (const event of events) {
if (event.type === 'value-updated') {
// event.node is the same object reference across future pushes.
// This is the canonical node object also exposed through parser.root.
}
}

Expand All @@ -126,6 +132,17 @@ interface ParseEvent {
}
```

Events and `parser.root` share the same canonical node objects; the parser does
not create a separate event-only projection graph. When an open-line projection
matches the committed parse, its node objects carry through to the committed
tree instead of being replaced. A `node-completed` event can refer to a node
that has just been retired by structural replacement; events for live nodes
refer to the objects reachable from the current root.

Event order is deterministic. Retired-node completions are emitted before
creations, then value updates; traversal within those phases is stable tree
order. Status-only completions for retained nodes are emitted in pre-order.

### Path Lookup

`getByPath()` accepts JSON Pointer-like paths over `children`:
Expand Down Expand Up @@ -158,9 +175,11 @@ warnings through `state.warnings`.

## Structural-Sharing Snapshots

`materialize(node)` converts a parser node tree into a plain JavaScript object
graph. It uses a `WeakMap` cache keyed by node identity, so unchanged subtrees
return the same object reference across calls.
`materialize(node)` converts the canonical parser node graph into a plain
JavaScript object graph. It uses a `WeakMap` cache keyed by node identity, so
unchanged subtrees return the same materialized object reference across calls.
When a canonical node changes in place, that node and the affected ancestor
path are rematerialized while unrelated subtrees retain their references.

```ts
const before = materialize(parser.root);
Expand Down Expand Up @@ -347,7 +366,16 @@ same purpose.
`@cacheplane/partial-markdown` guarantees:

- Truncated Markdown is parseable as in-progress input.
- Public push-style node object identity is stable across pushes.
- Push-style events and `parser.root` expose one canonical public node graph.
- Public push-style node object identity is stable across pushes, including
when a matching projected node commits.
- Numeric node IDs are nonnegative and unique among live nodes; continuing
nodes keep their IDs. During an explicit same-operation grammar
reinterpretation, a replacement may inherit the retired node's ID only after
the old incarnation completes. Independent parser instances need not assign
matching IDs.
- Event ordering is deterministic, with replacement completions before
creations and updates.
- Node status uses the same `pending | streaming | complete` lifecycle as
`@cacheplane/partial-json`.
- `materialize()` preserves references for unchanged subtrees.
Expand Down
3 changes: 2 additions & 1 deletion packages/partial-markdown/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cacheplane/partial-markdown",
"version": "0.5.7",
"version": "0.5.8",
"description": "Streaming partial-Markdown parser with identity preservation, push/pull APIs, JSON Pointer lookups, and structural-sharing materialization.",
"type": "module",
"main": "./dist/index.cjs",
Expand All @@ -23,6 +23,7 @@
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ describe('math across chunk boundaries', () => {
const math = parser.root!.children[0] as any;

parser.push('\\sum_');
expect(math.text).toBe('');
expect(parser.root!.children[0]).toBe(math);
expect(math.text).toBe('\\sum_');
parser.push('i x_i\n');
expect(math.text).toBe('\\sum_i x_i');
parser.push('= y\n$$\n');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ function materializeChunks(chunks: string[]): unknown {
return materialize(parser.root);
}

function normalizeNumericIds(value: unknown): unknown {
if (value instanceof Map) {
return new Map([...value].map(([key, entry]) => [key, normalizeNumericIds(entry)]));
}
if (Array.isArray(value)) return value.map(normalizeNumericIds);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).flatMap(([key, entry]) =>
key === 'id' && typeof entry === 'number' ? [] : [[key, normalizeNumericIds(entry)]],
),
);
}
return value;
}

describe('chunk partition equivalence', () => {
const samples = [
'Hello world.\n',
Expand All @@ -29,8 +44,33 @@ describe('chunk partition equivalence', () => {
const baseline = materializeChunks([input]);

for (const chunks of representativePartitions(input)) {
expect(materializeChunks(chunks)).toEqual(baseline);
expect(normalizeNumericIds(materializeChunks(chunks))).toEqual(normalizeNumericIds(baseline));
}
});
}

it('keeps citation sidecar child IDs stable and unique within a parser session', () => {
const input = 'See [^src1].\n\n[^src1]: Source title <https://example.com>\n';
const parser = createPartialMarkdownParser();
parser.push(input);

const definition = parser.root!.citations.get('src1')!;
const sidecarChildren = [...definition.children];
const sidecarIds = sidecarChildren.map((child) => child.id);

parser.push('More text.');

const visibleIds: number[] = [];
const visit = (node: any): void => {
visibleIds.push(node.id);
for (const child of node.children ?? []) visit(child);
};
visit(parser.root!);
const retainedChildren = parser.root!.citations.get('src1')!.children;

expect(retainedChildren).toEqual(sidecarChildren);
expect(retainedChildren.map((child) => child.id)).toEqual(sidecarIds);
expect(sidecarIds.every((id) => id >= 0)).toBe(true);
expect(new Set([...visibleIds, ...sidecarIds]).size).toBe(visibleIds.length + sidecarIds.length);
});
});
Loading
Loading