Normalizer rules
Repair the document after every edit with schema rules or your own.
A Normalizer runs after a transaction is applied and before the engine commits. Rules receive a normalize target and either return a replacement target or null. The runner repeats all rules until the target reaches a fixed point.
This is the right layer for semantic repair. Keep rules focused on documents, fragments, metadata, and trace tags.
Examples assume an engine from Quickstart.
What belongs in normalization
Normalization is a good fit for:
- Apply canonical schema repair after every edit.
- Ensure required wrapper blocks exist.
- Repair imported document shapes into canonical nodes.
- Normalize app-specific structural invariants that can be expressed from the semantic document alone.
- Add metadata and trace tags for repairs.
Use the canonical schema rules
import {
createCanonicalSchemaNormalizer,
createEditorEngine,
createPlanner,
} from "@spine-editor/core";
const engine = createEditorEngine({
initialState,
planner: createPlanner(),
normalizer: createCanonicalSchemaNormalizer(),
});
Equivalent to createNormalizer({ rules: createSchemaNormalizationRules(canonicalSchema) }).
Add a custom rule
import {
branchNode,
createNormalizer,
replaceNormalizedDoc,
textNode,
type NormalizerRule,
type NodeTypeName,
} from "@spine-editor/core";
const fillEmptyDoc: NormalizerRule = {
id: "app:fill-empty-doc",
normalize(target) {
if (target.kind !== "doc") return null;
if (target.doc.root.children.length > 0) return null;
return replaceNormalizedDoc({
root: branchNode("doc" as NodeTypeName, [
branchNode("paragraph" as NodeTypeName, [textNode("Start typing...")]),
]),
});
},
};
Rules should be narrow and idempotent. If the document already satisfies the invariant, return null.
Compose with schema rules
import {
canonicalSchema,
createNormalizer,
createSchemaNormalizationRules,
} from "@spine-editor/core";
const normalizer = createNormalizer({
rules: [...createSchemaNormalizationRules(canonicalSchema), fillEmptyDoc],
});
Schema rules first, app rules last.
Repair fragments too
The same normalizer can run against document targets and fragment targets. createSchemaNormalizationRules(canonicalSchema) already returns both the document repair rule and the fragment repair rule in deterministic order.
import {
canonicalSchema,
createNormalizer,
createSchemaNormalizationRules,
} from "@spine-editor/core";
const normalizer = createNormalizer({
rules: createSchemaNormalizationRules(canonicalSchema),
});
Fragment normalization is useful for import and paste pipelines that already produced a semantic fragment:
const result = normalizer.normalizeFragment(importedFragment, {
source: "import",
meta: { importer: "app-html" },
});
if (result.changed) {
console.log(result.appliedRuleIds);
}
engine.dispatch({
kind: "insertFragment",
parentPath: [],
index: engine.state.doc.root.children.length,
fragment: result.target.fragment,
});
Annotate a result
return replaceNormalizedDoc(nextDoc, {
meta: { normalizedBy: "app:fill-empty-doc" },
traceTags: ["app", "auto-repair"],
});
Convergence
Rules must reach a fixed point — running them on their own output must produce no further change. Otherwise the runner throws NormalizationDidNotConvergeError after maxPasses.
When convergence fails, simplify the rule and test the exact input/output pair. A good normalization rule makes one deterministic repair and then stops.
Core and DOM boundary
The DOM runtime renders after commit, so normalization should depend on semantic documents and fragments. If browser evidence suggests a repair, convert the evidence into an intent in the substrate, let the planner produce operations, and let normalization repair the semantic result.