Skip to Content
Numbering

Numbering

Lists and multi-level numbering. A numbered paragraph stores no counter text of its own. numberingPlugin (part of customPlugins, opt out with numberingPlugin: false) walks the document on each change, computes every counter, and keeps a counter_node in sync as the paragraph’s first child.

Two things are stored separately:

  • Definitions: doc.attrs.listConfigs, a map of listRef → level configs (format, text, start, suffix, tabStop, indent, gender, casing). These are document-global.
  • Counters: derived per story and never stored. See Regions.

Resolution merges the built-in default lists (Default1, Default2, DefaultBullet) under the doc’s own entries. A doc entry under a default ref shadows the built-in, so a document can redefine a default. A DOCX import never shadows one: a definition matching a built-in maps onto its ref, any other keeps its own. removeListConfig deletes the shadow and brings back the built-in.

Typing a prefix and a space at the start of an unnumbered paragraph starts a list: 1., 1), i., I., a), A. and similar for numbers, and *, - or for bullets. An immediate Backspace restores the typed text.

Where a paragraph’s numbering comes from

Numbering resolves through a cascade. The numbering node attr can hold four states, and null means inherit, not “unnumbered”:

numbering attrMeaning
null (default)Inherit: numbered only if the resolved style has a numbering entry
{ listRef: "X", … }Direct numbering: the paragraph owns its list
{ listRef: "__inherit", …overrides }List from the style; the other fields override it for this paragraph
{ listRef: null }Off-sentinel: the style numbers the paragraph, but the user turned it off

"__inherit" is the exported constant INHERIT_LIST_REF (from @stesura/core/constants). It is reserved and can never be a key of listConfigs: saveListConfigToDocAttrs, setStyleNumberingBinding and the doc guards all reject it.

Anything that decides what to render, export or enable must read the effective numbering, not the raw attr:

import { getEffectiveNumbering, getEffectiveNumberingAtSelection } from "@stesura/core/helpers"; const effective = getEffectiveNumbering(state, node); // → { listRef, level, instanceId, followBy?, tabStop?, fromStyle } | null // listRef is always a real listConfigs key — never "__inherit". getEffectiveNumberingAtSelection(state); // same, for the block at the selection

nodeIsNumbered(attrs) answers at the attr level only. It is true for any listRef, including "__inherit", and false for null and the off-sentinel. So a style-numbered paragraph with a null attr counts as unnumbered until the plugin stamps it. numberingListRef(attrs.numbering) gives the real list behind a stamped attr.

Style-linked numbering

A paragraph style can carry a numbering binding, as in Word’s legal templates. Paragraphs of that style number themselves with no per-node state, and a whole style family (Definitions L.1L.9) shares one listRef at different levels.

// doc.attrs.styleSheet "Definitions-L1": { name: "Definitions L.1", basedOn: "Normal", paragraphStyle: { numbering: { listRef: "legal", level: 1 } }, // optional followBy, tabStop }
  • The binding inherits through basedOn like every other paragraphStyle field, as a whole object (no field-level merge).
  • An explicit null resets a style that is based on a numbered style back to unnumbered.
  • The reverse map (listRef → level → styleId) is derived, never stored separately. At most one style may bind a given (listRef, level).
import { setStyleNumberingBinding } from "@stesura/core/commands"; import { getResolvedStyleSheet, getStyleForListLevel } from "@stesura/core/helpers"; // Bind. Any style already holding the slot is unbound for you. setStyleNumberingBinding("Definitions-L1", { listRef: "legal", level: 1 })(state, dispatch); // Unbind. setStyleNumberingBinding("Definitions-L1", null)(state, dispatch); // Which style owns a level, if any. getStyleForListLevel(getResolvedStyleSheet(state), "legal", 2);

Because the level is bound to a style, promote/demote becomes a style switch. adjustLevel on a style-numbered paragraph applies the style bound to the target level and resets numbering to null. It only writes a level override when no style is bound to the target level. Turning numbering on next to a style-numbered list works the same way: the paragraph adopts the neighbour’s style.

What the plugin stamps

The plugin is the only writer of the derived numbering attrs:

  • Direct paragraphs get { listRef, level, instanceId, followBy, isBullet } (plus the user’s tabStop, if set).
  • Style-numbered paragraphs keep listRef: "__inherit" and get the derived values under resolvedListRef, resolvedLevel, resolvedInstanceId and resolvedFollowBy, plus isBullet. Their plain level, instanceId, followBy and tabStop slots hold user overrides only and are kept as they are. resolveEffectiveNumbering ignores the resolved* values, so an edit to the binding always re-derives. Serialization and export read them as display values.

