Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ npm install
npm run dev
```

Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_session.js), add a [Toolbar](./src/routes/components/Toolbar.svelte), and render custom [Overlays](./src/routes/components/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes).
Now make it your own. The next thing you probably want to do is define your own [node types](./src/routes/create_demo_session.js), add a [Toolbar](./src/routes/Toolbar.svelte), and render custom [Overlays](./src/routes/Overlays.svelte). For that just get inspired by the [Svedit demo code](./src/routes).

You can also install Svedit into an existing SvelteKit project with `npm install svedit`, but you'll need to set up the session, schema, config, and components yourself. See the [hello-svedit repo](https://github.com/michael/hello-svedit) or this repo's [`src/routes`](./src/routes) for reference.

Expand Down Expand Up @@ -224,6 +224,67 @@ const document_schema = {

Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes with `kind: 'annotation'`. Simple marks like `strong` and `emphasis` have no properties, while marks like `link` can carry data (e.g. `href`). Each `text` or `node_array` property specifies which types are allowed via `mark_types` and `annotation_types` — this lets you control formatting per property (e.g. allow bold and links in body text, but only emphasis in titles). `mark_types` may only reference `kind: 'mark'` node types and `annotation_types` only `kind: 'annotation'` node types.

## Adding a new node type

Everything a node type needs can be grouped in one plain object — a package — and merged into your session setup with `compose`. Every package needs a unique `name`, used for diagnostics and duplicate-name checks. In the simplest case a new type is just a schema entry and a component:

```js
// src/packages/quote/package.js
import Quote from './Quote.svelte';

export default {
name: 'quote',
schema: {
quote: {
kind: 'block',
properties: {
content: { type: 'text', allow_newlines: true },
author: { type: 'string' }
}
}
},
node_components: { quote: Quote },
// Demo-app metadata; Svedit itself does not know about layouts.
node_layouts: { quote: 1 }
};
```

Then compose your packages into the flat schema + config a `Session` expects, and allow the new type where it may appear (container wiring — `node_types`, `default_node_type`, `mark_types`, `annotation_types` — always stays with the property that owns the container, typically your document package):

```js
import { compose, Session } from 'svedit';
import page_pkg from '../packages/page/package.js';
import quote_pkg from '../packages/quote/package.js';

const { schema, config } = compose([page_pkg, quote_pkg], {
generate_id: nanoid
});

