Skip to content

fix(muya): preserve ordered list source markers - #4776

Merged
Jocs merged 9 commits into
marktext:developfrom
Renakoni:fix/preserve-ordered-list-markers
Sep 14, 2026
Merged

Jocs merged 9 commits into
marktext:developfrom
Renakoni:fix/preserve-ordered-list-markers

Conversation

@Renakoni

Copy link
Copy Markdown
Contributor

Closes #4772

Summary

Preserve ordered-list source markers when opening and saving Markdown.

Previously, MarkText parsed ordered lists into semantic list state but dropped each list item's original marker. The serializer then regenerated markers by incrementing the list-level start value.

For example:

Below is the numbered list:

1. One
1. Two
1. Three

Text after numbered list.

could be saved back as:

Below is the numbered list:

1. One
2. Two
3. Three

Text after numbered list.

That creates an avoidable diff for users who intentionally use repeated ordered-list markers, which is valid Markdown and common in source-oriented workflows.

This PR fixes the data-loss point directly: MarkdownToState now records the original ordered marker for parser-created list items, the Muya list-item block preserves that metadata through the live editor tree, and StateToMarkdown uses the stored marker when serializing. List items without preserved marker metadata, such as newly created editor items or manually constructed state, still use the existing generated-number fallback.

Type of change

  • Bug fix (non-breaking, fixes an issue)
  • New feature (non-breaking, adds functionality)
  • Breaking change (causes existing functionality to change)
  • Documentation update

Test plan

  • New tests added
  • Manually tested on: Windows 11 / local MarkText development environment

The following checks all passed:

pnpm --filter @muyajs/core exec vitest run src/state/__tests__/listSerialization.spec.ts src/state/__tests__/blockSerialization.spec.ts
pnpm --filter @muyajs/core lint:types
pnpm --filter @muyajs/core lint
pnpm --filter @muyajs/core exec vitest run --testTimeout 30000

The lint command reports existing complexity / regexp warnings, but 0 errors.

Manual verification used the #4772 Markdown fixture on Windows and confirmed the saved Markdown source still preserves 1. / 1. / 1.. The WYSIWYG view continues to render the ordered list visually as 1, 2, 3, which is expected <ol> behavior and separate from source-marker preservation.

Notes for reviewers [optional]

The root cause is that the state model only preserved ordered-list metadata at the list level:

{
    name: 'order-list',
    meta: {
        start: 1,
        delimiter: '.',
    },
    children: [...]
}

That is enough to render an ordered list, but not enough to round-trip the Markdown source. After parsing, the editor no longer knew whether the original items were written as 1. / 1. / 1., 1. / 2. / 3., 10) / 20), or another valid marker sequence.

The fix stores the source marker on parser-created list items:

{
    name: 'list-item',
    meta: {
        orderMarker: '1.',
    },
    children: [...]
}

StateToMarkdown now serializes that marker when present. If no item-level marker exists, it keeps the previous behavior and generates markers from order-list.meta.start.

This is intentionally source-preserving rather than a global formatting option. It does not force all ordered lists to use 1. and it does not change the editor's visual ordered-list rendering. The UI can still display semantic ordered lists as 1, 2, 3; the saved Markdown source keeps the user's original markers.

Regression coverage includes:

  • The exact [Bug] Marktext renumber numbered list #4772 repeated-marker example.
  • Non-canonical ordered markers such as 10) one / 20) two.
  • Nested repeated ordered markers.
  • Full Muya block-tree round-trip via muya.getMarkdown(), ensuring the metadata is not lost after parsing into live editor blocks.
  • Existing generated-number behavior for state without item-level source marker metadata.

By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license).

Copilot AI review requested due to automatic review settings June 28, 2026 04:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

this.createDomNode();
}

private static _cloneMeta(meta: IListItemState['meta']): IListItemState['meta'] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer a util function rather than static method?

@Jocs

Jocs commented Jul 4, 2026

Copy link
Copy Markdown
Member

Thanks for the fix — the core intent is right and the no-edit open→save round-trip is correct and well-tested. I found one save-time crash that should block merge, plus a couple of edit-time numbering inconsistencies that stem from a single design choice, and two small cleanups.

1. Blocker — dfm list indentation throws RangeError on save

packages/muya/src/state/stateToMarkdown.ts:574 — the new preservedMarker branch sets itemMarker directly and skips the n > 99 → n = 1 clamp that previously bounded marker width in dfm mode. Downstream, the unchanged line

listIndent = ' '.repeat(4 - itemMarker.length) // stateToMarkdown.ts:610

then receives a negative count.

dfm is a shipping user preference (prefComponents/markdown/config.ts → value: 'dfm', main/preferences/schema.json enum). Repro: set Preferences → Markdown → List indentation → DFM, then open/save any ordered list that contains an item numbered ≥ 100 (a 100+ item numbered list, or one authored like 100.):

100. one
101. two

→ RangeError: Invalid count value: -1, and save/getMarkdown throws. On develop the clamp reset those to 1., so it never threw. Confirmed locally by running the PR serializer on this input in dfm mode (throws) vs develop (does not).

