diff --git a/README.md b/README.md index c219d64..636378b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/docs/design/contract-v0.md b/docs/design/contract-v0.md index db3853f..13b98ae 100644 --- a/docs/design/contract-v0.md +++ b/docs/design/contract-v0.md @@ -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 diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index 3340a26..eb7a4c5 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -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. diff --git a/package.json b/package.json index 3efe311..2650b60 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backend/native-scanner.test.ts b/src/backend/native-scanner.test.ts index 6fde615..204bbf1 100644 --- a/src/backend/native-scanner.test.ts +++ b/src/backend/native-scanner.test.ts @@ -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); diff --git a/src/backend/native-scanner.ts b/src/backend/native-scanner.ts index 4dd32dc..05a8713 100644 --- a/src/backend/native-scanner.ts +++ b/src/backend/native-scanner.ts @@ -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'; @@ -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; } @@ -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; }