const session = new Session(schema, doc, config);
```

That's usually all. Everything else is optional and only needed when the defaults don't fit:

- **Inserter** — Svedit inserts new nodes generically from schema defaults: the node is created via `fill_node_defaults`, inserted, and for `kind: 'text'` nodes the caret is placed at the start of `content`. Add `inserters: { quote: (tr, content) => {...} }` to a package only when a new node needs more, e.g. seeded child nodes (see `list` and `story` in [`src/packages/`](src/packages/)).
- **HTML exporter** — `html_exporters: { quote: (node) => ... }` improves copy/paste to external apps; without one a generic exporter is used.
- **Commands and keymap** — a package can contribute commands (as a factory receiving the editor context) and keybindings that reference commands by name:
- **App registries** — custom object-valued keys like `node_layouts: { quote: 1 }` are merged into `config.node_layouts`.

```js
commands: (context) => ({ toggle_strong: new ToggleMarkCommand('strong', context) }),
keymap: { 'meta+b,ctrl+b': ['toggle_strong'] }
```

`compose` merges everything into one flat result: schema entries and object-valued registry keys (`node_components`, `system_components`, `inserters`, `html_exporters`, plus custom app registries like `node_layouts`) merge key-by-key and **collisions throw**, keymap entries for the same key concatenate in package order, and command names are resolved across all packages. Unknown scalar/function package keys throw; app-level scalar config belongs in the second `compose(..., app_config)` argument. There is no plugin runtime or registry — a package is just an object, and after composition the result is indistinguishable from a hand-written flat config. The flat style (as used in [`src/test/create_test_session.js`](src/test/create_test_session.js)) remains fully supported.

To catch omissions early, `Session` runs completeness checks in dev mode and logs `[svedit]` warnings with a fix hint for each finding:

- a `document`/`block`/`text` type has no registered component (it would render as `UnknownNode`),
- a `default_node_type` can neither be auto-inserted from schema defaults nor has a custom inserter (Enter would fail),
- a `mark`/`annotation` type is not referenced by any `mark_types`/`annotation_types` (it could never be applied).

The demo app is the reference for this setup style — each folder in [`src/packages/`](src/packages/) is one self-contained package, and [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) composes them.

## Document

A document is a plain JavaScript object (POJO) with a `document_id` (the entry point) and a `nodes` object containing all content nodes.
Expand Down Expand Up @@ -475,7 +536,7 @@ const session_config = {
- **`generate_id`** - Function that generates unique IDs for new nodes
- **`node_components`** - Maps each node type from your schema to a Svelte component
- **`system_components`** - Optional overrides for internal editor components and a slot for your own overlays:
- `overlays` — A Svelte component rendered inside `<Svedit>` but outside the content canvas. Use it to add floating UI like link editors, image toolbars, or annotation popovers that appear near the current selection. See [`src/routes/components/Overlays.svelte`](src/routes/components/Overlays.svelte) for an example.
- `overlays` — A Svelte component rendered inside `<Svedit>` but outside the content canvas. Use it to add floating UI like link editors, image toolbars, or annotation popovers that appear near the current selection. See [`src/routes/Overlays.svelte`](src/routes/Overlays.svelte) for an example.
- `node_gap`, `node_gap_markers`, `node_selection_markers` — Override the default system components if you need custom visuals for node gaps or selection indicators.
- **`inserters`** - Functions that create blank nodes of each type and set up the selection
- **`create_commands_and_keymap`** - Factory function that creates commands and keybindings for an editor instance
Expand Down
10 changes: 10 additions & 0 deletions src/lib/Session.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
validate_document_schema,
validate_document,
validate_config_components,
check_config_completeness,
validate_node,
is_id_valid,
get_referencing_node_ids,
Expand Down Expand Up @@ -94,6 +95,15 @@ export default class Session {
validate_document(this.doc, this.schema);
validate_config_components(this.schema, this.config);

// Soft completeness checks: point out likely setup omissions (missing
// components, non-insertable default node types, unused range types)
// during development without breaking the app.
if (import.meta.env?.DEV) {
for (const warning of check_config_completeness(this.schema, this.config)) {
console.warn(`[svedit] ${warning}`);
}
}

// Set selection after doc is initialized so validation can work properly
this.selection = options.selection ?? null;
}
Expand Down
179 changes: 179 additions & 0 deletions src/lib/compose.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Opt-in composition of packages into a flat schema + config.
*
* A package is a plain object that groups everything one concern needs
* (schema entries including sub-node types, components, inserters,
* exporters, commands, keymap contributions). `compose` merges packages
* into exactly the flat `{ schema, config }` shape a Session expects — after
* composition there is no runtime indirection, and the flat style remains
* fully supported.
*
* Rules:
* - Every package must have a unique `name`; names are used in diagnostics.
* - Packages define node types; only the app wires containers
* (`node_types`, `default_node_type`, `mark_types`, `annotation_types`).
* - Known Svedit registry keys and custom app registry keys merge
* key-by-key; duplicate keys across packages throw.
* - Unknown scalar/function package keys throw, because app-level scalar
* config belongs in the second `compose(..., app_config)` argument.
* - `commands` factories are combined into a single
* `create_commands_and_keymap`; `keymap` entries reference commands by
* name and concatenate when multiple packages bind the same key.
*/

/**
* @import { Package } from './types'
*/

import { define_keymap } from './KeyMapper.svelte.js';

/** Package keys that are not plain config registries */
const SPECIAL_KEYS = ['name', 'schema', 'commands', 'keymap'];

/**
* @param {Package} pkg
* @param {number} index
* @returns {string} Package name for error messages
*/
function package_name(pkg, index) {
if (typeof pkg.name !== 'string' || pkg.name.length === 0) {
throw new Error(`compose: package #${index + 1} must have a non-empty name.`);
}
return pkg.name;
}

