feat(writekit): rename @robonen/editor to @robonen/writekit

Rename the rich-text editor package and all Editor* exports to Writekit*;
remove the old vue/editor tree.
This commit is contained in:
2026-06-15 16:54:06 +07:00
parent 55e78786d6
commit 263c32002f
149 changed files with 1563 additions and 1748 deletions
+11
View File
@@ -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>>;
+12
View File
@@ -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' };
+39
View File
@@ -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;
}
+8
View File
@@ -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';
+19
View File
@@ -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[];
}
+24
View File
@@ -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[];
}
+44
View File
@@ -0,0 +1,44 @@
import type { Inline, Node, WritekitDocument } 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 writekit state.
*/
export function normalizeDocument(doc: WritekitDocument, schema: Schema): WritekitDocument {
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);
}
+91
View File
@@ -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);
}
+42
View File
@@ -0,0 +1,42 @@
import type { WritekitDocument } 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: WritekitDocument, 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 };
}