The plugin also heals: when a style stops numbering, a dangling "__inherit" and a now-meaningless off-sentinel both revert to null.

Never write the derived attrs from a style-apply command, paste handler or importer. Set the style (or the direct list) and let the walk fill them in.

Commands

All of these come from @stesura/core/commands and act on the selection:

CommandEffect
toggleCounter({ buttonType }) / toggleCounter({ levelFormat })The list button ("number" / "bullet") or a format pick. Pass exactly one. Toggles off, switches type, or continues an adjacent list.
applyListRef(listRef)Apply a list as direct numbering.
adjustLevel({ direction }) / adjustLevel({ level })"up" goes one level deeper and "down" one level shallower. "down" at level 1 removes the numbering. Clamped to the deepest level the list defines.
restartNumbering / continueNumberingStart a new run at the selected level-1 item, or rejoin the previous run of the same list.
removeNumberingClear the numbering and its counter node.
setNumberingAttrs(partial)Merge attrs into every node of the selected instance.
setFollowBy(followBy) / setListTabStop(px | null)Write the level config (see cascades).
updateNumbering({ listRef, instanceId, levels })Save edited levels and move the instance onto them. The ref is reused when an identical config exists, and forked when the old one is a default or shared.
saveListConfigToDocAttrs(ref, levels) / removeListConfig(ref)Write or delete a listConfigs entry directly.
cleanupUnusedListRefsDrop configs that no node or style references.

Regions

Word evaluates numbering per story. Definitions are document-global and counters are not. The walk starts a new region at each container boundary:

Region keyStory
bodyThe body, across all sections
footnotesAll footnote bodies together
endnotesAll endnote bodies together
hf:<sectionIndex>:<header|footer>:<variant>One header/footer part

The header/footer key uses the section’s 0-based index in the document, not its id attr (headerFooterRegion(sectionIndex, kind, variant)). The region constants and helpers are exported from @stesura/core/plugins.

A numbered list in a footnote therefore restarts at 1 instead of continuing the body’s count, even when both use the same listRef. Style-derived instances are scoped to a region too (styleNumberingInstanceId(listRef, region), which gives style:<listRef>:<region>), so a style family is one continuous list per story.

Instances and adjacency

An instance is a run of a list, not a list. Word’s rule is adjacency: a restart resets the definition’s counter, and everything after it on that list continues from the restart, whatever sits in between.

The walk tracks the run that is currently open for each (region, listRef). A numbered paragraph’s instance is, in order:

  1. its own explicit instanceId (the direct slot, or the override slot on "__inherit"), which is what a restart writes;
  2. else the open run the paragraph sits in;
  3. else styleNumberingInstanceId(listRef, region) when a style binds the list;
  4. else a fresh id.

That instance then becomes the open run. Paragraphs of other lists and unnumbered paragraphs don’t break it. A region boundary does.

So restartNumbering stamps one paragraph, the head, and the rest of the run follows. Later paragraphs are restamped only when they carry an explicit instanceId of their own, meaning they head a later run that moves along with it. A paragraph added to the run afterwards (typed, pasted, or restyled onto the numbered style) joins it with no stamp at all.

On export each distinct instance becomes its own w:num pointing at the shared abstract definition.

Sub-editors

Evaluation is root-side. The main editor’s plugin walks headers, footers and note bodies, so their content numbers correctly whether or not a sub-editor is open. Sub-editor views mount only numberingPropsPlugin, which moves typing at the very start of a numbered paragraph after the counter. They never run a second evaluating walk.

Definitions are not copied into a sub-editor’s doc attrs. Reads fall through to the root editor’s store, and config writes (saveListConfigToDocAttrs, removeListConfig, updateNumbering) are forwarded to the root doc as doc-attr steps.

The followBy / tabStop cascades

Both use the same order as the rest of numbering: the paragraph attr beats the style binding, which beats the level config:

  • followBy: node attr → style binding → level config suffix"tab".
  • tabStop: node attr → style binding → level config tabStop → editor default.

setFollowBy and setListTabStop write the level config, which is the tier DOCX exports. A default or shared config is forked first, so only the selected instance changes. On a style-numbered paragraph they also clear the binding’s copy of the field, so the config becomes the single source of truth. The new value is then pushed onto the instance’s node attrs, so it shows immediately.

Word formats and language

