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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ can depend on it without inheriting that copyleft.

## Status

**Phase 15 shipped** — CommonMark setext level-2 (`text` + continuous `---`)
vs thematic-break parity (Noto intentional golden gap #1). **Phase 14** — nest /
**Phase 16 shipped** — mixed-marker nested lists stay one span when indented to
the parent item content column (Noto intentional golden gap #2). **Phase 15** —
CommonMark setext level-2 (`text` + continuous `---`) vs thematic-break parity
(Noto intentional golden gap #1). **Phase 14** — nest /
interrupt parity vs micromark: definition lazy continuations, GFM tables
interrupt paragraphs, and lists keep indented nested blocks after a blank.
**Phase 13** — CommonMark lazy continuation in the native
Expand All @@ -42,14 +44,14 @@ thematic / GFM tables / task lists / **display math** / **YAML frontmatter** /
Synthetic medium/large A/B vs micromark (via legacy entry): native ~4.5 ms /
~14.5 ms vs micromark ~304 ms / ~1.5 s (see
[`docs/design/bench.md`](docs/design/bench.md)).
**v0.1.12** — setext-`---` vs hr. **v0.1.11** — adjacent defs as block starts.
**v0.1.13** — mixed-marker nested lists. **v0.1.12** — setext-`---` vs hr. **v0.1.11** — adjacent defs as block starts.
**v0.1.10** — nest / interrupt parity. **v0.1.9** — lazy continuation.
**v0.1.8** — CJK / Typora-shaped strong lock-in. **v0.1.7** —
`sourceEditBetween` / `reparseFromText`. **v0.1.6** line-prefix offset
alignment. **v0.1.5** table delimiter widening. **v0.1.4** verbatim runs + bare
autolink. **v0.1.3** hard-break + list-marker; **v0.1.2** native indented-code;
**v0.1.1** quote/callout split.
Noto may pin `github:roobli/md#v0.1.12` when ready.
Noto may pin `github:roobli/md#v0.1.13` when ready.

See:

Expand Down
5 changes: 5 additions & 0 deletions docs/design/contract-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ before `isBlockStart` treats `---` as thematic. Spaced `- - -` and `*`/`_`
markers stay thematic; standalone `---` / frontmatter unchanged. Closes Noto
intentional golden gap #1 (setext-`---` vs hr).

**Phase 16**: Mixed-marker nested lists — while extending a list, a different
family list item whose indent reaches the current sibling content column stays
in the same span (micromark nest parity). Unindented / under-indented mixed
markers still open a new list. Closes Noto intentional golden gap #2.

## Correctness goals

- CommonMark subset for the block kinds above
Expand Down
15 changes: 15 additions & 0 deletions docs/design/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,18 @@ table-interrupt and list-nested indented blocks is host-side.

Phase 15 **shipped** on `main` (v0.1.12). Noto host golden / IR→PM adoption of
setext-`---` headings remains host-side.

## Phase 16 acceptance

- [x] Mixed-marker nested lists (bullet under ordered / ordered under bullet)
stay one span when the nest reaches the parent item content column
(CommonMark / micromark); insufficient indent still opens a new list
- [x] Blank-then-indented mixed nest continues the parent list; nested tasks do
not flip the outer span to `task-list` unless a sibling item is a task
- [x] Same-family nesting unchanged; `joinSplit` identity + kind sequences in
`native-scanner.test.ts`; package `0.1.13`
- [x] Closes Noto intentional golden diff #2 (mixed-marker nested lists) on the
engine side

Phase 16 **shipped** on `main` (v0.1.13). Noto host golden / IR→PM adoption of
cross-family nests remains host-side.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@roobli/md",
"version": "0.1.12",
"version": "0.1.13",
"description": "WYSIWYG-first markdown engine for Noto — block-oriented, byte-exact friendly, fast on large vaults.",
"license": "MIT",
"type": "module",
Expand Down
53 changes: 53 additions & 0 deletions src/backend/native-scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,59 @@ describe('tryNativeSplit', () => {
expect(tryNativeSplit(alone)!.spans.map((s) => s.kind)).toEqual(['thematic-break']);
});

it('Phase 16: mixed-marker nested lists stay one span (micromark parity)', () => {
const orderedBullet = '1. outer\n - nested\n2. outer two\n';
const ob = tryNativeSplit(orderedBullet)!;
expect(joinSplit(ob)).toBe(orderedBullet);
expect(ob.spans.map((s) => s.kind)).toEqual(['ordered-list']);
expect(ob.spans[0]!.markdown).toBe('1. outer\n - nested\n2. outer two');

const bulletOrdered = '- outer\n 1. nested\n- outer two\n';
const bo = tryNativeSplit(bulletOrdered)!;
expect(joinSplit(bo)).toBe(bulletOrdered);
expect(bo.spans.map((s) => s.kind)).toEqual(['bullet-list']);
expect(bo.spans[0]!.markdown).toBe('- outer\n 1. nested\n- outer two');

const multi = '1. outer\n - nested a\n - nested b\n2. outer two\n';
expect(tryNativeSplit(multi)!.spans.map((s) => s.kind)).toEqual(['ordered-list']);
expect(tryNativeSplit(multi)!.spans[0]!.markdown).toBe(
'1. outer\n - nested a\n - nested b\n2. outer two',
);

const deep = '1. outer\n - nested\n 1. deep ordered\n2. outer two\n';
expect(tryNativeSplit(deep)!.spans.map((s) => s.kind)).toEqual(['ordered-list']);
expect(tryNativeSplit(deep)!.spans[0]!.markdown).toBe(
'1. outer\n - nested\n 1. deep ordered\n2. outer two',
);

// Blank then indented mixed nest stays in the list.
const blankNest = '1. outer\n\n - nested\n2. two\n';
const bn = tryNativeSplit(blankNest)!;
expect(joinSplit(bn)).toBe(blankNest);
expect(bn.spans.map((s) => s.kind)).toEqual(['ordered-list']);
expect(bn.spans[0]!.markdown).toBe('1. outer\n\n - nested\n2. two');

// Nested task under ordered stays ordered-list (kind follows outer siblings).
const taskNest = '1. outer\n - [ ] nested task\n2. two\n';
expect(tryNativeSplit(taskNest)!.spans.map((s) => s.kind)).toEqual(['ordered-list']);

// Insufficient indent: mixed marker starts a new list (micromark).
const split2 = '1. a\n - b\n';
expect(tryNativeSplit(split2)!.spans.map((s) => s.kind)).toEqual([
'ordered-list',
'bullet-list',
]);
const split0 = '1. a\n- b\n';
expect(tryNativeSplit(split0)!.spans.map((s) => s.kind)).toEqual([
'ordered-list',
'bullet-list',
]);

// Same-family nest still one span.
const same = '- a\n - b\n- c\n';
expect(tryNativeSplit(same)!.spans.map((s) => s.kind)).toEqual(['bullet-list']);
});

it('natively splits GFM tables with exact offsets', () => {
const text = '| a | b |\n| - | - |\n| 1 | 2 |\n';
const split = tryNativeSplit(text);
Expand Down
39 changes: 33 additions & 6 deletions src/backend/native-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
* unprefixed paragraph lines inside quotes and list items (micromark parity).
* Phase 14: definition lazy continuations; GFM tables interrupt paragraphs;
* lists keep indented nested blocks after a blank (micromark nest/interrupt parity).
* Phase 15: setext `---` vs thematic-break while extending paragraphs.
* Phase 16: mixed-marker nested lists stay one span when indented to the
* parent item content column (micromark parity; Noto intentional golden gap #2).
*/

import type { BlockKind } from '../kinds.js';
Expand Down Expand Up @@ -539,13 +542,39 @@ export function tryNativeSplit(text: string): SplitDocument {
const ordered = orderedMarker(line.content) !== null;
let hasTask = isTaskListItem(line.content);
const start = spanStartAfterPrefix(line);
// Content column after the current sibling item's marker. Mixed-marker
// nests must reach this indent (CommonMark / micromark); same-family
// siblings update it. Phase 16.
const openingMarker = (orderedMarker(line.content) ?? bulletMarker(line.content))!;
let nestIndent = openingMarker[0].length;
let j = i + 1;

/** Absorb a list-item line into this span, or signal break. */
const absorbListItem = (content: string): 'keep' | 'break' => {
const marker = orderedMarker(content) ?? bulletMarker(content);
if (!marker) return 'break';
const nextOrdered = orderedMarker(content) !== null;
const nextIndent = indentOf(content);
const nested = nextIndent >= nestIndent;
if (nextOrdered !== ordered) {
// Phase 16: indented mixed-marker nest stays in this span.
if (!nested) return 'break';
return 'keep';
}
// Same-family: siblings (indent < nestIndent) may flip task-list and
// refresh nestIndent; nested same-family keep parent nestIndent so a
// later mixed nest under the parent still matches micromark.
if (!nested) {
if (isTaskListItem(content)) hasTask = true;
nestIndent = marker[0].length;
}
return 'keep';
};

while (j < lines.length) {
const next = lines[j]!;
if (isListItem(next.content)) {
const nextOrdered = orderedMarker(next.content) !== null;
if (nextOrdered !== ordered) break;
if (isTaskListItem(next.content)) hasTask = true;
if (absorbListItem(next.content) === 'break') break;
j += 1;
continue;
}
Expand All @@ -554,9 +583,7 @@ export function tryNativeSplit(text: string): SplitDocument {
while (k < lines.length && isBlank(lines[k]!.content)) k += 1;
if (k >= lines.length) break;
if (isListItem(lines[k]!.content)) {
const nextOrdered = orderedMarker(lines[k]!.content) !== null;
if (nextOrdered !== ordered) break;
if (isTaskListItem(lines[k]!.content)) hasTask = true;
if (absorbListItem(lines[k]!.content) === 'break') break;
j = k;
continue;
}
Expand Down
Loading