Fix: apply the same width clamp to the preserved-marker path, or floor the repeat count at 0.

2. Middle-insert emits a duplicate ordered marker

packages/muya/src/state/stateToMarkdown.ts:583 (with packages/muya/src/block/content/paragraphContent/index.ts:499) — editor-created items have no meta.orderMarker, so they fall back to listInfo.start, which collides with the number a following preserved item still carries.

Repro: open 1. one / 2. two, put the caret at the end of "one" and press Enter. The Enter handler creates { name: 'list-item', children: [] } (no meta), so save produces:

1. one
2.
2. two

Two 2. markers, where develop renumbered cleanly to 1. / 2. / 3.. For the source-oriented workflows this PR targets, that reintroduces exactly the kind of avoidable diff it aims to remove.

3. Deleting the first item desyncs the displayed number from the saved source

packages/muya/src/state/stateToMarkdown.ts:571 vs order-list.meta.start — nothing recomputes order-list.meta.start (which drives <ol start=…>), while the serializer now emits each surviving item's preserved marker.

Repro: open 5. a / 6. b (editor shows 5, 6), delete "a" → the editor still shows the survivor as "5", but save writes 6. b; reopening parses start=6 and it now displays "6". Same root cause as #2. (Serialization half confirmed locally; the display half is inferred from the <ol start> rendering.)

Root cause tying #2 and #3 together

The marker is stored redundantly on each item while start/delimiter already live on the list — two sources of truth for the same fact. Per-item markers freeze at parse time and never participate in renumbering, so any structural edit (insert / delete / reorder) drifts. Worth considering whether preservation belongs at list granularity instead — e.g. a "preserve authored numbering" flag on order-list.meta that suppresses renumbering — which would make both #2 and #3 disappear.

4. Minor — duplicated listInfo.start++

packages/muya/src/state/stateToMarkdown.ts:576 and :583 increment identically; the counter advances once per ordered item regardless of branch. Compute only itemMarker in each arm and increment once after the if/else.

5. Minor — non-idiomatic meta clone

packages/muya/src/block/commonMark/listItem/index.ts:19,44,56 — every sibling block assigns this.meta = meta by reference in the constructor and clones inline with meta: { ...this.meta } in getState (see codeBlock, taskList, table/cell). Since orderMarker is a string there's no aliasing risk to guard against, so create can do listItem.meta = state.meta and getState can inline the spread, dropping the _cloneMeta helper.


The round-trip fix itself is correct; #1 is the merge blocker, #2/#3 are the design decision to settle, and #4/#5 are optional.

@Renakoni

Renakoni commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I pushed an update addressing these points.

For the _cloneMeta comment: agreed, I dropped the static helper and switched listItem back to the same style used
by sibling blocks: assign state.meta on create and inline-spread it in getState().

For the serializer issues: I changed the preservation model so source markers are only used for an unchanged parsed
ordered-list structure. markdownToState now stores the parsed item marker sequence on order- list.meta.sourceMarkers, and stateToMarkdown only preserves those markers when the current children still match
that original marker sequence. Once the list structure changes, e.g. insert/delete/reorder, serialization falls back
to meta.start + delimiter, so we avoid the mixed preserved/generated numbering drift.

This should address:

  • DFM crash: repeat() is now guarded with Math.max(0, 4 - itemMarker.length), so 100. / 101. no longer throws.
  • Middle insert duplicate marker: inserting a new item invalidates the source-marker signature, so the list renumbers
    as 1. / 2. / 3..
  • Delete-first desync: deleting an item also invalidates preservation, so saved markdown follows the list start
    value instead of a stale per-item marker.
  • Duplicate listInfo.start++: now incremented once after marker selection.
  • Non-idiomatic clone helper: removed.

I also added regression coverage for the DFM 100. case, middle insert, delete-first, unchanged repeated markers, and
the block-level round-trip path.

