feat(editor): eslint/tsconfig migration + type fixes
@robonen/editor: migrate to eslint flat config + composite tsconfig; fix convergence test type annotations.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import type { AttrValue } from '../model';
|
||||
|
||||
/** Specification for a single attribute: default, requiredness, validation. */
|
||||
export interface AttrSpec<V extends AttrValue = AttrValue> {
|
||||
readonly default?: V;
|
||||
readonly required?: boolean;
|
||||
readonly validate?: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
/** Map of attribute name → {@link AttrSpec}. */
|
||||
export type AttrsSpec = Readonly<Record<string, AttrSpec>>;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The content model of a block — a deliberately small, closed union instead of
|
||||
* ProseMirror's content-expression grammar (KISS).
|
||||
*
|
||||
* - `text`: holds inline content; `marks` whitelists which marks may apply,
|
||||
* - `container`: holds child blocks (reserved; no default block uses it yet),
|
||||
* - `atom`: holds no editable content (image, divider).
|
||||
*/
|
||||
export type ContentKind
|
||||
= | { readonly kind: 'text'; readonly marks?: 'all' | 'none' | readonly string[] }
|
||||
| { readonly kind: 'container'; readonly allow?: readonly string[]; readonly group?: string }
|
||||
| { readonly kind: 'atom' };
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Attrs } from '../model';
|
||||
|
||||
/** Placeholder marking where a node/mark's content should be spliced in. */
|
||||
export type DOMOutputHole = 0;
|
||||
|
||||
export type DOMOutputChild = DOMOutputSpec | DOMOutputHole;
|
||||
|
||||
/**
|
||||
* A serializable description of DOM output (ProseMirror-style), kept free of
|
||||
* real DOM so the schema layer stays pure. The view realizes it into elements.
|
||||
*
|
||||
* - `'text'` → a text node,
|
||||
* - `['tag', { attr: 'v' }, 0]` → `<tag attr="v">…content…</tag>`,
|
||||
* - the attrs object is optional; `0` is the content hole.
|
||||
*
|
||||
* The array part is an interface so the recursion (an element may contain nested
|
||||
* elements) is well-founded for the type checker.
|
||||
*/
|
||||
export type DOMOutputSpec = string | DOMOutputArray;
|
||||
|
||||
export interface DOMOutputArray extends ReadonlyArray<string | Record<string, string> | DOMOutputChild> {}
|
||||
|
||||
/**
|
||||
* A rule for parsing DOM (paste / HTML import) into a block or mark.
|
||||
* `getAttrs` receives a real `HTMLElement` (only ever called by the view); the
|
||||
* type reference is compile-time only and introduces no runtime DOM dependency.
|
||||
*/
|
||||
export interface ParseRule {
|
||||
/** CSS selector to match, e.g. `'a[href]'`, `'strong'`, `'h1'`. */
|
||||
readonly tag?: string;
|
||||
/** Inline-style match, e.g. `'font-weight=700'` (reserved for M2). */
|
||||
readonly style?: string;
|
||||
/** Static attrs applied when the rule matches. */
|
||||
readonly attrs?: Attrs;
|
||||
/** Derive attrs from the matched element; `false`/`null` rejects the match. */
|
||||
readonly getAttrs?: (el: HTMLElement) => Attrs | false | null;
|
||||
/** Higher priority rules are tried first. */
|
||||
readonly priority?: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './attr-spec';
|
||||
export * from './content-kind';
|
||||
export * from './dom';
|
||||
export * from './node-spec';
|
||||
export * from './mark-spec';
|
||||
export * from './schema';
|
||||
export * from './validate';
|
||||
export * from './normalize';
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Mark } from '../model';
|
||||
import type { AttrsSpec } from './attr-spec';
|
||||
import type { DOMOutputSpec, ParseRule } from './dom';
|
||||
|
||||
/** Schema contribution of a mark type. */
|
||||
export interface MarkSpec {
|
||||
/** Attribute specs (defaults + validation), e.g. link `href`. */
|
||||
readonly attrs?: AttrsSpec;
|
||||
/** Whether typing at the mark's boundary extends it (bold yes, link no). */
|
||||
readonly inclusive?: boolean;
|
||||
/** Marks that cannot coexist with this one; `'_all'` excludes every other. */
|
||||
readonly excludes?: readonly string[] | '_all';
|
||||
/** Nesting order in {@link DOMOutputSpec}: lower = outer wrapper. */
|
||||
readonly rank?: number;
|
||||
/** Serialize the mark to a DOM description wrapping its content. */
|
||||
readonly toDOM: (mark: Mark) => DOMOutputSpec;
|
||||
/** Rules for parsing DOM into this mark (paste / import). */
|
||||
readonly parseDOM: readonly ParseRule[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Node } from '../model';
|
||||
import type { AttrsSpec } from './attr-spec';
|
||||
import type { ContentKind } from './content-kind';
|
||||
import type { DOMOutputSpec, ParseRule } from './dom';
|
||||
|
||||
/** Schema contribution of a block type. */
|
||||
export interface NodeSpec {
|
||||
/** Content model (text / container / atom). */
|
||||
readonly content: ContentKind;
|
||||
/** Attribute specs (defaults + validation). */
|
||||
readonly attrs?: AttrsSpec;
|
||||
/** Group name for membership tests (e.g. `'block'`, `'list'`). */
|
||||
readonly group?: string;
|
||||
/** Keep this block's type/identity when merged into (e.g. code-block). */
|
||||
readonly defining?: boolean;
|
||||
/** Raw multiline text: Enter inserts a newline instead of splitting (code-block). */
|
||||
readonly code?: boolean;
|
||||
/** Selection and merge cannot cross this block's boundary. */
|
||||
readonly isolating?: boolean;
|
||||
/** Serialize a node of this type to a DOM description (HTML export). */
|
||||
readonly toDOM?: (node: Node) => DOMOutputSpec;
|
||||
/** Rules for parsing DOM into a node of this type (paste / import). */
|
||||
readonly parseDOM?: readonly ParseRule[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { EditorDocument, Inline, Node } from '../model';
|
||||
import { isInlineContent, normalizeInline, replaceBlocks } from '../model';
|
||||
import type { NodeSpec } from './node-spec';
|
||||
import type { Schema } from './schema';
|
||||
import { marksAllowed } from './schema';
|
||||
|
||||
function filterRunMarks(inline: Inline, spec: NodeSpec, schema: Schema): Inline {
|
||||
return inline.map(run => ({
|
||||
text: run.text,
|
||||
marks: run.marks.filter(mark => schema.markSpec(mark.type) !== undefined && marksAllowed(spec, mark.type)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a document to canonical form against a schema: coerce attrs, normalize
|
||||
* inline content, drop marks that are unknown or disallowed in their block, and
|
||||
* drop blocks of unknown type. This is the single funnel every document passes
|
||||
* through before it becomes editor state.
|
||||
*/
|
||||
export function normalizeDocument(doc: EditorDocument, schema: Schema): EditorDocument {
|
||||
const content: Node[] = [];
|
||||
|
||||
for (const block of doc.content) {
|
||||
const spec = schema.nodeSpec(block.type);
|
||||
|
||||
if (!spec)
|
||||
continue; // drop unknown block types
|
||||
|
||||
const attrs = schema.coerceAttrs(block.type, block.attrs);
|
||||
|
||||
if (spec.content.kind === 'text') {
|
||||
const inline = isInlineContent(block.content) ? block.content : [];
|
||||
content.push({ ...block, attrs, content: normalizeInline(filterRunMarks(inline, spec, schema)) });
|
||||
}
|
||||
else if (spec.content.kind === 'atom') {
|
||||
content.push({ ...block, attrs, content: block.content ?? null });
|
||||
}
|
||||
else {
|
||||
content.push({ ...block, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
return replaceBlocks(doc, content);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AttrValue, Attrs } from '../model';
|
||||
import type { AttrsSpec } from './attr-spec';
|
||||
import type { NodeSpec } from './node-spec';
|
||||
import type { MarkSpec } from './mark-spec';
|
||||
|
||||
/**
|
||||
* The compiled schema: the set of known node/mark specs plus attribute
|
||||
* coercion helpers. Projected from the registry (the registry is the SSOT).
|
||||
*/
|
||||
export interface Schema {
|
||||
readonly nodes: ReadonlyMap<string, NodeSpec>;
|
||||
readonly marks: ReadonlyMap<string, MarkSpec>;
|
||||
nodeSpec: (type: string) => NodeSpec | undefined;
|
||||
markSpec: (type: string) => MarkSpec | undefined;
|
||||
/** Default attrs for a block type (all defaults applied). */
|
||||
defaultAttrs: (type: string) => Attrs;
|
||||
/** Fill defaults and drop unknown keys for a block type. */
|
||||
coerceAttrs: (type: string, attrs?: Attrs) => Attrs;
|
||||
/** Default attrs for a mark type. */
|
||||
defaultMarkAttrs: (type: string) => Attrs;
|
||||
/** Fill defaults and drop unknown keys for a mark type. */
|
||||
coerceMarkAttrs: (type: string, attrs?: Attrs) => Attrs;
|
||||
}
|
||||
|
||||
function coerceWithSpec(spec: AttrsSpec | undefined, attrs?: Attrs): Attrs {
|
||||
if (!spec)
|
||||
return {};
|
||||
|
||||
const result: Record<string, AttrValue> = {};
|
||||
|
||||
for (const key in spec) {
|
||||
const provided = attrs?.[key];
|
||||
|
||||
if (provided !== undefined)
|
||||
result[key] = provided;
|
||||
else if (spec[key]!.default !== undefined)
|
||||
result[key] = spec[key]!.default!;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Build a {@link Schema} from node and mark spec maps. */
|
||||
export function createSchema(input: {
|
||||
nodes: ReadonlyMap<string, NodeSpec>;
|
||||
marks: ReadonlyMap<string, MarkSpec>;
|
||||
}): Schema {
|
||||
const { nodes, marks } = input;
|
||||
|
||||
return {
|
||||
nodes,
|
||||
marks,
|
||||
nodeSpec: type => nodes.get(type),
|
||||
markSpec: type => marks.get(type),
|
||||
defaultAttrs: type => coerceWithSpec(nodes.get(type)?.attrs),
|
||||
coerceAttrs: (type, attrs) => coerceWithSpec(nodes.get(type)?.attrs, attrs),
|
||||
defaultMarkAttrs: type => coerceWithSpec(marks.get(type)?.attrs),
|
||||
coerceMarkAttrs: (type, attrs) => coerceWithSpec(marks.get(type)?.attrs, attrs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a block spec holds inline (text) content. */
|
||||
export function isTextBlock(spec: NodeSpec): boolean {
|
||||
return spec.content.kind === 'text';
|
||||
}
|
||||
|
||||
/** Whether a block spec is an atom/void block. */
|
||||
export function isAtomBlock(spec: NodeSpec): boolean {
|
||||
return spec.content.kind === 'atom';
|
||||
}
|
||||
|
||||
/** Whether a block spec is a container of child blocks. */
|
||||
export function isContainerBlock(spec: NodeSpec): boolean {
|
||||
return spec.content.kind === 'container';
|
||||
}
|
||||
|
||||
/** Whether a mark of `markType` is allowed inside a block with this spec. */
|
||||
export function marksAllowed(spec: NodeSpec, markType: string): boolean {
|
||||
if (spec.content.kind !== 'text')
|
||||
return false;
|
||||
|
||||
const allowed = spec.content.marks;
|
||||
|
||||
if (allowed === undefined || allowed === 'all')
|
||||
return true;
|
||||
|
||||
if (allowed === 'none')
|
||||
return false;
|
||||
|
||||
return allowed.includes(markType);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { EditorDocument } from '../model';
|
||||
import type { Schema } from './schema';
|
||||
|
||||
export interface ValidationResult {
|
||||
readonly valid: boolean;
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural validation of a document against a schema. Reports unknown block
|
||||
* types, missing required attrs, and failed attr validators. Used in tests and
|
||||
* as a guard around untrusted input; runtime mutation paths rely on
|
||||
* {@link normalizeDocument} instead.
|
||||
*/
|
||||
export function validateDocument(doc: EditorDocument, schema: Schema): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const block of doc.content) {
|
||||
const spec = schema.nodeSpec(block.type);
|
||||
|
||||
if (!spec) {
|
||||
errors.push(`unknown block type: '${block.type}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!spec.attrs)
|
||||
continue;
|
||||
|
||||
for (const key in spec.attrs) {
|
||||
const attr = spec.attrs[key]!;
|
||||
const value = block.attrs[key];
|
||||
|
||||
if (attr.required && value === undefined && attr.default === undefined)
|
||||
errors.push(`block '${block.type}' is missing required attr '${key}'`);
|
||||
|
||||
if (attr.validate && value !== undefined && !attr.validate(value))
|
||||
errors.push(`block '${block.type}' has invalid attr '${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
Reference in New Issue
Block a user