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:
2026-06-07 16:30:05 +07:00
parent 626fbc70d8
commit 09272dffeb
136 changed files with 7248 additions and 0 deletions
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { heading, paragraph } from '../../blocks';
import { bold } from '../../marks';
import { createRegistry } from '../registry';
describe('registry', () => {
it('projects a schema from definitions', () => {
const registry = createRegistry({ blocks: [paragraph, heading], marks: [bold] });
expect(registry.schema.nodeSpec('paragraph')).toBeDefined();
expect(registry.schema.markSpec('bold')).toBeDefined();
expect(registry.getBlock('heading')?.meta?.title).toBe('Heading');
expect(registry.listBlocks().map(block => block.type)).toEqual(['paragraph', 'heading']);
});
it('throws on a duplicate type by default', () => {
expect(() => createRegistry({ blocks: [paragraph, paragraph] })).toThrow();
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { Component } from 'vue';
import type { Attrs, Content, Node } from '../model';
import type { NodeSpec } from '../schema';
import type { CommandFactory } from '../state/command';
import type { InputRuleSpec } from './input-rule';
/** Props passed to an atom/void block's Vue `component`. */
export interface BlockComponentProps {
/** The block's model node (read its `attrs`). */
node: Node;
/** Whether the block is currently node-selected. */
selected: boolean;
/** Editor-level editable flag. */
editable: boolean;
/** Merge new attrs into the block (e.g. image src/caption). */
update: (attrs: Attrs) => void;
}
/** Presentational/discovery metadata: powers slash menu, conversion, toolbars. */
export interface BlockMeta {
readonly title: string;
readonly icon?: string;
readonly keywords?: readonly string[];
readonly group?: string;
}
/** Optional block-specific behaviors used by core commands. */
export interface BlockBehavior {
/** Content for a fresh empty block of this type (defaults to empty inline). */
readonly empty?: () => Content;
/** Plain-text extraction (defaults to inline text). */
readonly toText?: (node: Node) => string;
}
/**
* A block definition: schema contribution + behavior + an opaque Vue component.
* Non-view layers treat `component` as an opaque value; only the view resolves
* it. The type is `Component` purely for authoring ergonomics (type-only import).
*/
export interface BlockDefinition {
readonly type: string;
readonly spec: NodeSpec;
readonly component?: Component;
readonly meta?: BlockMeta;
readonly behavior?: BlockBehavior;
readonly commands?: Record<string, CommandFactory>;
/** Wrapper element tag for the block (default `'div'`). */
readonly as?: string;
/** Placeholder text shown when an empty text block has focus. */
readonly placeholder?: string;
readonly inputRules?: readonly InputRuleSpec[];
}
/** Identity factory that narrows a block definition's literal type (cf. `definePlugin`). */
export function defineBlock<const D extends BlockDefinition>(def: D): D {
return def;
}
+26
View File
@@ -0,0 +1,26 @@
import type { MarkSpec } from '../schema';
import type { InputRuleSpec } from './input-rule';
/** Presentational/discovery metadata for a mark (toolbar label, shortcut hint). */
export interface MarkMeta {
readonly title: string;
readonly icon?: string;
readonly hotkey?: string;
}
/**
* A mark definition: schema contribution (attrs, exclusivity, rank, toDOM,
* parseDOM) + metadata. Marks are data-only — the view renders/parses them via
* the spec, which is what makes them fully modular through the registry.
*/
export interface MarkDefinition {
readonly type: string;
readonly spec: MarkSpec;
readonly meta?: MarkMeta;
readonly inputRules?: readonly InputRuleSpec[];
}
/** Identity factory that narrows a mark definition's literal type (cf. `definePlugin`). */
export function defineMark<const D extends MarkDefinition>(def: D): D {
return def;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './input-rule';
export * from './define-block';
export * from './define-mark';
export * from './registry';
+15
View File
@@ -0,0 +1,15 @@
import type { Attrs } from '../model';
/**
* A pattern that transforms the current block or marks when typed (e.g. `'# '`
* → heading, `'**x**'` → bold). The matching engine lands in M2; the type is
* declared now so block/mark definitions can carry their rules as data.
*/
export interface InputRuleSpec {
/** Pattern tested against the text ending at the caret. */
readonly match: RegExp;
/** Target block/mark type to apply on match. */
readonly type?: string;
/** Attrs to apply with the transformation. */
readonly attrs?: Attrs;
}
+99
View File
@@ -0,0 +1,99 @@
import type { MarkSpec, NodeSpec, Schema } from '../schema';
import { createSchema } from '../schema';
import type { BlockDefinition } from './define-block';
import type { MarkDefinition } from './define-mark';
/** How to resolve two definitions registered under the same type. */
export type ConflictPolicy = 'throw' | 'last-wins' | 'first-wins';
/**
* The single source of truth for which block and mark types exist and how they
* behave. Immutable: built once via {@link createRegistry}; {@link extendRegistry}
* returns a new registry. The {@link Schema} is projected from the definitions.
*/
export interface Registry {
readonly blocks: ReadonlyMap<string, BlockDefinition>;
readonly marks: ReadonlyMap<string, MarkDefinition>;
readonly schema: Schema;
getBlock: (type: string) => BlockDefinition | undefined;
getMark: (type: string) => MarkDefinition | undefined;
/** Definitions in registration order (drives slash menu / toolbars). */
listBlocks: () => readonly BlockDefinition[];
listMarks: () => readonly MarkDefinition[];
/** Alias of {@link listMarks}, for the inline renderer/parser. */
allMarks: () => readonly MarkDefinition[];
hasBlock: (type: string) => boolean;
hasMark: (type: string) => boolean;
}
export interface CreateRegistryOptions {
readonly blocks?: readonly BlockDefinition[];
readonly marks?: readonly MarkDefinition[];
readonly onConflict?: ConflictPolicy;
}
function buildMap<D extends { readonly type: string }>(
items: readonly D[],
onConflict: ConflictPolicy,
kind: string,
): Map<string, D> {
const map = new Map<string, D>();
for (const item of items) {
if (map.has(item.type)) {
if (onConflict === 'throw')
throw new Error(`Editor registry: duplicate ${kind} type '${item.type}'`);
if (onConflict === 'first-wins')
continue;
}
map.set(item.type, item);
}
return map;
}
/** Build an immutable {@link Registry} from block and mark definitions. */
export function createRegistry(options: CreateRegistryOptions = {}): Registry {
const onConflict = options.onConflict ?? 'throw';
const blocks = buildMap(options.blocks ?? [], onConflict, 'block');
const marks = buildMap(options.marks ?? [], onConflict, 'mark');
const nodeSpecs = new Map<string, NodeSpec>();
for (const [type, def] of blocks)
nodeSpecs.set(type, def.spec);
const markSpecs = new Map<string, MarkSpec>();
for (const [type, def] of marks)
markSpecs.set(type, def.spec);
const schema = createSchema({ nodes: nodeSpecs, marks: markSpecs });
const blockList = [...blocks.values()];
const markList = [...marks.values()];
return {
blocks,
marks,
schema,
getBlock: type => blocks.get(type),
getMark: type => marks.get(type),
listBlocks: () => blockList,
listMarks: () => markList,
allMarks: () => markList,
hasBlock: type => blocks.has(type),
hasMark: type => marks.has(type),
};
}
/** Return a new registry extending `base` with extra blocks/marks (override wins). */
export function extendRegistry(
base: Registry,
add: { blocks?: readonly BlockDefinition[]; marks?: readonly MarkDefinition[]; onConflict?: ConflictPolicy },
): Registry {
return createRegistry({
blocks: [...base.listBlocks(), ...(add.blocks ?? [])],
marks: [...base.listMarks(), ...(add.marks ?? [])],
onConflict: add.onConflict ?? 'last-wins',
});
}