niyo-oyin added a commit to niyo-oyin/marktext-ja that referenced this pull request Jul 30, 2026
Upstream PRs applied (all diff-reviewed, tests included and passing):
- marktext#4776: preserve ordered-list source markers — opening and saving no
  longer rewrites 1./1./1. to 1./2./3. (marktext#4772, silent doc mutation)
- marktext#4788: UNC/WSL paths become valid file:// authority URLs
  (file://server/share/…, not file:////…) so network images load
  (marktext#4577, marktext#4563)
- marktext#4952: table columns size to content (min-width 10em → 2em, marktext#4894)
- marktext#4773: malformed percent-escape in a clicked link no longer throws an
  unhandled URIError (marktext#4749)
- marktext#4873: Shift+digit/punctuation keybindings recordable and matchable
  again (patch-package fix for @hfelix/electron-localshortcut, marktext#4863)
- marktext#4910: sidebar icons match by extension first, so Dockerfile-Notes.md
  gets the markdown icon (marktext#4890)
- marktext#4317: restored windows are clamped to the target display's work area
  (multi-monitor / DPI, marktext#2928, marktext#1947)

Found while integrating marktext#4776: cloneStateTree shallow-copied meta, so
array-valued fields (order-list sourceMarkers, table aligns) stayed
SHARED between a getState() clone and the live document — mutating a
returned tree corrupted the document. Meta arrays are now copied, and
the clone walks an explicit work list instead of recursing, so
pathological nesting (600-level lists, marktext#4747) can no longer overflow
the call stack (10k-depth regression test).

Also: folder search debounces its per-keystroke ripgrep run (300ms,
Enter searches immediately, IME-composing keys ignored) (marktext#3556), and a
new undoFloor spec pins that undoing past the opened baseline never
empties the document (marktext#5028 — legacy-engine bug, does not reproduce on
@muyajs/core).

muya 1536/1536, desktop 783/783, lint/tsc/madge clean. PLANS.md gains a
prioritized backlog from the full 563-item issue/PR survey.
Jocs and others added 6 commits September 14, 2026 21:27
The previous case read `muya.getMarkdown()` straight after boot, which
serializes the parsed JSON state without touching the block tree, so it
passed even with the list-item meta dropped from `ListItem.getState()`.
Replace the list with its clone first so the markers must survive the
block round trip that loose-list toggling and indenting rely on.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01D3wiiczGv6VCkvm7egv6h1
`compatibleTaskList` already matches each ordered item's marker to derive
the delimiter, so record the full marker on the token there instead of
re-running a second regex over `item.raw` twice per item in
`markdownToState`. Parsed states are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01D3wiiczGv6VCkvm7egv6h1
`JSONState.getState()` and the serializer both deep-clone state, and
nothing mutates `sourceMarkers` in place, so the dedicated clone helper
guards nothing. Keep `OrderList` in line with `BulletList` and
`TaskList`, which hold their meta by reference and shallow-copy it in
`getState()`.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01D3wiiczGv6VCkvm7egv6h1
The serializer already clones each list's meta onto `_listType` and
advances `start` on it per item. Drop `sourceMarkers` from that clone
when the list no longer matches its parsed items and shift one marker
per item, instead of widening the meta type with a preserve flag and a
second counter that tracked `start`. Output is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01D3wiiczGv6VCkvm7egv6h1
… reopen

The marktext#5341 and marktext#5349 specs assert that the state after Tab or Shift+Tab
equals the state the parser rebuilds from the saved markdown. Ordered
lists now carry their parsed markers, and an edit leaves those
describing the list as it was parsed, while reopening reparses fresh
ones. Compare structure without the marker hints; the markdown is still
required to round-trip unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01D3wiiczGv6VCkvm7egv6h1
@Jocs

Jocs commented Sep 14, 2026

Copy link
Copy Markdown
Member

Thanks for the rework — the fallback model fixes all five points from the first review. I pushed a few follow-up commits directly to the branch so this can land without another round trip:

  • Merge develop — the branch was ~2.5 months behind; CI now runs against the current base.
  • test(muya): rebuild the ordered list from its blocks in the marker test — the previous block-level case read muya.getMarkdown() right after boot. That serializes the parsed JSON state and never calls ListItem.getState(), so it stayed green with the item meta removed. The test now replaces the list with its clone first, which is the path loose-list toggling and indenting take; it fails if ListItem.getState() drops meta.
  • refactor(muya): read the ordered item marker where list tokens are split — compatibleTaskList already matches each ordered item's marker to get the delimiter, so it now records item.orderMarker there, replacing the second regex and the three helper methods in markdownToState. Parsed states are identical to before.
  • refactor(muya): assign the order-list meta like the other list blocks — JSONState.getState() and the serializer already deep-clone, and nothing mutates sourceMarkers in place, so OrderList goes back to holding its meta by reference like BulletList / TaskList.
  • refactor(muya): consume preserved markers from the cloned list meta — instead of the preserveOrderMarkers flag and an index that mirrored start, the serializer drops sourceMarkers from its per-list meta clone when the list no longer matches, and shifts one marker per item. Output is unchanged.
  • test(muya): ignore parsed ordered markers when comparing state across reopen — two specs added on develop since this PR opened ([Bug] Shift+Tab on a nested task list item puts a task item inside the parent bullet list #5341, [Bug] Tab on a list item appends it to the previous item's sublist even when that sublist holds the other item kind #5349) assert that the state after Tab/Shift+Tab equals the state reparsed from the saved markdown. After an edit, sourceMarkers / orderMarker still describe the list as parsed, while reopening parses fresh ones, so those specs now compare structure without the marker hints (the markdown must still round-trip unchanged).

Scope stays as you implemented it: authored markers are kept only while a parsed list is structurally unchanged, and any insert/delete/reorder renumbers from start.

@Jocs
Jocs merged commit e7dfe30 into marktext:develop Sep 14, 2026
6 checks passed
@Jocs Jocs mentioned this pull request Sep 14, 2026
1 task done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Marktext renumber numbered list

3 participants