cardinalText, ordinalText and ordinal spell the counter out. They render in the document’s language (the paragraph’s effective proofingLanguage, not the UI locale), so an Italian paragraph numbers Quarantaduesimo while its English neighbour numbers Forty-Second.

Every format goes through one seam:

import { createListFormatOptions } from "@stesura/core/helpers"; const options = createListFormatOptions("it-IT", "feminine"); options.find((o) => o.value === "ordinalText")?.transform?.(42); // "Quarantaduesima"

Pickable vs renderable

Eight formats are pickable: decimal, upper/lower Roman, upper/lower letter, cardinal text, ordinal text and ordinal. These are what the level-format dropdown offers, through createPickableListFormatOptions.

createListFormatOptions returns more: decimalZero, hex, chicago, numberInDash, the enclosed ranges (①, ⑴, ⒈), full-width / Thai / Devanagari digits, and Russian / Hebrew / Arabic lettering. These are render-only, so an imported Word file displays its counters faithfully. Import and export pass format through verbatim either way.

A format outside the table renders as decimal, which is ST_NumberFormat’s own fallback. none (and bullet) render an empty counter and keep any literal characters in the level text. A %N is never echoed into the document.

Words come from gendered-to-words. Only the locales in NUMBER_LOCALES are bundled, through the library’s per-locale entry points, and they match the language picker’s SUPPORTED_LANGUAGES. Adding a language to the picker means adding it to NUMBER_LOCALES too, or a spec fails. An unrecognised proofingLanguage falls back instead of throwing. The order is: exact match → language+region ignoring script/extension subtags → any region of the same language → en-GB.

Gender

Levels take an optional gender: "masculine" (the default) or "feminine". This lets “Articolo Primo” and “Sezione Prima” share a document. It applies per level, so "%1.%2" can mix the two. It affects the three word formats and only the languages with distinct forms (es, fr, it, pt, localeHasNumberGenderVariants). Other formats and languages ignore it.

Casing

Levels take an optional casing for cardinalText and ordinalText: "capitalize" (the native output, “Forty-Two”), "uppercase", "lowercase" or "firstLetter" (“Forty-two”).

gender and casing are local extensions with no w:lvl equivalent. Unlike tabStop, they are stripped on DOCX export and don’t survive a round trip.

DOCX

EditorOOXML
paragraphStyle.numberingw:numPr in the style’s pPr (styles.xml)
Style bound to a levelw:lvl/w:pStyle back-link in numbering.xml
"__inherit" paragraph matching its styleno paragraph w:numPr — Word reads the style
"__inherit" with an overrideexplicit paragraph w:numPr
Off-sentinelw:numId 0
Level startw:start
listRefthe w:abstractNum — one counter per definition, as in Word
instanceIda w:num over that abstract

Import: abstracts, not numIds

Word runs one counter per w:abstractNum. A w:num names that definition and optionally restarts it; it is not a list of its own. So a listRef is the resolved abstractNumId, and every w:num over it is classified:

  • alias — no w:lvlOverride, or overrides structurally identical to the abstract’s levels (Word writes a full w:lvl clone with every restart, so the clone’s presence means nothing);
  • restart — identical clones plus a w:startOverride on ilvl 0 at the level’s own start. The restart is consumed on the num’s first use in the document (ECMA-376 §17.9.31); later uses continue the run. A num some style’s w:numPr names is never a restart: it is the definition’s base run (Word writes no restart there; docx-js stamps one on every concrete num);
  • fork — a num that really redefines a level, restarts at some other value, or restarts only deeper levels. It gets its own config <abstract>.<numId> and a numbering_mismatch warning.

A paragraph naming an alias or a consumed restart carries no instanceId when a style binds the list — the plugin’s carry supplies it — so a paragraph with a direct w:numPr and the style-only paragraph after it are one continuous run.

Import also resolves the w:numStyleLink indirection legal templates use, and marks style-referenced definitions as used so the anti-garbage filter keeps the configs the styles point at. A style carrying w:outlineLvl is not promoted to a heading node when it is style-numbered — Word’s legal numbering styles use the outline level for navigation only.

w:numPr with a w:ilvl and no w:numId is a level override on an inherited binding, both on a style (resolved as a separate slot up the basedOn chain) and on a paragraph (stamped as { listRef: "__inherit", level }). A paragraph that must not be numbered — a dangling w:numId, w:numId 0, the continuation fragment of a split paragraph — gets the off-sentinel when its style is numbered: null means inherit, which would renumber it.

Last updated on