/**
* Merge `source` into `target` key-by-key, throwing on duplicate keys.
*
* @param {Record<string, any>} target
* @param {Record<string, any>} source
* @param {Record<string, string>} owners - Maps merged keys to their package name
* @param {string} owner - Name of the package being merged
* @param {string} registry - Registry name for error messages (e.g. 'schema')
*/
function merge_registry(target, source, owners, owner, registry) {
for (const [key, value] of Object.entries(source)) {
if (key in target) {
throw new Error(
`compose: ${registry} key '${key}' from ${owner} conflicts with ${owners[key]}.`
);
}
target[key] = value;
owners[key] = owner;
}
}

function is_plain_object(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}

/**
* Compose packages and app config into a flat schema and config.
*
* @param {Package[]} packages - Packages, merged in order
* @param {Record<string, any>} [app_config] - App-level config (generate_id,
* system_components, handlers, ...). Object-valued keys participate in the
* collision-checked merge; other values are set directly.
* @returns {{ schema: Record<string, any>, config: Record<string, any> }}
*/
export function compose(packages, app_config = {}) {
/** @type {Record<string, any>} */
const schema = {};
/** @type {Record<string, any>} */
const config = {};

// Track which package contributed each key, per registry
/** @type {Record<string, Record<string, string>>} */
const owners = { schema: {} };

/** @type {Array<{ owner: string, factory: (context: any) => Record<string, any> }>} */
const command_factories = [];
/** @type {Record<string, string[]>} */
const keymap_names = {};
const package_names = new Set();

for (const [index, pkg] of packages.entries()) {
const owner = package_name(pkg, index);
if (package_names.has(owner)) {
throw new Error(`compose: duplicate package name '${owner}'.`);
}
package_names.add(owner);

for (const key of Object.keys(pkg)) {
if (SPECIAL_KEYS.includes(key)) continue;
const value = pkg[key];
if (!is_plain_object(value)) {
throw new Error(
`compose: unknown package key '${key}' in ${owner}. Custom package keys must be plain object registries.`
);
}
}

if (pkg.schema) {
merge_registry(schema, pkg.schema, owners.schema, owner, 'schema');
}

if (pkg.commands) {
command_factories.push({ owner, factory: pkg.commands });
}

if (pkg.keymap) {
for (const [key_combo, command_names] of Object.entries(pkg.keymap)) {
// Same key from multiple packages concatenates in order,
// matching the existing fallback semantics of keymap arrays.
keymap_names[key_combo] = [...(keymap_names[key_combo] ?? []), ...command_names];
}
}

for (const [key, value] of Object.entries(pkg)) {
if (SPECIAL_KEYS.includes(key)) continue;
config[key] ??= {};
owners[key] ??= {};
merge_registry(config[key], value, owners[key], owner, `config.${key}`);
}
}

for (const [key, value] of Object.entries(app_config)) {
if (is_plain_object(value)) {
config[key] ??= {};
owners[key] ??= {};
merge_registry(config[key], value, owners[key], 'app config', `config.${key}`);
} else {
config[key] = value;
}
}

if (command_factories.length > 0 || Object.keys(keymap_names).length > 0) {
if (config.create_commands_and_keymap) {
throw new Error(
'compose: pass commands/keymap through packages OR provide create_commands_and_keymap, not both.'
);
}
config.create_commands_and_keymap = (context) => {
/** @type {Record<string, any>} */
const commands = {};
/** @type {Record<string, string>} */
const command_owners = {};
for (const { owner, factory } of command_factories) {
merge_registry(commands, factory(context), command_owners, owner, 'commands');
}

/** @type {Record<string, any[]>} */
const keymap = {};
for (const [key_combo, command_names] of Object.entries(keymap_names)) {
keymap[key_combo] = command_names.map((command_name) => {
if (!commands[command_name]) {
throw new Error(
`compose: keymap '${key_combo}' references unknown command '${command_name}'.`
);
}
return commands[command_name];
});
}

return { commands, keymap: define_keymap(keymap) };
};
}

return { schema, config };
}
76 changes: 76 additions & 0 deletions src/lib/doc_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ export function validate_document_schema(document_schema) {
`Node type "${node_type}" property "${prop_name}" references unknown node types: ${missing_types.join(', ')}. Available node types: ${Object.keys(document_schema).join(', ')}`
);
}
// node_types must reference content node kinds, not range kinds
const range_kind_types = prop_def.node_types.filter((ref_type) =>
['mark', 'annotation'].includes(document_schema[ref_type]?.kind)
);
if (range_kind_types.length > 0) {
throw new Error(
`Node type "${node_type}" property "${prop_name}" node_types must not reference mark or annotation types, got: ${range_kind_types.join(', ')}. Use mark_types/annotation_types instead.`
);
}
}
if (prop_def.type === 'text' || prop_def.type === 'node_array') {
// mark_types must reference kind 'mark', annotation_types kind 'annotation'
Expand Down Expand Up @@ -202,6 +211,73 @@ export function validate_config_components(schema, config) {
}
}

/**
* Check schema and config for common omissions when setting up node types.
*
* Unlike the validate_* functions these are soft checks: each finding is a
* human-readable warning describing what was likely forgotten and how to fix
* it. Session logs them in dev mode; nothing throws.
*
* @param {DocumentSchema} schema - The document schema
* @param {any} config - The Svedit config
* @returns {string[]} Warning messages, empty when the setup looks complete
*/
export function check_config_completeness(schema, config) {
const warnings = [];

// Referenced mark/annotation types, to detect unused range types
const referenced_range_types = new Set();
for (const node_schema of Object.values(schema)) {
for (const prop_def of Object.values(node_schema.properties)) {
for (const key of ['mark_types', 'annotation_types']) {
for (const type of prop_def[key] ?? []) {
referenced_range_types.add(type);
}
}
}
}

for (const [node_type, node_schema] of Object.entries(schema)) {
if (
['document', 'block', 'text'].includes(node_schema.kind) &&
!config?.node_components?.[node_type]
) {
warnings.push(
`Node type '${node_type}' has no registered component and will render as UnknownNode. Add it to config.node_components.`
);
}

if (
['mark', 'annotation'].includes(node_schema.kind) &&
!referenced_range_types.has(node_type)
) {
warnings.push(
`Node type '${node_type}' (kind '${node_schema.kind}') is not referenced by any ${node_schema.kind}_types and can never be applied.`
);
}

for (const [prop_name, prop_def] of Object.entries(node_schema.properties)) {
if (prop_def.type !== 'node_array') continue;
const default_type = get_default_node_type(prop_def);
if (!default_type || config?.inserters?.[default_type]) continue;

// Dry-run the generic inserter: a default node type must be
// constructible from schema defaults alone.
try {
validate_node(fill_node_defaults({ id: '_check', type: default_type }, schema), schema, {}, {
require_references: false
});
} catch (error) {
warnings.push(
`Node type '${default_type}' is the default node type of '${node_type}.${prop_name}' but cannot be auto-inserted (${/** @type {Error} */ (error).message}). Define config.inserters['${default_type}'].`
);
}
}
}

return warnings;
}

/**
* Validate a primitive value against its schema type.
* @param {PrimitiveType} type - The expected type
Expand Down
5 changes: 5 additions & 0 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ export {
validate_document_schema,
validate_document,
validate_node,
validate_config_components,
check_config_completeness,
get_referencing_node_ids
} from './doc_utils.js';

// Config composition
export { compose } from './compose.js';

// Command system
export { default as Command } from './Command.svelte.js';
export * from './Command.svelte.js';
Expand Down
Loading