From 0563fd6e439157ceb3f2c189d20c99710fd489e5 Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 18:55:21 +0200 Subject: [PATCH 01/10] Split config into features (= groups of nodes) --- README.md | 58 ++ src/lib/Session.svelte.js | 10 + src/lib/compose.js | 164 ++++ src/lib/doc_utils.js | 76 ++ src/lib/index.js | 5 + src/lib/transforms.svelte.js | 61 +- src/lib/types.d.ts | 28 + src/routes/commands.svelte.js | 3 +- src/routes/create_demo_session.js | 1162 ++---------------------- src/routes/features/core.js | 41 + src/routes/features/hero.js | 45 + src/routes/features/image_grid.js | 95 ++ src/routes/features/list.js | 74 ++ src/routes/features/marker.js | 22 + src/routes/features/marks.js | 61 ++ src/routes/features/page.js | 43 + src/routes/features/story.js | 88 ++ src/routes/features/text_blocks.js | 83 ++ src/test/compose.svelte.test.js | 158 ++++ src/test/dev_ergonomics.svelte.test.js | 157 ++++ 20 files changed, 1331 insertions(+), 1103 deletions(-) create mode 100644 src/lib/compose.js create mode 100644 src/routes/features/core.js create mode 100644 src/routes/features/hero.js create mode 100644 src/routes/features/image_grid.js create mode 100644 src/routes/features/list.js create mode 100644 src/routes/features/marker.js create mode 100644 src/routes/features/marks.js create mode 100644 src/routes/features/page.js create mode 100644 src/routes/features/story.js create mode 100644 src/routes/features/text_blocks.js create mode 100644 src/test/compose.svelte.test.js create mode 100644 src/test/dev_ergonomics.svelte.test.js diff --git a/README.md b/README.md index 67988fb9..0777c6ad 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,64 @@ 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 feature definition — and merged into your session setup with `compose`. In the simplest case a new type is just a schema entry plus a component: + +```js +// features/quote.js +import Quote from '../components/Quote.svelte'; + +export default { + name: 'quote', + schema: { + quote: { + kind: 'block', + properties: { + content: { type: 'text', allow_newlines: true }, + author: { type: 'string' } + } + } + }, + node_components: { quote: Quote } +}; +``` + +Then compose your features 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 feature): + +```js +import { compose, Session } from 'svedit'; +import page from './features/page.js'; +import quote from './features/quote.js'; + +const { schema, config } = compose([page, quote], { + 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 definition only when a new node needs more, e.g. seeded child nodes (see `list` and `story` in [`src/routes/features/`](src/routes/features/)). +- **HTML exporter** — `html_exporters: { quote: (node) => ... }` improves copy/paste to external apps; without one a generic exporter is used. +- **Commands and keymap** — a definition can contribute commands (as a factory receiving the editor context) and keybindings that reference commands by name: + +```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 registry keys (components, inserters, exporters, and any app-specific registry like `node_layouts`) merge key-by-key and **collisions throw**, keymap entries for the same key concatenate in definition order, and command names are resolved across all definitions. There is no plugin runtime — after composition the result is indistinguishable from a hand-written flat config, and 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 file in [`src/routes/features/`](src/routes/features/) is one self-contained feature, 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. diff --git a/src/lib/Session.svelte.js b/src/lib/Session.svelte.js index 55ad64f5..cafee207 100644 --- a/src/lib/Session.svelte.js +++ b/src/lib/Session.svelte.js @@ -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, @@ -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; } diff --git a/src/lib/compose.js b/src/lib/compose.js new file mode 100644 index 00000000..59a9701a --- /dev/null +++ b/src/lib/compose.js @@ -0,0 +1,164 @@ +/** + * Opt-in composition of per-feature definitions into a flat schema + config. + * + * A feature definition is a plain object that groups everything one feature + * needs (schema entries including sub-node types, components, inserters, + * exporters, commands, keymap contributions). `compose` merges definitions + * 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: + * - Definitions define node types; only the app wires containers + * (`node_types`, `default_node_type`, `mark_types`, `annotation_types`). + * - Object-valued config keys (node_components, inserters, html_exporters, + * and any app-specific registry like node_layouts) merge key-by-key; + * duplicate keys across definitions throw. + * - `commands` factories are combined into a single + * `create_commands_and_keymap`; `keymap` entries reference commands by + * name and concatenate when multiple definitions bind the same key. + */ + +/** + * @import { FeatureDefinition } from './types' + */ + +import { define_keymap } from './KeyMapper.svelte.js'; + +/** Definition keys that are not plain config registries */ +const SPECIAL_KEYS = ['name', 'schema', 'commands', 'keymap']; + +/** + * @param {FeatureDefinition} definition + * @param {number} index + * @returns {string} Definition name for error messages + */ +function definition_name(definition, index) { + return definition.name ?? `definition #${index + 1}`; +} + +/** + * Merge `source` into `target` key-by-key, throwing on duplicate keys. + * + * @param {Record} target + * @param {Record} source + * @param {Record} owners - Maps merged keys to their definition name + * @param {string} owner - Name of the definition 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; + } +} + +/** + * Compose feature definitions and app config into a flat schema and config. + * + * @param {FeatureDefinition[]} definitions - Feature definitions, merged in order + * @param {Record} [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, config: Record }} + */ +export function compose(definitions, app_config = {}) { + /** @type {Record} */ + const schema = {}; + /** @type {Record} */ + const config = {}; + + // Track which definition contributed each key, per registry + /** @type {Record>} */ + const owners = { schema: {} }; + + /** @type {Array<{ owner: string, factory: (context: any) => Record }>} */ + const command_factories = []; + /** @type {Record} */ + const keymap_names = {}; + + const all_entries = [ + ...definitions.map((definition, index) => ({ + definition, + owner: definition_name(definition, index) + })), + { definition: app_config, owner: 'app config' } + ]; + + for (const { definition, owner } of all_entries) { + if (definition.schema) { + merge_registry(schema, definition.schema, owners.schema, owner, 'schema'); + } + + if (definition.commands) { + command_factories.push({ owner, factory: definition.commands }); + } + + if (definition.keymap) { + for (const [key_combo, command_names] of Object.entries(definition.keymap)) { + // Same key from multiple definitions 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(definition)) { + if (SPECIAL_KEYS.includes(key)) continue; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + // Registry-style config (node_components, inserters, + // html_exporters, node_layouts, ...): merge key-by-key. + config[key] ??= {}; + owners[key] ??= {}; + merge_registry(config[key], value, owners[key], owner, `config.${key}`); + } else { + // Scalar/function/array config (generate_id, view_classes, + // handle_media_paste, ...): app config wins, definitions may + // not compete for the same key. + if (key in config && owner !== 'app config') { + throw new Error(`compose: config key '${key}' from ${owner} is already set.`); + } + 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 definitions OR provide create_commands_and_keymap, not both.' + ); + } + config.create_commands_and_keymap = (context) => { + /** @type {Record} */ + const commands = {}; + /** @type {Record} */ + const command_owners = {}; + for (const { owner, factory } of command_factories) { + merge_registry(commands, factory(context), command_owners, owner, 'commands'); + } + + /** @type {Record} */ + 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 }; +} diff --git a/src/lib/doc_utils.js b/src/lib/doc_utils.js index 0a987165..75f70f40 100644 --- a/src/lib/doc_utils.js +++ b/src/lib/doc_utils.js @@ -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' @@ -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 diff --git a/src/lib/index.js b/src/lib/index.js index 639bcdc0..c871083c 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -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'; diff --git a/src/lib/transforms.svelte.js b/src/lib/transforms.svelte.js index 747b5afb..e53c3cbd 100644 --- a/src/lib/transforms.svelte.js +++ b/src/lib/transforms.svelte.js @@ -1,5 +1,5 @@ import { split_text, join_text, get_char_length } from './utils.js'; -import { get_default_node_type } from './doc_utils.js'; +import { get_default_node_type, fill_node_defaults } from './doc_utils.js'; /** * Set multiple properties on a node via a transaction. @@ -14,6 +14,51 @@ export function set_properties(tr, path, properties) { } } +/** + * Insert a new node of the given type at the current node selection. + * + * A custom inserter registered in `config.inserters[node_type]` always wins. + * Otherwise a generic schema-driven insertion is performed: the node is + * created from schema defaults, inserted, and — for `kind: 'text'` nodes — a + * collapsed text selection is placed at the start of its content. + * + * @param {object} tr - The transaction + * @param {string} node_type - The node type to insert + * @param {any} [content] - Optional text value assigned to `content` (kind 'text' only) + */ +export function insert_node_of_type(tr, node_type, content) { + const custom_inserter = tr.config?.inserters?.[node_type]; + if (custom_inserter) { + custom_inserter(tr, content); + return; + } + + const node = fill_node_defaults({ id: tr.generate_id(), type: node_type }, tr.schema); + const node_kind = tr.schema[node_type]?.kind; + if (content !== undefined && node_kind === 'text') { + node.content = content; + } + + try { + tr.create(node); + } catch (error) { + throw new Error( + `Cannot auto-insert node type '${node_type}' from schema defaults (${/** @type {Error} */ (error).message}). Define config.inserters['${node_type}'].`, + { cause: error } + ); + } + tr.insert_nodes([node.id]); + + if (node_kind === 'text') { + tr.set_selection({ + type: 'text', + path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], + anchor_offset: 0, + focus_offset: 0 + }); + } +} + export function break_text_node(tr) { // Keep a reference of the original selection (before any transforms are applied) const selection = tr.selection; @@ -64,7 +109,7 @@ export function break_text_node(tr) { tr.set_selection(node_insert_position); - tr.config.inserters[target_node_type](tr, right_text); + insert_node_of_type(tr, target_node_type, right_text); return true; } @@ -154,11 +199,11 @@ export function insert_default_node(tr) { const property_definition = tr.schema[node_array_node.type].properties[property_name]; const default_type = get_default_node_type(property_definition); - // Use the inserter function if available - if (tr.config?.inserters?.[default_type]) { - tr.config.inserters[default_type](tr); - return true; - } else { - throw new Error(`No inserter function available for default node type '${default_type}'`); + if (!default_type) { + console.warn('Cannot determine default node type for insert_default_node'); + return false; } + + insert_node_of_type(tr, default_type); + return true; } diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 1f187722..c29bc24f 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -339,6 +339,34 @@ export type NodeSchema = TextNodeSchema | NonTextNodeSchema; */ export type DocumentSchema = Record; +/** + * A feature definition groups everything one feature needs (schema entries + * including sub-node types, components, inserters, exporters, commands, + * keymap contributions). Definitions are merged into a flat schema + config + * via `compose`. Any additional object-valued key (e.g. `node_layouts`) is + * merged as an app-specific registry. + */ +export type FeatureDefinition = { + /** Optional name used in compose error messages */ + name?: string; + /** Schema entries contributed by this feature (loosely typed so plain + * object literals don't require const assertions; the merged result is + * validated at Session construction) */ + schema?: Record; + /** Components per node type, merged into config.node_components */ + node_components?: Record; + /** Custom inserters per node type, merged into config.inserters */ + inserters?: Record void>; + /** HTML exporters per node type, merged into config.html_exporters */ + html_exporters?: Record string>; + /** Command factory; returned commands are merged by name */ + commands?: (context: any) => Record; + /** Key combos mapped to arrays of command names */ + keymap?: Record; + /** Additional object-valued keys merge as registries; other values are set directly */ + [key: string]: any; +}; + /** * A node in the document. * Must have id and type properties, with other properties defined by the schema. diff --git a/src/routes/commands.svelte.js b/src/routes/commands.svelte.js index 158a6ad2..88635e3a 100644 --- a/src/routes/commands.svelte.js +++ b/src/routes/commands.svelte.js @@ -1,5 +1,6 @@ import Command from '$lib/Command.svelte.js'; import { is_selection_collapsed } from '$lib/utils.js'; +import { insert_node_of_type } from '$lib/transforms.svelte.js'; import { get_closest_switchable_layout, get_cycle_node_state, @@ -116,7 +117,7 @@ export class CycleNodeTypeCommand extends Command { }); if (is_node_subtree_empty(session, node)) { - session.config.inserters[new_type](tr); + insert_node_of_type(tr, new_type); } else { replace_node_with_equivalent_type(tr, node_array_path, node_index, node, new_type); } diff --git a/src/routes/create_demo_session.js b/src/routes/create_demo_session.js index 96fc0741..3f629c83 100644 --- a/src/routes/create_demo_session.js +++ b/src/routes/create_demo_session.js @@ -1,1108 +1,82 @@ -import { - Session, - SelectAllCommand, - InsertDefaultNodeCommand, - AddNewLineCommand, - BreakTextNodeCommand, - ToggleMarkCommand, - ToggleAnnotationCommand, - UndoCommand, - RedoCommand, - SelectParentCommand, - define_document_schema, - fill_document_defaults, - define_keymap -} from 'svedit'; -import { CycleLayoutCommand, CycleNodeTypeCommand, ToggleLinkCommand } from './commands.svelte.js'; +import { Session, compose, fill_document_defaults } from 'svedit'; import nanoid from './nanoid.js'; +import doc from './demo_doc.js'; // System components import Overlays from './components/Overlays.svelte'; -// Node components -import Page from './components/Page.svelte'; -import Story from './components/Story.svelte'; -import Button from './components/Button.svelte'; -import Paragraph from './components/Paragraph.svelte'; -import Heading1 from './components/Heading1.svelte'; -import Heading2 from './components/Heading2.svelte'; -import Heading3 from './components/Heading3.svelte'; -import List from './components/List.svelte'; -import ListItem from './components/ListItem.svelte'; -import ImageGrid from './components/ImageGrid.svelte'; -import ImageGridItem from './components/ImageGridItem.svelte'; -import Hero from './components/Hero.svelte'; -import Strong from './components/Strong.svelte'; -import Emphasis from './components/Emphasis.svelte'; -import Highlight from './components/Highlight.svelte'; -import Link from './components/Link.svelte'; -import Section from './components/Section.svelte'; +// Feature definitions: each groups everything one feature needs (schema +// entries incl. sub-node types, components, inserters, exporters, commands, +// keymaps). `compose` merges them into the flat schema + config a Session +// expects. Container wiring (which node types the page body accepts) lives in +// the page feature — features define node types, the app composes them. +import page from './features/page.js'; +import text_blocks from './features/text_blocks.js'; +import marks from './features/marks.js'; +import marker from './features/marker.js'; +import story from './features/story.js'; +import list from './features/list.js'; +import image_grid from './features/image_grid.js'; +import hero from './features/hero.js'; +import core from './features/core.js'; -const ALL_MARKS = ['strong', 'emphasis', 'highlight', 'link']; -const TITLE_MARKS = ['emphasis', 'highlight']; +const { schema, config } = compose( + [page, text_blocks, marks, marker, story, list, image_grid, hero, core], + { + // Custom ID generator function + generate_id: nanoid, + // Provide overrides for system components (node_gap, node_gap_markers, + // node_selection_markers) or user-land overlays (link previews, etc.) + system_components: { + overlays: Overlays + }, + // Toggle view classes for the editor. On by default. + view_classes: true, + handle_property_deletion: (tr, path) => { + const property_definition = tr.inspect(path); + if (property_definition?.type !== 'string' || property_definition?.name !== 'image') return; + tr.set(path, ''); + }, + handle_media_paste: async (doc, pasted_media) => { + // ATTENTION: In a real-world-app, you may want to upload `pasted_media` here, + // before referencing them from the document. -export const document_schema = define_document_schema({ - page: { - kind: 'document', - properties: { - body: { - type: 'node_array', - node_types: [ - 'paragraph', - 'heading_1', - 'heading_2', - 'heading_3', - 'story', - 'list', - 'image_grid', - 'hero' - ], - mark_types: ['section'], - annotation_types: ['marker'], - default_node_type: 'paragraph' - }, - keywords: { - type: 'string_array' - }, - daily_visitors: { - type: 'integer_array' - }, - created_at: { - type: 'datetime' - } - } - }, - hero: { - kind: 'block', - properties: { - layout: { type: 'integer', default: 1 }, - title: { - type: 'text', - mark_types: TITLE_MARKS, - allow_newlines: false - }, - description: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - }, - image: { type: 'string' } // a dedicated type asset would be better - } - }, - paragraph: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - heading_1: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - heading_2: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - heading_3: { - kind: 'text', - properties: { - layout: { type: 'integer', default: 1 }, - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - } - } - }, - button: { - kind: 'text', - properties: { - content: { - type: 'text', - mark_types: [], - allow_newlines: false - }, - href: { type: 'string' } - } - }, - story: { - kind: 'block', - properties: { - layout: { type: 'integer', default: 1 }, - title: { - type: 'text', - mark_types: TITLE_MARKS, - allow_newlines: false - }, - description: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: true - }, - buttons: { - type: 'node_array', - node_types: ['button'], - default_node_type: 'button' - }, - image: { type: 'string' } - } - }, - image_grid: { - kind: 'block', - properties: { - layout: { type: 'integer', default: 1 }, - image_grid_items: { - type: 'node_array', - node_types: ['image_grid_item'], - annotation_types: ['marker'] - } - } - }, - image_grid_item: { - kind: 'block', - properties: { - image: { type: 'string' }, // a dedicated type asset would be better - title: { - type: 'text', - mark_types: TITLE_MARKS, - allow_newlines: false - }, - description: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: false - } - } - }, - list_item: { - kind: 'text', - properties: { - content: { - type: 'text', - mark_types: ALL_MARKS, - allow_newlines: false - } - } - }, - list: { - kind: 'block', - properties: { - list_items: { - type: 'node_array', - node_types: ['list_item'], - annotation_types: ['marker'], - }, - layout: { type: 'integer', default: 1 } - } - }, - link: { - kind: 'mark', - properties: { - href: { type: 'string' } - } - }, - strong: { - kind: 'mark', - properties: {} - }, - emphasis: { - kind: 'mark', - properties: {} - }, - highlight: { - kind: 'mark', - properties: {} - }, - section: { - kind: 'mark', - properties: {} - }, - // An annotation: data-only, no component allowed, so it may overlap marks - // and other annotations. Covered node wrappers get `anno-marker` classes - // automatically, styled in styles/annotations.css. - marker: { - kind: 'annotation', - properties: {} - } -}); - -const doc = { - document_id: 'page_1', - nodes: { - zXfRSCtGrGWaBCpaWdBvdZG: { - id: 'zXfRSCtGrGWaBCpaWdBvdZG', - type: 'link', - href: 'https://editable.website' - }, - hero_1: { - id: 'hero_1', - type: 'hero', - layout: 1, - title: { - content: 'Svedit', - marks: [], - annotations: [] - }, - description: { - content: 'A tiny library for building editable websites in Svelte.', - marks: [ - { - start_offset: 28, - end_offset: 36, - node_id: 'zXfRSCtGrGWaBCpaWdBvdZG' - } - ], - annotations: [] - }, - image: '' - }, - heading_1: { - id: 'heading_1', - type: 'heading_1', - layout: 1, - content: { - content: 'Text and structured content in symbiosis', - marks: [], - annotations: [] - } - }, - paragraph_1: { - id: 'paragraph_1', - type: 'paragraph', - layout: 1, - content: { - content: - "Unlike most rich text editors, Svedit isn't restricted to a linear character-based model for addressing content and caret positions. For that reason we can combine text-ish content like a paragraph or heading with structured, form-like content.", - marks: [], - annotations: [] - } - }, - JRZfuWeXnWKnWHaNtjupSsX: { - id: 'JRZfuWeXnWKnWHaNtjupSsX', - type: 'button', - content: { - content: 'Get started', - marks: [], - annotations: [] - }, - href: 'https://github.com/michael/svedit' - }, - story_1: { - id: 'story_1', - type: 'story', - layout: 1, - image: '/images/editable.svg', - title: { - content: 'Visual in‑place editing', - marks: [], - annotations: [] - }, - description: { - content: - 'Model your content in JSON, render it with Svelte components, and edit content directly in the layout. You only have to follow a couple of rules to make this work.', - marks: [], - annotations: [] - }, - buttons: { - nodes: ['JRZfuWeXnWKnWHaNtjupSsX'], - marks: [], - annotations: [] - } - }, - link_1: { - id: 'link_1', - type: 'link', - href: 'https://editable.website' - }, - story_2: { - id: 'story_2', - type: 'story', - layout: 2, - image: '/images/lightweight.svg', - title: { - content: 'Minimal viable editor', - marks: [], - annotations: [] - }, - description: { - content: - "The reference implementation uses only about 2000 lines of code. That means you'll be able to serve editable web pages, removing the need for a separate Content Management System.", - marks: [ - { - start_offset: 100, - end_offset: 118, - node_id: 'link_1' - } - ], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - image_grid_item_1: { - id: 'image_grid_item_1', - type: 'image_grid_item', - image: '/images/svelte-framework.svg', - title: { - content: 'Svelte-native editing', - marks: [], - annotations: [] - }, - description: { - content: "No mingling with 3rd-party rendering API's.", - marks: [], - annotations: [] - } - }, - image_grid_item_2: { - id: 'image_grid_item_2', - type: 'image_grid_item', - image: '/images/annotations.svg', - title: { - content: 'Marks and annotations', - marks: [], - annotations: [] - }, - description: { - content: - 'Wrap ranges in marks (e.g. links) and attach annotations (e.g. comments) at a separate layer.', - marks: [], - annotations: [] - } - }, - image_grid_item_3: { - id: 'image_grid_item_3', - type: 'image_grid_item', - image: '/images/graphmodel.svg', - title: { - content: 'Graph‑first content with nested nodes', - marks: [], - annotations: [] - }, - description: { - content: - 'From simple paragraphs to complex nodes with nested arrays and multiple properties.', - marks: [], - annotations: [] - } - }, - image_grid_item_4: { - id: 'image_grid_item_4', - type: 'image_grid_item', - image: '/images/dom-synced.svg', - title: { - content: 'DOM ↔ model selections match', - marks: [], - annotations: [] - }, - description: { - content: 'Avoids flaky mapping layers found in other editors.', - marks: [], - annotations: [] - } - }, - image_grid_item_5: { - id: 'image_grid_item_5', - type: 'image_grid_item', - image: '/images/cjk.svg', - title: { - content: 'Unicode‑safe, composition‑safe input', - marks: [], - annotations: [] - }, - description: { - content: 'Works correctly with emoji, diacritics, and CJK.', - marks: [], - annotations: [] - } - }, - image_grid_item_6: { - id: 'image_grid_item_6', - type: 'image_grid_item', - image: '/images/timetravel.svg', - title: { - content: 'Transactional editing with time travel', - marks: [], - annotations: [] - }, - description: { - content: 'Every change is safe and undoable.', - marks: [], - annotations: [] - } - }, - image_grid_1: { - id: 'image_grid_1', - type: 'image_grid', - layout: 1, - image_grid_items: { - nodes: [ - 'image_grid_item_1', - 'image_grid_item_2', - 'image_grid_item_3', - 'image_grid_item_4', - 'image_grid_item_5', - 'image_grid_item_6' - ], - marks: [], - annotations: [] - } - }, - story_3: { - id: 'story_3', - type: 'story', - layout: 1, - image: '/images/nested-blocks-illustration.svg', - title: { - content: 'Nested nodes', - marks: [], - annotations: [] - }, - description: { - content: - 'A node can embed a node_array of other nodes. For instance the list node at the bottom of the page has a node_array of list items.', - marks: [], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - story_4: { - id: 'story_4', - type: 'story', - layout: 2, - image: '/images/node-carets.svg', - title: { - content: 'Node carets', - marks: [], - annotations: [] - }, - description: { - content: - 'They work just like text carets, but instead of a character position in a string they address a node position in a node_array.\n\nTry it by selecting one of the gaps between the nodes. Then press ↵ to insert a new node or ⌫ to delete the node before the caret.', - marks: [], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - link_2: { - id: 'link_2', - type: 'link', - href: 'https://svelte.dev' - }, - emphasis_1: { - id: 'emphasis_1', - type: 'emphasis' - }, - story_5: { - id: 'story_5', - type: 'story', - layout: 1, - image: '/images/svelte-logo.svg', - title: { - content: 'Made for Svelte 5', - marks: [], - annotations: [] - }, - description: { - content: - 'Integrate with your Svelte application. Use it as a template and copy and paste Svedit.svelte to build your custom rich content editor.', - marks: [ - { - start_offset: 20, - end_offset: 26, - node_id: 'link_2' - }, - { - start_offset: 80, - end_offset: 93, - node_id: 'emphasis_1' - } - ], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - story_6: { - id: 'story_6', - type: 'story', - layout: 2, - image: '/images/extendable.svg', - title: { - content: 'Alpha version', - marks: [], - annotations: [] - }, - description: { - content: - "Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case.\n\nFind below a list of known issues we'll be working to get fixed next:", - marks: [], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - list_item_1: { - id: 'list_item_1', - type: 'list_item', - content: { - content: - "It's a bit hard to select whole lists or image grids with the mouse still. We're looking to improve this. However, by pressing the ESC key (or CMD+A) several times you can reach parent nodes easily.", - marks: [], - annotations: [] - } - }, - list_item_2: { - id: 'list_item_2', - type: 'list_item', - content: { - content: - 'Copy and pasting from and to external sources is working in principle, but is only capturing plain text so far.', - marks: [], - annotations: [] - } - }, - list_item_3: { - id: 'list_item_3', - type: 'list_item', - content: { - content: - 'Works best in Chrome, Safari 26+, and Firefox 157+, as Svedit uses CSS Anchor Positioning for overlays.', - marks: [], - annotations: [] - } - }, - list_item_4: { - id: 'list_item_4', - type: 'list_item', - content: { - content: - "Mobile support ist still experimental. As of 0.3.0 Svedit works on latest iOS and Android, but the UX isn't optimized yet.", - marks: [], - annotations: [] - } - }, - list_1: { - id: 'list_1', - type: 'list', - list_items: { - nodes: ['list_item_1', 'list_item_2', 'list_item_3', 'list_item_4'], - marks: [], - annotations: [] - }, - layout: 3 - }, - link_4: { - id: 'link_4', - type: 'link', - href: 'https://michaelaufreiter.com' - }, - link_5: { - id: 'link_5', - type: 'link', - href: 'https://mutter.co' - }, - VgWNyDmWcpgtkHvZXhjYTPS: { - id: 'VgWNyDmWcpgtkHvZXhjYTPS', - type: 'link', - href: 'https://github.com/michael/svedit/' - }, - story_7: { - id: 'story_7', - type: 'story', - layout: 1, - image: '/images/github.svg', - title: { - content: 'Find us on GitHub', - marks: [], - annotations: [] - }, - description: { - content: - 'Thank you for all the stars on GitHub. Svedit is made by Michael Aufreiter and Johannes Mutter and is licensed under the MIT License.', - marks: [ - { - start_offset: 57, - end_offset: 74, - node_id: 'link_4' - }, - { - start_offset: 79, - end_offset: 94, - node_id: 'link_5' - }, - { - start_offset: 31, - end_offset: 37, - node_id: 'VgWNyDmWcpgtkHvZXhjYTPS' - } - ], - annotations: [] - }, - buttons: { - nodes: [], - marks: [], - annotations: [] - } - }, - section_1: { - id: 'section_1', - type: 'section' - }, - page_1: { - id: 'page_1', - type: 'page', - body: { - nodes: [ - 'hero_1', - 'heading_1', - 'paragraph_1', - 'story_1', - 'story_2', - 'image_grid_1', - 'story_3', - 'story_4', - 'story_5', - 'story_6', - 'list_1', - 'story_7' - ], - marks: [ - { - start_offset: 0, - end_offset: 1, - node_id: 'section_1' - } - ], - annotations: [] - }, - keywords: ['svelte', 'editor', 'rich content'], - daily_visitors: [10, 20, 30, 100], - created_at: '2025-05-30T10:39:59.987Z' - } - } -}; - -// App-specific config object, always available via doc.config for introspection -export const session_config = { - // Custom ID generator function - generate_id: nanoid, - // Provide overrides for system components (node_gap, node_gap_markers, - // node_selection_markers) or user-land overlays (link previews, etc.) - system_components: { - overlays: Overlays - }, - // Registry of components for each node type - node_components: { - page: Page, - button: Button, - paragraph: Paragraph, - heading_1: Heading1, - heading_2: Heading2, - heading_3: Heading3, - story: Story, - list: List, - list_item: ListItem, - image_grid: ImageGrid, - image_grid_item: ImageGridItem, - hero: Hero, - strong: Strong, - emphasis: Emphasis, - highlight: Highlight, - link: Link, - section: Section - // NOTE: `marker` must not have a component: it is an annotation, so it - // is data-only and may overlap marks (e.g. a section) and other - // annotations. Covered node wrappers get `anno-marker` classes - // automatically — see styles/annotations.css for how it's styled with - // pure CSS. - }, - // Toggle view classes for the editor. On by default. - view_classes: true, - handle_property_deletion: (tr, path) => { - const property_definition = tr.inspect(path); - if (property_definition?.type !== 'string' || property_definition?.name !== 'image') return; - tr.set(path, ''); - }, - handle_media_paste: async (doc, pasted_media) => { - // ATTENTION: In a real-world-app, you may want to upload `pasted_media` here, - // before referencing them from the document. - - if (doc.selection.type === 'property') { - const property_definition = doc.inspect(doc.selection.path); - if (property_definition.name === 'image') { - const tr = doc.tr; - tr.set(doc.selection.path, pasted_media[0].data_url); - doc.apply(tr); - } - return null; - } else { - const pasted_json = { main_nodes: [], nodes: {} }; - // When caret inside an image grid we want to insert an image_grid_item - // otherwise we want to insert a story, as that is the only body node, - // that can carry an image. - let target_node_type; - if (doc.can_insert('image_grid_item')) { - target_node_type = 'image_grid_item'; + if (doc.selection.type === 'property') { + const property_definition = doc.inspect(doc.selection.path); + if (property_definition.name === 'image') { + const tr = doc.tr; + tr.set(doc.selection.path, pasted_media[0].data_url); + doc.apply(tr); + } + return null; } else { - target_node_type = 'story'; - } - for (let i = 0; i < pasted_media.length; i++) { - const pasted_image = pasted_media[i]; - pasted_json.nodes['node_' + i] = { - id: 'node_' + i, - type: target_node_type, - image: pasted_image.data_url - }; - pasted_json.main_nodes.push('node_' + i); + const pasted_json = { main_nodes: [], nodes: {} }; + // When caret inside an image grid we want to insert an image_grid_item + // otherwise we want to insert a story, as that is the only body node, + // that can carry an image. + let target_node_type; + if (doc.can_insert('image_grid_item')) { + target_node_type = 'image_grid_item'; + } else { + target_node_type = 'story'; + } + for (let i = 0; i < pasted_media.length; i++) { + const pasted_image = pasted_media[i]; + pasted_json.nodes['node_' + i] = { + id: 'node_' + i, + type: target_node_type, + image: pasted_image.data_url + }; + pasted_json.main_nodes.push('node_' + i); + } + return pasted_json; } - return pasted_json; } - }, - - // HTML exporters for different node types - html_exporters: { - hero: (node) => { - let html = ''; - if (node.title.content.trim()) { - html += `

${node.title.content}

\n`; - } - if (node.description.content.trim()) { - html += `

${node.description.content}

\n`; - } - return html; - }, - - list: (node, doc, html_exporters) => { - const { list_item } = html_exporters; - let html = '
    \n'; - for (const list_item_id of node.list_items.nodes) { - html += list_item(doc.get(list_item_id), doc, html_exporters); - } - return html + '
'; - }, - story: (node, doc, html_exporters) => { - const { button } = html_exporters; - let html = ''; - if (node.image) { - html += `${node.title.content}\n`; - } - html += `

${node.title.content}

\n`; - if (node.description) { - html += `

${node.description.content}

\n`; - } - for (const button_id of node.buttons.nodes) { - html += button(doc.get(button_id), doc, html_exporters); - } - return html; - }, - paragraph: (node) => { - return `

${node.content.content}

\n`; - }, - heading_1: (node) => { - return `

${node.content.content}

\n`; - }, - heading_2: (node) => { - return `

${node.content.content}

\n`; - }, - heading_3: (node) => { - return `

${node.content.content}

\n`; - }, - button: (node) => { - return `${node.content.content}\n`; - }, - image_grid_item: (node) => { - let html = '
\n'; - if (node.image) { - html += `${node.title.content}\n`; - } - if (node.title.content.trim()) { - html += `

${node.title.content}

\n`; - } - if (node.description.content.trim()) { - html += `

${node.description.content}

\n`; - } - return html + '
'; - }, - list_item: (node) => { - const content = - typeof node.content === 'object' && node.content.content - ? node.content.content - : node.content || ''; - return `
  • ${content}
  • \n`; - } - }, - node_layouts: { - button: 1, - paragraph: 1, - heading_1: 1, - heading_2: 1, - heading_3: 1, - story: 3, - list: 5, - list_item: 1, - image_grid: 1, - hero: 1 - }, - // Custom functions to insert new "blank" nodes and setting the selection depening on the - // intended behavior. - inserters: { - paragraph: function (tr, content, layout) { - const new_paragraph = { - id: nanoid(), - type: 'paragraph' - }; - if (content !== undefined) new_paragraph.content = content; - if (layout !== undefined) new_paragraph.layout = layout; - tr.create(new_paragraph); - tr.insert_nodes([new_paragraph.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - heading_1: function (tr, content, layout) { - const new_heading_1 = { - id: nanoid(), - type: 'heading_1' - }; - if (content !== undefined) new_heading_1.content = content; - if (layout !== undefined) new_heading_1.layout = layout; - tr.create(new_heading_1); - tr.insert_nodes([new_heading_1.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - heading_2: function (tr, content, layout) { - const new_heading_2 = { - id: nanoid(), - type: 'heading_2' - }; - if (content !== undefined) new_heading_2.content = content; - if (layout !== undefined) new_heading_2.layout = layout; - tr.create(new_heading_2); - tr.insert_nodes([new_heading_2.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - heading_3: function (tr, content, layout) { - const new_heading_3 = { - id: nanoid(), - type: 'heading_3' - }; - if (content !== undefined) new_heading_3.content = content; - if (layout !== undefined) new_heading_3.layout = layout; - tr.create(new_heading_3); - tr.insert_nodes([new_heading_3.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - story: function (tr) { - const new_button = { - id: nanoid(), - type: 'button' - }; - tr.create(new_button); - const new_story = { - id: nanoid(), - type: 'story', - buttons: { nodes: [new_button.id], marks: [], annotations: [] } - }; - tr.create(new_story); - tr.insert_nodes([new_story.id]); - }, - list: function (tr) { - const new_list_item = { - id: nanoid(), - type: 'list_item' - }; - tr.create(new_list_item); - const new_list = { - id: nanoid(), - type: 'list', - list_items: { nodes: [new_list_item.id], marks: [], annotations: [] }, - layout: 3 - }; - tr.create(new_list); - tr.insert_nodes([new_list.id]); - }, - list_item: function (tr, content) { - const new_list_item = { - id: nanoid(), - type: 'list_item' - }; - if (content !== undefined) new_list_item.content = content; - tr.create(new_list_item); - tr.insert_nodes([new_list_item.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - - image_grid: function (tr) { - const new_image_grid_items = []; - for (let i = 0; i < 6; i++) { - const image_grid_item = { - id: nanoid(), - type: 'image_grid_item' - }; - tr.create(image_grid_item); - new_image_grid_items.push(image_grid_item.id); - } - const new_image_grid = { - id: nanoid(), - type: 'image_grid', - image_grid_items: { nodes: new_image_grid_items, marks: [], annotations: [] } - }; - tr.create(new_image_grid); - tr.insert_nodes([new_image_grid.id]); - }, - image_grid_item: function (tr) { - const new_image_grid_item = { - id: nanoid(), - type: 'image_grid_item' - }; - tr.create(new_image_grid_item); - tr.insert_nodes([new_image_grid_item.id]); - tr.set_selection({ - type: 'node', - path: [...tr.selection.path], - anchor_offset: tr.selection.focus_offset, - focus_offset: tr.selection.focus_offset - }); - }, - button: function (tr, content) { - const new_button = { - id: nanoid(), - type: 'button' - }; - if (content !== undefined) new_button.content = content; - tr.create(new_button); - tr.insert_nodes([new_button.id]); - tr.set_selection({ - type: 'text', - path: [...tr.selection.path, tr.selection.focus_offset - 1, 'content'], - anchor_offset: 0, - focus_offset: 0 - }); - }, - hero: function (tr) { - const new_hero = { - id: nanoid(), - type: 'hero' - }; - tr.create(new_hero); - tr.insert_nodes([new_hero.id]); - } - }, - - /** - * Factory function to create Svedit commands and keymap. - * Called by Svedit component with the svedit context. - * - * @param {object} context - The svedit context with doc, editable, canvas. - * @returns {{ commands: object, keymap: object }} - */ - create_commands_and_keymap: (context) => { - // Create command instances with the provided context - const commands = { - select_all: new SelectAllCommand(context), - insert_default_node: new InsertDefaultNodeCommand(context), - add_new_line: new AddNewLineCommand(context), - break_text_node: new BreakTextNodeCommand(context), - toggle_strong: new ToggleMarkCommand('strong', context), - toggle_emphasis: new ToggleMarkCommand('emphasis', context), - toggle_highlight: new ToggleMarkCommand('highlight', context), - toggle_link: new ToggleLinkCommand(context), - toggle_section: new ToggleMarkCommand('section', context), - // Annotations only compete with same-type annotations, so the - // marker toggle never conflicts with sections or other marks. - toggle_marker: new ToggleAnnotationCommand('marker', context), - undo: new UndoCommand(context), - redo: new RedoCommand(context), - select_parent: new SelectParentCommand(context), - next_layout: new CycleLayoutCommand('next', context), - previous_layout: new CycleLayoutCommand('previous', context), - next_type: new CycleNodeTypeCommand('next', context), - previous_type: new CycleNodeTypeCommand('previous', context) - }; - - // Define keymap binding keys to commands - const keymap = define_keymap({ - 'meta+a,ctrl+a': [commands.select_all], - enter: [commands.break_text_node, commands.add_new_line, commands.insert_default_node], - 'shift+enter': [commands.add_new_line], - 'meta+b,ctrl+b': [commands.toggle_strong], - 'meta+i,ctrl+i': [commands.toggle_emphasis], - 'meta+u,ctrl+u': [commands.toggle_highlight], - 'meta+k,ctrl+k': [commands.toggle_link], - 'meta+shift+s,ctrl+shift+s': [commands.toggle_section], - 'meta+shift+m,ctrl+shift+m': [commands.toggle_marker], - 'meta+z,ctrl+z': [commands.undo], - 'meta+shift+z,ctrl+shift+z': [commands.redo], - escape: [commands.select_parent], - 'ctrl+shift+arrowright': [commands.next_layout], - 'ctrl+shift+arrowleft': [commands.previous_layout], - 'ctrl+shift+arrowdown': [commands.next_type], - 'ctrl+shift+arrowup': [commands.previous_type] - }); - - return { commands, keymap }; } -}; +); + +export const document_schema = schema; +export const session_config = config; export default function create_demo_session() { const demo_doc = fill_document_defaults(doc, document_schema); diff --git a/src/routes/features/core.js b/src/routes/features/core.js new file mode 100644 index 00000000..823e72ac --- /dev/null +++ b/src/routes/features/core.js @@ -0,0 +1,41 @@ +import { + SelectAllCommand, + InsertDefaultNodeCommand, + AddNewLineCommand, + BreakTextNodeCommand, + UndoCommand, + RedoCommand, + SelectParentCommand +} from 'svedit'; +import { CycleLayoutCommand, CycleNodeTypeCommand } from '../commands.svelte.js'; + +// App-level editing commands and their keybindings, independent of any +// specific node type. +export default { + name: 'core', + commands: (context) => ({ + select_all: new SelectAllCommand(context), + insert_default_node: new InsertDefaultNodeCommand(context), + add_new_line: new AddNewLineCommand(context), + break_text_node: new BreakTextNodeCommand(context), + undo: new UndoCommand(context), + redo: new RedoCommand(context), + select_parent: new SelectParentCommand(context), + next_layout: new CycleLayoutCommand('next', context), + previous_layout: new CycleLayoutCommand('previous', context), + next_type: new CycleNodeTypeCommand('next', context), + previous_type: new CycleNodeTypeCommand('previous', context) + }), + keymap: { + 'meta+a,ctrl+a': ['select_all'], + enter: ['break_text_node', 'add_new_line', 'insert_default_node'], + 'shift+enter': ['add_new_line'], + 'meta+z,ctrl+z': ['undo'], + 'meta+shift+z,ctrl+shift+z': ['redo'], + escape: ['select_parent'], + 'ctrl+shift+arrowright': ['next_layout'], + 'ctrl+shift+arrowleft': ['previous_layout'], + 'ctrl+shift+arrowdown': ['next_type'], + 'ctrl+shift+arrowup': ['previous_type'] + } +}; diff --git a/src/routes/features/hero.js b/src/routes/features/hero.js new file mode 100644 index 00000000..13907275 --- /dev/null +++ b/src/routes/features/hero.js @@ -0,0 +1,45 @@ +import Hero from '../components/Hero.svelte'; +import { ALL_MARKS, TITLE_MARKS } from './marks.js'; + +// The hero needs no inserter: the generic schema-driven inserter creates it +// from schema defaults. +export default { + name: 'hero', + schema: { + hero: { + kind: 'block', + properties: { + layout: { type: 'integer', default: 1 }, + title: { + type: 'text', + mark_types: TITLE_MARKS, + allow_newlines: false + }, + description: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + }, + image: { type: 'string' } // a dedicated type asset would be better + } + } + }, + node_components: { + hero: Hero + }, + node_layouts: { + hero: 1 + }, + html_exporters: { + hero: (node) => { + let html = ''; + if (node.title.content.trim()) { + html += `

    ${node.title.content}

    \n`; + } + if (node.description.content.trim()) { + html += `

    ${node.description.content}

    \n`; + } + return html; + } + } +}; diff --git a/src/routes/features/image_grid.js b/src/routes/features/image_grid.js new file mode 100644 index 00000000..d78249c1 --- /dev/null +++ b/src/routes/features/image_grid.js @@ -0,0 +1,95 @@ +import ImageGrid from '../components/ImageGrid.svelte'; +import ImageGridItem from '../components/ImageGridItem.svelte'; +import { ALL_MARKS, TITLE_MARKS } from './marks.js'; + +export default { + name: 'image_grid', + schema: { + image_grid: { + kind: 'block', + properties: { + layout: { type: 'integer', default: 1 }, + image_grid_items: { + type: 'node_array', + node_types: ['image_grid_item'], + annotation_types: ['marker'] + } + } + }, + image_grid_item: { + kind: 'block', + properties: { + image: { type: 'string' }, // a dedicated type asset would be better + title: { + type: 'text', + mark_types: TITLE_MARKS, + allow_newlines: false + }, + description: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: false + } + } + } + }, + node_components: { + image_grid: ImageGrid, + image_grid_item: ImageGridItem + }, + node_layouts: { + image_grid: 1 + }, + inserters: { + // Custom: a new grid is seeded with six empty items. + image_grid: function (tr) { + const new_image_grid_items = []; + for (let i = 0; i < 6; i++) { + const image_grid_item = { + id: tr.generate_id(), + type: 'image_grid_item' + }; + tr.create(image_grid_item); + new_image_grid_items.push(image_grid_item.id); + } + const new_image_grid = { + id: tr.generate_id(), + type: 'image_grid', + image_grid_items: { nodes: new_image_grid_items, marks: [], annotations: [] } + }; + tr.create(new_image_grid); + tr.insert_nodes([new_image_grid.id]); + }, + // Custom: after inserting an item the caret collapses behind it + // instead of keeping the new node selected. + image_grid_item: function (tr) { + const new_image_grid_item = { + id: tr.generate_id(), + type: 'image_grid_item' + }; + tr.create(new_image_grid_item); + tr.insert_nodes([new_image_grid_item.id]); + tr.set_selection({ + type: 'node', + path: [...tr.selection.path], + anchor_offset: tr.selection.focus_offset, + focus_offset: tr.selection.focus_offset + }); + } + }, + html_exporters: { + image_grid_item: (node) => { + let html = '
    \n'; + if (node.image) { + html += `${node.title.content}\n`; + } + if (node.title.content.trim()) { + html += `

    ${node.title.content}

    \n`; + } + if (node.description.content.trim()) { + html += `

    ${node.description.content}

    \n`; + } + return html + '
    '; + } + } +}; diff --git a/src/routes/features/list.js b/src/routes/features/list.js new file mode 100644 index 00000000..e6593c71 --- /dev/null +++ b/src/routes/features/list.js @@ -0,0 +1,74 @@ +import List from '../components/List.svelte'; +import ListItem from '../components/ListItem.svelte'; +import { ALL_MARKS } from './marks.js'; + +export default { + name: 'list', + schema: { + list: { + kind: 'block', + properties: { + list_items: { + type: 'node_array', + node_types: ['list_item'], + annotation_types: ['marker'] + }, + layout: { type: 'integer', default: 1 } + } + }, + list_item: { + kind: 'text', + properties: { + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: false + } + } + } + }, + node_components: { + list: List, + list_item: ListItem + }, + node_layouts: { + list: 5, + list_item: 1 + }, + inserters: { + // Custom: a new list is seeded with one empty item; list_item itself + // uses the generic schema-driven inserter. + list: function (tr) { + const new_list_item = { + id: tr.generate_id(), + type: 'list_item' + }; + tr.create(new_list_item); + const new_list = { + id: tr.generate_id(), + type: 'list', + list_items: { nodes: [new_list_item.id], marks: [], annotations: [] }, + layout: 3 + }; + tr.create(new_list); + tr.insert_nodes([new_list.id]); + } + }, + html_exporters: { + list: (node, doc, html_exporters) => { + const { list_item } = html_exporters; + let html = '
      \n'; + for (const list_item_id of node.list_items.nodes) { + html += list_item(doc.get(list_item_id), doc, html_exporters); + } + return html + '
    '; + }, + list_item: (node) => { + const content = + typeof node.content === 'object' && node.content.content + ? node.content.content + : node.content || ''; + return `
  • ${content}
  • \n`; + } + } +}; diff --git a/src/routes/features/marker.js b/src/routes/features/marker.js new file mode 100644 index 00000000..ef949b18 --- /dev/null +++ b/src/routes/features/marker.js @@ -0,0 +1,22 @@ +import { ToggleAnnotationCommand } from 'svedit'; + +export default { + name: 'marker', + schema: { + // An annotation: data-only, no component allowed, so it may overlap marks + // and other annotations. Covered node wrappers get `anno-marker` classes + // automatically, styled in styles/annotations.css. + marker: { + kind: 'annotation', + properties: {} + } + }, + commands: (context) => ({ + // Annotations only compete with same-type annotations, so the + // marker toggle never conflicts with sections or other marks. + toggle_marker: new ToggleAnnotationCommand('marker', context) + }), + keymap: { + 'meta+shift+m,ctrl+shift+m': ['toggle_marker'] + } +}; diff --git a/src/routes/features/marks.js b/src/routes/features/marks.js new file mode 100644 index 00000000..38ac9f49 --- /dev/null +++ b/src/routes/features/marks.js @@ -0,0 +1,61 @@ +import { ToggleMarkCommand } from 'svedit'; +import { ToggleLinkCommand } from '../commands.svelte.js'; + +import Strong from '../components/Strong.svelte'; +import Emphasis from '../components/Emphasis.svelte'; +import Highlight from '../components/Highlight.svelte'; +import Link from '../components/Link.svelte'; +import Section from '../components/Section.svelte'; + +// Mark bundles other features reference from their text properties +export const ALL_MARKS = ['strong', 'emphasis', 'highlight', 'link']; +export const TITLE_MARKS = ['emphasis', 'highlight']; + +export default { + name: 'marks', + schema: { + strong: { + kind: 'mark', + properties: {} + }, + emphasis: { + kind: 'mark', + properties: {} + }, + highlight: { + kind: 'mark', + properties: {} + }, + link: { + kind: 'mark', + properties: { + href: { type: 'string' } + } + }, + section: { + kind: 'mark', + properties: {} + } + }, + node_components: { + strong: Strong, + emphasis: Emphasis, + highlight: Highlight, + link: Link, + section: Section + }, + commands: (context) => ({ + toggle_strong: new ToggleMarkCommand('strong', context), + toggle_emphasis: new ToggleMarkCommand('emphasis', context), + toggle_highlight: new ToggleMarkCommand('highlight', context), + toggle_link: new ToggleLinkCommand(context), + toggle_section: new ToggleMarkCommand('section', context) + }), + keymap: { + 'meta+b,ctrl+b': ['toggle_strong'], + 'meta+i,ctrl+i': ['toggle_emphasis'], + 'meta+u,ctrl+u': ['toggle_highlight'], + 'meta+k,ctrl+k': ['toggle_link'], + 'meta+shift+s,ctrl+shift+s': ['toggle_section'] + } +}; diff --git a/src/routes/features/page.js b/src/routes/features/page.js new file mode 100644 index 00000000..cb2fd3ba --- /dev/null +++ b/src/routes/features/page.js @@ -0,0 +1,43 @@ +import Page from '../components/Page.svelte'; + +// The page feature owns all container wiring: which node types are allowed in +// the body, which marks/annotations may wrap body ranges, and what gets +// inserted by default. Features define node types; the app composes them. +export default { + name: 'page', + schema: { + page: { + kind: 'document', + properties: { + body: { + type: 'node_array', + node_types: [ + 'paragraph', + 'heading_1', + 'heading_2', + 'heading_3', + 'story', + 'list', + 'image_grid', + 'hero' + ], + mark_types: ['section'], + annotation_types: ['marker'], + default_node_type: 'paragraph' + }, + keywords: { + type: 'string_array' + }, + daily_visitors: { + type: 'integer_array' + }, + created_at: { + type: 'datetime' + } + } + } + }, + node_components: { + page: Page + } +}; diff --git a/src/routes/features/story.js b/src/routes/features/story.js new file mode 100644 index 00000000..75e2ffec --- /dev/null +++ b/src/routes/features/story.js @@ -0,0 +1,88 @@ +import Story from '../components/Story.svelte'; +import Button from '../components/Button.svelte'; +import { ALL_MARKS, TITLE_MARKS } from './marks.js'; + +export default { + name: 'story', + schema: { + story: { + kind: 'block', + properties: { + layout: { type: 'integer', default: 1 }, + title: { + type: 'text', + mark_types: TITLE_MARKS, + allow_newlines: false + }, + description: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + }, + buttons: { + type: 'node_array', + node_types: ['button'], + default_node_type: 'button' + }, + image: { type: 'string' } + } + }, + button: { + kind: 'text', + properties: { + content: { + type: 'text', + mark_types: [], + allow_newlines: false + }, + href: { type: 'string' } + } + } + }, + node_components: { + story: Story, + button: Button + }, + node_layouts: { + story: 3, + button: 1 + }, + inserters: { + // Custom: a new story is seeded with a nested button, which schema + // defaults alone can't express. + story: function (tr) { + const new_button = { + id: tr.generate_id(), + type: 'button' + }; + tr.create(new_button); + const new_story = { + id: tr.generate_id(), + type: 'story', + buttons: { nodes: [new_button.id], marks: [], annotations: [] } + }; + tr.create(new_story); + tr.insert_nodes([new_story.id]); + } + }, + html_exporters: { + story: (node, doc, html_exporters) => { + const { button } = html_exporters; + let html = ''; + if (node.image) { + html += `${node.title.content}\n`; + } + html += `

    ${node.title.content}

    \n`; + if (node.description) { + html += `

    ${node.description.content}

    \n`; + } + for (const button_id of node.buttons.nodes) { + html += button(doc.get(button_id), doc, html_exporters); + } + return html; + }, + button: (node) => { + return `${node.content.content}\n`; + } + } +}; diff --git a/src/routes/features/text_blocks.js b/src/routes/features/text_blocks.js new file mode 100644 index 00000000..e9ac5501 --- /dev/null +++ b/src/routes/features/text_blocks.js @@ -0,0 +1,83 @@ +import Paragraph from '../components/Paragraph.svelte'; +import Heading1 from '../components/Heading1.svelte'; +import Heading2 from '../components/Heading2.svelte'; +import Heading3 from '../components/Heading3.svelte'; +import { ALL_MARKS } from './marks.js'; + +// Text blocks need no inserters: Svedit's generic schema-driven inserter +// creates them from schema defaults and places the caret in `content`. +export default { + name: 'text_blocks', + schema: { + paragraph: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + }, + heading_1: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + }, + heading_2: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + }, + heading_3: { + kind: 'text', + properties: { + layout: { type: 'integer', default: 1 }, + content: { + type: 'text', + mark_types: ALL_MARKS, + allow_newlines: true + } + } + } + }, + node_components: { + paragraph: Paragraph, + heading_1: Heading1, + heading_2: Heading2, + heading_3: Heading3 + }, + node_layouts: { + paragraph: 1, + heading_1: 1, + heading_2: 1, + heading_3: 1 + }, + html_exporters: { + paragraph: (node) => { + return `

    ${node.content.content}

    \n`; + }, + heading_1: (node) => { + return `

    ${node.content.content}

    \n`; + }, + heading_2: (node) => { + return `

    ${node.content.content}

    \n`; + }, + heading_3: (node) => { + return `

    ${node.content.content}

    \n`; + } + } +}; diff --git a/src/test/compose.svelte.test.js b/src/test/compose.svelte.test.js new file mode 100644 index 00000000..2853611c --- /dev/null +++ b/src/test/compose.svelte.test.js @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import { compose } from '../lib/compose.js'; +import Session from '../lib/Session.svelte.js'; +import Command from '../lib/Command.svelte.js'; + +import Page from '../routes/components/Page.svelte'; +import Paragraph from '../routes/components/Paragraph.svelte'; + +class NoopCommand extends Command { + execute() {} +} + +const page_definition = { + name: 'page', + schema: { + page: { + kind: 'document', + properties: { + body: { + type: 'node_array', + node_types: ['paragraph'], + default_node_type: 'paragraph' + } + } + } + }, + node_components: { page: Page } +}; + +const paragraph_definition = { + name: 'paragraph', + schema: { + paragraph: { + kind: 'text', + properties: { + content: { type: 'text', allow_newlines: true } + } + } + }, + node_components: { paragraph: Paragraph }, + node_layouts: { paragraph: 1 } +}; + +describe('compose', () => { + it('merges schema and object-valued config registries', () => { + const { schema, config } = compose([page_definition, paragraph_definition], { + generate_id: () => 'id_1', + view_classes: false + }); + + expect(Object.keys(schema)).toEqual(['page', 'paragraph']); + expect(config.node_components.page).toBe(Page); + expect(config.node_components.paragraph).toBe(Paragraph); + // Unknown app-specific registries merge too + expect(config.node_layouts).toEqual({ paragraph: 1 }); + // App-level scalars are set directly + expect(config.generate_id()).toBe('id_1'); + expect(config.view_classes).toBe(false); + }); + + it('throws on duplicate schema keys across definitions', () => { + expect(() => compose([paragraph_definition, paragraph_definition])).toThrow( + "schema key 'paragraph' from paragraph conflicts with paragraph" + ); + }); + + it('throws on duplicate registry keys and names the definitions', () => { + const other = { + name: 'other', + node_components: { paragraph: Page } + }; + expect(() => compose([paragraph_definition, other])).toThrow( + "config.node_components key 'paragraph' from other conflicts with paragraph" + ); + }); + + it('throws when two definitions set the same scalar config key', () => { + const a = { name: 'a', view_classes: true }; + const b = { name: 'b', view_classes: false }; + expect(() => compose([a, b])).toThrow("config key 'view_classes' from b is already set"); + }); + + it('lets app config override scalar keys from definitions', () => { + const a = { name: 'a', view_classes: true }; + const { config } = compose([a], { view_classes: false }); + expect(config.view_classes).toBe(false); + }); + + it('builds create_commands_and_keymap from command factories and name-based keymaps', () => { + const feature_a = { + name: 'a', + commands: (context) => ({ command_a: new NoopCommand(context) }), + keymap: { 'meta+j,ctrl+j': ['command_a'], enter: ['command_a'] } + }; + const feature_b = { + name: 'b', + commands: (context) => ({ command_b: new NoopCommand(context) }), + // Same key from multiple definitions concatenates in order + keymap: { enter: ['command_b'] } + }; + + const { config } = compose([feature_a, feature_b]); + const { commands, keymap } = config.create_commands_and_keymap({ session: null }); + + expect(Object.keys(commands)).toEqual(['command_a', 'command_b']); + expect(keymap['meta+j,ctrl+j']).toEqual([commands.command_a]); + expect(keymap['enter']).toEqual([commands.command_a, commands.command_b]); + }); + + it('throws on duplicate command names across definitions', () => { + const a = { name: 'a', commands: (context) => ({ go: new NoopCommand(context) }) }; + const b = { name: 'b', commands: (context) => ({ go: new NoopCommand(context) }) }; + const { config } = compose([a, b]); + expect(() => config.create_commands_and_keymap({})).toThrow( + "commands key 'go' from b conflicts with a" + ); + }); + + it('throws when a keymap references an unknown command name', () => { + const a = { name: 'a', keymap: { enter: ['missing_command'] } }; + const { config } = compose([a]); + expect(() => config.create_commands_and_keymap({})).toThrow( + "keymap 'enter' references unknown command 'missing_command'" + ); + }); + + it('rejects mixing definition commands with a custom create_commands_and_keymap', () => { + const a = { name: 'a', commands: (context) => ({ go: new NoopCommand(context) }) }; + expect(() => compose([a], { create_commands_and_keymap: () => ({}) })).toThrow( + 'not both' + ); + }); + + it('produces a config a Session can be constructed from', () => { + const { schema, config } = compose([page_definition, paragraph_definition], { + generate_id: () => `id_${Math.random().toString(36).slice(2)}` + }); + + const doc = { + document_id: 'page_1', + nodes: { + paragraph_1: { + id: 'paragraph_1', + type: 'paragraph', + content: { content: 'Hello', marks: [], annotations: [] } + }, + page_1: { + id: 'page_1', + type: 'page', + body: { nodes: ['paragraph_1'], marks: [], annotations: [] } + } + } + }; + + const session = new Session(schema, doc, config); + expect(session.get('paragraph_1').content.content).toBe('Hello'); + }); +}); diff --git a/src/test/dev_ergonomics.svelte.test.js b/src/test/dev_ergonomics.svelte.test.js new file mode 100644 index 00000000..af655873 --- /dev/null +++ b/src/test/dev_ergonomics.svelte.test.js @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import create_test_session from './create_test_session.js'; +import { + check_config_completeness, + validate_document_schema +} from '../lib/doc_utils.js'; +import { insert_default_node, break_text_node } from '../lib/transforms.svelte.js'; + +// A fresh session whose shared module-level config is cloned, so per-test +// mutations (removing inserters/components) don't leak into other tests. +function create_isolated_session() { + const session = create_test_session(); + session.schema = structuredClone(session.schema); + session.config = { + ...session.config, + node_components: { ...session.config.node_components }, + inserters: { ...session.config.inserters } + }; + return session; +} + +describe('check_config_completeness', () => { + it('returns no warnings for a complete setup', () => { + const session = create_isolated_session(); + expect(check_config_completeness(session.schema, session.config)).toEqual([]); + }); + + it('warns about node types without a registered component', () => { + const session = create_isolated_session(); + delete session.config.node_components.story; + + const warnings = check_config_completeness(session.schema, session.config); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'story'"); + expect(warnings[0]).toContain('UnknownNode'); + }); + + it('does not require components for mark and annotation types', () => { + const session = create_isolated_session(); + session.schema.strong = { kind: 'mark', properties: {} }; + session.schema.comment = { kind: 'annotation', properties: {} }; + /** @type {any} */ (session.schema.story.properties.title).mark_types = ['strong']; + /** @type {any} */ (session.schema.story.properties.title).annotation_types = ['comment']; + + expect(check_config_completeness(session.schema, session.config)).toEqual([]); + }); + + it('warns about mark and annotation types no property allows', () => { + const session = create_isolated_session(); + session.schema.strong = { kind: 'mark', properties: {} }; + + const warnings = check_config_completeness(session.schema, session.config); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'strong'"); + expect(warnings[0]).toContain('never be applied'); + }); + + it('warns when a default node type cannot be auto-inserted and has no custom inserter', () => { + const session = create_isolated_session(); + // An asset reference without a default cannot be fabricated from schema + // defaults, so a paragraph without a custom inserter is not insertable. + session.schema.asset = { kind: 'block', properties: { url: { type: 'string' } } }; + session.config.node_components.asset = session.config.node_components.story; + session.schema.paragraph.properties.asset = { type: 'node', node_types: ['asset'] }; + delete session.config.inserters.paragraph; + + const warnings = check_config_completeness(session.schema, session.config); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'paragraph'"); + expect(warnings[0]).toContain("config.inserters['paragraph']"); + }); + + it('accepts a default node type covered by the generic inserter', () => { + const session = create_isolated_session(); + delete session.config.inserters.paragraph; + expect(check_config_completeness(session.schema, session.config)).toEqual([]); + }); +}); + +describe('schema validation of node_types kinds', () => { + it('rejects node_types referencing mark or annotation types', () => { + const session = create_isolated_session(); + const schema = structuredClone(session.schema); + schema.strong = { kind: 'mark', properties: {} }; + /** @type {any} */ (schema.page.properties.body).node_types = ['paragraph', 'strong']; + + expect(() => validate_document_schema(schema)).toThrow( + 'node_types must not reference mark or annotation types, got: strong' + ); + }); +}); + +describe('generic schema-driven inserter', () => { + it('insert_default_node inserts a schema-default node without a custom inserter', () => { + const session = create_isolated_session(); + delete session.config.inserters.paragraph; + + session.selection = { + type: 'node', + path: ['page_1', 'body'], + anchor_offset: 0, + focus_offset: 0 + }; + const tr = session.tr; + expect(insert_default_node(tr)).toBe(true); + session.apply(tr); + + const body = session.get(['page_1', 'body']); + expect(body.nodes).toHaveLength(4); + const new_node = session.get(body.nodes[0]); + expect(new_node.type).toBe('paragraph'); + expect(new_node.content).toEqual({ content: '', marks: [], annotations: [] }); + // The caret is placed at the start of the new node's content + expect(session.selection).toEqual({ + type: 'text', + path: ['page_1', 'body', 0, 'content'], + anchor_offset: 0, + focus_offset: 0 + }); + }); + + it('break_text_node splits without a custom inserter and carries the right part over', () => { + const session = create_isolated_session(); + delete session.config.inserters.list_item; + + // 'first list item' — split after 'first' + session.selection = { + type: 'text', + path: ['list_1', 'list_items', 0, 'content'], + anchor_offset: 5, + focus_offset: 5 + }; + const tr = session.tr; + expect(break_text_node(tr)).toBe(true); + session.apply(tr); + + const list_items = session.get(['list_1', 'list_items']); + expect(list_items.nodes).toHaveLength(3); + expect(session.get(list_items.nodes[0]).content.content).toBe('first'); + expect(session.get(list_items.nodes[1]).content.content).toBe(' list item'); + }); + + it('throws a hint naming config.inserters when defaults cannot construct the node', () => { + const session = create_isolated_session(); + session.schema.asset = { kind: 'block', properties: { url: { type: 'string' } } }; + session.schema.paragraph.properties.asset = { type: 'node', node_types: ['asset'] }; + delete session.config.inserters.paragraph; + + session.selection = { + type: 'node', + path: ['page_1', 'body'], + anchor_offset: 0, + focus_offset: 0 + }; + expect(() => insert_default_node(session.tr)).toThrow("config.inserters['paragraph']"); + }); +}); From 7a206153a868c9a5cfaecaf3996992d138ac0b76 Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 19:02:50 +0200 Subject: [PATCH 02/10] Include demo_doc.js --- src/routes/demo_doc.js | 477 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 src/routes/demo_doc.js diff --git a/src/routes/demo_doc.js b/src/routes/demo_doc.js new file mode 100644 index 00000000..b0863fe6 --- /dev/null +++ b/src/routes/demo_doc.js @@ -0,0 +1,477 @@ +// The demo document: a plain JS object with a document_id and a flat map of +// nodes. See the "Document" section in the README for the format. +export default { + document_id: 'page_1', + nodes: { + zXfRSCtGrGWaBCpaWdBvdZG: { + id: 'zXfRSCtGrGWaBCpaWdBvdZG', + type: 'link', + href: 'https://editable.website' + }, + hero_1: { + id: 'hero_1', + type: 'hero', + layout: 1, + title: { + content: 'Svedit', + marks: [], + annotations: [] + }, + description: { + content: 'A tiny library for building editable websites in Svelte.', + marks: [ + { + start_offset: 28, + end_offset: 36, + node_id: 'zXfRSCtGrGWaBCpaWdBvdZG' + } + ], + annotations: [] + }, + image: '' + }, + heading_1: { + id: 'heading_1', + type: 'heading_1', + layout: 1, + content: { + content: 'Text and structured content in symbiosis', + marks: [], + annotations: [] + } + }, + paragraph_1: { + id: 'paragraph_1', + type: 'paragraph', + layout: 1, + content: { + content: + "Unlike most rich text editors, Svedit isn't restricted to a linear character-based model for addressing content and caret positions. For that reason we can combine text-ish content like a paragraph or heading with structured, form-like content.", + marks: [], + annotations: [] + } + }, + JRZfuWeXnWKnWHaNtjupSsX: { + id: 'JRZfuWeXnWKnWHaNtjupSsX', + type: 'button', + content: { + content: 'Get started', + marks: [], + annotations: [] + }, + href: 'https://github.com/michael/svedit' + }, + story_1: { + id: 'story_1', + type: 'story', + layout: 1, + image: '/images/editable.svg', + title: { + content: 'Visual in‑place editing', + marks: [], + annotations: [] + }, + description: { + content: + 'Model your content in JSON, render it with Svelte components, and edit content directly in the layout. You only have to follow a couple of rules to make this work.', + marks: [], + annotations: [] + }, + buttons: { + nodes: ['JRZfuWeXnWKnWHaNtjupSsX'], + marks: [], + annotations: [] + } + }, + link_1: { + id: 'link_1', + type: 'link', + href: 'https://editable.website' + }, + story_2: { + id: 'story_2', + type: 'story', + layout: 2, + image: '/images/lightweight.svg', + title: { + content: 'Minimal viable editor', + marks: [], + annotations: [] + }, + description: { + content: + "The reference implementation uses only about 2000 lines of code. That means you'll be able to serve editable web pages, removing the need for a separate Content Management System.", + marks: [ + { + start_offset: 100, + end_offset: 118, + node_id: 'link_1' + } + ], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + image_grid_item_1: { + id: 'image_grid_item_1', + type: 'image_grid_item', + image: '/images/svelte-framework.svg', + title: { + content: 'Svelte-native editing', + marks: [], + annotations: [] + }, + description: { + content: "No mingling with 3rd-party rendering API's.", + marks: [], + annotations: [] + } + }, + image_grid_item_2: { + id: 'image_grid_item_2', + type: 'image_grid_item', + image: '/images/annotations.svg', + title: { + content: 'Marks and annotations', + marks: [], + annotations: [] + }, + description: { + content: + 'Wrap ranges in marks (e.g. links) and attach annotations (e.g. comments) at a separate layer.', + marks: [], + annotations: [] + } + }, + image_grid_item_3: { + id: 'image_grid_item_3', + type: 'image_grid_item', + image: '/images/graphmodel.svg', + title: { + content: 'Graph‑first content with nested nodes', + marks: [], + annotations: [] + }, + description: { + content: + 'From simple paragraphs to complex nodes with nested arrays and multiple properties.', + marks: [], + annotations: [] + } + }, + image_grid_item_4: { + id: 'image_grid_item_4', + type: 'image_grid_item', + image: '/images/dom-synced.svg', + title: { + content: 'DOM ↔ model selections match', + marks: [], + annotations: [] + }, + description: { + content: 'Avoids flaky mapping layers found in other editors.', + marks: [], + annotations: [] + } + }, + image_grid_item_5: { + id: 'image_grid_item_5', + type: 'image_grid_item', + image: '/images/cjk.svg', + title: { + content: 'Unicode‑safe, composition‑safe input', + marks: [], + annotations: [] + }, + description: { + content: 'Works correctly with emoji, diacritics, and CJK.', + marks: [], + annotations: [] + } + }, + image_grid_item_6: { + id: 'image_grid_item_6', + type: 'image_grid_item', + image: '/images/timetravel.svg', + title: { + content: 'Transactional editing with time travel', + marks: [], + annotations: [] + }, + description: { + content: 'Every change is safe and undoable.', + marks: [], + annotations: [] + } + }, + image_grid_1: { + id: 'image_grid_1', + type: 'image_grid', + layout: 1, + image_grid_items: { + nodes: [ + 'image_grid_item_1', + 'image_grid_item_2', + 'image_grid_item_3', + 'image_grid_item_4', + 'image_grid_item_5', + 'image_grid_item_6' + ], + marks: [], + annotations: [] + } + }, + story_3: { + id: 'story_3', + type: 'story', + layout: 1, + image: '/images/nested-blocks-illustration.svg', + title: { + content: 'Nested nodes', + marks: [], + annotations: [] + }, + description: { + content: + 'A node can embed a node_array of other nodes. For instance the list node at the bottom of the page has a node_array of list items.', + marks: [], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + story_4: { + id: 'story_4', + type: 'story', + layout: 2, + image: '/images/node-carets.svg', + title: { + content: 'Node carets', + marks: [], + annotations: [] + }, + description: { + content: + 'They work just like text carets, but instead of a character position in a string they address a node position in a node_array.\n\nTry it by selecting one of the gaps between the nodes. Then press ↵ to insert a new node or ⌫ to delete the node before the caret.', + marks: [], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + link_2: { + id: 'link_2', + type: 'link', + href: 'https://svelte.dev' + }, + emphasis_1: { + id: 'emphasis_1', + type: 'emphasis' + }, + story_5: { + id: 'story_5', + type: 'story', + layout: 1, + image: '/images/svelte-logo.svg', + title: { + content: 'Made for Svelte 5', + marks: [], + annotations: [] + }, + description: { + content: + 'Integrate with your Svelte application. Use it as a template and copy and paste Svedit.svelte to build your custom rich content editor.', + marks: [ + { + start_offset: 20, + end_offset: 26, + node_id: 'link_2' + }, + { + start_offset: 80, + end_offset: 93, + node_id: 'emphasis_1' + } + ], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + story_6: { + id: 'story_6', + type: 'story', + layout: 2, + image: '/images/extendable.svg', + title: { + content: 'Alpha version', + marks: [], + annotations: [] + }, + description: { + content: + "Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case.\n\nFind below a list of known issues we'll be working to get fixed next:", + marks: [], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + list_item_1: { + id: 'list_item_1', + type: 'list_item', + content: { + content: + "It's a bit hard to select whole lists or image grids with the mouse still. We're looking to improve this. However, by pressing the ESC key (or CMD+A) several times you can reach parent nodes easily.", + marks: [], + annotations: [] + } + }, + list_item_2: { + id: 'list_item_2', + type: 'list_item', + content: { + content: + 'Copy and pasting from and to external sources is working in principle, but is only capturing plain text so far.', + marks: [], + annotations: [] + } + }, + list_item_3: { + id: 'list_item_3', + type: 'list_item', + content: { + content: + 'Works best in Chrome, Safari 26+, and Firefox 157+, as Svedit uses CSS Anchor Positioning for overlays.', + marks: [], + annotations: [] + } + }, + list_item_4: { + id: 'list_item_4', + type: 'list_item', + content: { + content: + "Mobile support ist still experimental. As of 0.3.0 Svedit works on latest iOS and Android, but the UX isn't optimized yet.", + marks: [], + annotations: [] + } + }, + list_1: { + id: 'list_1', + type: 'list', + list_items: { + nodes: ['list_item_1', 'list_item_2', 'list_item_3', 'list_item_4'], + marks: [], + annotations: [] + }, + layout: 3 + }, + link_4: { + id: 'link_4', + type: 'link', + href: 'https://michaelaufreiter.com' + }, + link_5: { + id: 'link_5', + type: 'link', + href: 'https://mutter.co' + }, + VgWNyDmWcpgtkHvZXhjYTPS: { + id: 'VgWNyDmWcpgtkHvZXhjYTPS', + type: 'link', + href: 'https://github.com/michael/svedit/' + }, + story_7: { + id: 'story_7', + type: 'story', + layout: 1, + image: '/images/github.svg', + title: { + content: 'Find us on GitHub', + marks: [], + annotations: [] + }, + description: { + content: + 'Thank you for all the stars on GitHub. Svedit is made by Michael Aufreiter and Johannes Mutter and is licensed under the MIT License.', + marks: [ + { + start_offset: 57, + end_offset: 74, + node_id: 'link_4' + }, + { + start_offset: 79, + end_offset: 94, + node_id: 'link_5' + }, + { + start_offset: 31, + end_offset: 37, + node_id: 'VgWNyDmWcpgtkHvZXhjYTPS' + } + ], + annotations: [] + }, + buttons: { + nodes: [], + marks: [], + annotations: [] + } + }, + section_1: { + id: 'section_1', + type: 'section' + }, + page_1: { + id: 'page_1', + type: 'page', + body: { + nodes: [ + 'hero_1', + 'heading_1', + 'paragraph_1', + 'story_1', + 'story_2', + 'image_grid_1', + 'story_3', + 'story_4', + 'story_5', + 'story_6', + 'list_1', + 'story_7' + ], + marks: [ + { + start_offset: 0, + end_offset: 1, + node_id: 'section_1' + } + ], + annotations: [] + }, + keywords: ['svelte', 'editor', 'rich content'], + daily_visitors: [10, 20, 30, 100], + created_at: '2025-05-30T10:39:59.987Z' + } + } +}; + +// App-specific config object, always available via doc.config for introspection From 45c53e187d55680ad3c561e0b2deb29d10e1695b Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 19:11:58 +0200 Subject: [PATCH 03/10] Feature -> Package --- README.md | 18 ++--- src/lib/compose.js | 70 +++++++++---------- src/lib/types.d.ts | 12 ++-- src/routes/create_demo_session.js | 28 ++++---- src/routes/{features => packages}/core.js | 0 src/routes/{features => packages}/hero.js | 0 .../{features => packages}/image_grid.js | 0 src/routes/{features => packages}/list.js | 0 src/routes/{features => packages}/marker.js | 0 src/routes/{features => packages}/marks.js | 2 +- src/routes/{features => packages}/page.js | 4 +- src/routes/{features => packages}/story.js | 0 .../{features => packages}/text_blocks.js | 0 src/test/compose.svelte.test.js | 32 ++++----- 14 files changed, 83 insertions(+), 83 deletions(-) rename src/routes/{features => packages}/core.js (100%) rename src/routes/{features => packages}/hero.js (100%) rename src/routes/{features => packages}/image_grid.js (100%) rename src/routes/{features => packages}/list.js (100%) rename src/routes/{features => packages}/marker.js (100%) rename src/routes/{features => packages}/marks.js (95%) rename src/routes/{features => packages}/page.js (85%) rename src/routes/{features => packages}/story.js (100%) rename src/routes/{features => packages}/text_blocks.js (100%) diff --git a/README.md b/README.md index 0777c6ad..38d3b74f 100644 --- a/README.md +++ b/README.md @@ -226,10 +226,10 @@ Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes w ## Adding a new node type -Everything a node type needs can be grouped in one plain object — a feature definition — and merged into your session setup with `compose`. In the simplest case a new type is just a schema entry plus a component: +Everything a node type needs can be grouped in one plain object — a package — and merged into your session setup with `compose`. In the simplest case a new type is just a schema entry plus a component: ```js -// features/quote.js +// packages/quote.js import Quote from '../components/Quote.svelte'; export default { @@ -247,12 +247,12 @@ export default { }; ``` -Then compose your features 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 feature): +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 from './features/page.js'; -import quote from './features/quote.js'; +import page from './packages/page.js'; +import quote from './packages/quote.js'; const { schema, config } = compose([page, quote], { generate_id: nanoid @@ -263,16 +263,16 @@ 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 definition only when a new node needs more, e.g. seeded child nodes (see `list` and `story` in [`src/routes/features/`](src/routes/features/)). +- **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/routes/packages/`](src/routes/packages/)). - **HTML exporter** — `html_exporters: { quote: (node) => ... }` improves copy/paste to external apps; without one a generic exporter is used. -- **Commands and keymap** — a definition can contribute commands (as a factory receiving the editor context) and keybindings that reference commands by name: +- **Commands and keymap** — a package can contribute commands (as a factory receiving the editor context) and keybindings that reference commands by name: ```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 registry keys (components, inserters, exporters, and any app-specific registry like `node_layouts`) merge key-by-key and **collisions throw**, keymap entries for the same key concatenate in definition order, and command names are resolved across all definitions. There is no plugin runtime — after composition the result is indistinguishable from a hand-written flat config, and the flat style (as used in [`src/test/create_test_session.js`](src/test/create_test_session.js)) remains fully supported. +`compose` merges everything into one flat result: schema entries and registry keys (components, inserters, exporters, and any app-specific registry 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. 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: @@ -280,7 +280,7 @@ To catch omissions early, `Session` runs completeness checks in dev mode and log - 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 file in [`src/routes/features/`](src/routes/features/) is one self-contained feature, and [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) composes them. +The demo app is the reference for this setup style — each file in [`src/routes/packages/`](src/routes/packages/) is one self-contained package, and [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) composes them. ## Document diff --git a/src/lib/compose.js b/src/lib/compose.js index 59a9701a..02ccf7d5 100644 --- a/src/lib/compose.js +++ b/src/lib/compose.js @@ -1,40 +1,40 @@ /** - * Opt-in composition of per-feature definitions into a flat schema + config. + * Opt-in composition of packages into a flat schema + config. * - * A feature definition is a plain object that groups everything one feature - * needs (schema entries including sub-node types, components, inserters, - * exporters, commands, keymap contributions). `compose` merges definitions + * 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: - * - Definitions define node types; only the app wires containers + * - Packages define node types; only the app wires containers * (`node_types`, `default_node_type`, `mark_types`, `annotation_types`). * - Object-valued config keys (node_components, inserters, html_exporters, * and any app-specific registry like node_layouts) merge key-by-key; - * duplicate keys across definitions throw. + * duplicate keys across packages throw. * - `commands` factories are combined into a single * `create_commands_and_keymap`; `keymap` entries reference commands by - * name and concatenate when multiple definitions bind the same key. + * name and concatenate when multiple packages bind the same key. */ /** - * @import { FeatureDefinition } from './types' + * @import { Package } from './types' */ import { define_keymap } from './KeyMapper.svelte.js'; -/** Definition keys that are not plain config registries */ +/** Package keys that are not plain config registries */ const SPECIAL_KEYS = ['name', 'schema', 'commands', 'keymap']; /** - * @param {FeatureDefinition} definition + * @param {Package} pkg * @param {number} index - * @returns {string} Definition name for error messages + * @returns {string} Package name for error messages */ -function definition_name(definition, index) { - return definition.name ?? `definition #${index + 1}`; +function package_name(pkg, index) { + return pkg.name ?? `package #${index + 1}`; } /** @@ -42,8 +42,8 @@ function definition_name(definition, index) { * * @param {Record} target * @param {Record} source - * @param {Record} owners - Maps merged keys to their definition name - * @param {string} owner - Name of the definition being merged + * @param {Record} 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) { @@ -59,21 +59,21 @@ function merge_registry(target, source, owners, owner, registry) { } /** - * Compose feature definitions and app config into a flat schema and config. + * Compose packages and app config into a flat schema and config. * - * @param {FeatureDefinition[]} definitions - Feature definitions, merged in order + * @param {Package[]} packages - Packages, merged in order * @param {Record} [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, config: Record }} */ -export function compose(definitions, app_config = {}) { +export function compose(packages, app_config = {}) { /** @type {Record} */ const schema = {}; /** @type {Record} */ const config = {}; - // Track which definition contributed each key, per registry + // Track which package contributed each key, per registry /** @type {Record>} */ const owners = { schema: {} }; @@ -83,31 +83,31 @@ export function compose(definitions, app_config = {}) { const keymap_names = {}; const all_entries = [ - ...definitions.map((definition, index) => ({ - definition, - owner: definition_name(definition, index) + ...packages.map((pkg, index) => ({ + pkg, + owner: package_name(pkg, index) })), - { definition: app_config, owner: 'app config' } + { pkg: app_config, owner: 'app config' } ]; - for (const { definition, owner } of all_entries) { - if (definition.schema) { - merge_registry(schema, definition.schema, owners.schema, owner, 'schema'); + for (const { pkg, owner } of all_entries) { + if (pkg.schema) { + merge_registry(schema, pkg.schema, owners.schema, owner, 'schema'); } - if (definition.commands) { - command_factories.push({ owner, factory: definition.commands }); + if (pkg.commands) { + command_factories.push({ owner, factory: pkg.commands }); } - if (definition.keymap) { - for (const [key_combo, command_names] of Object.entries(definition.keymap)) { - // Same key from multiple definitions concatenates in order, + 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(definition)) { + for (const [key, value] of Object.entries(pkg)) { if (SPECIAL_KEYS.includes(key)) continue; if (value && typeof value === 'object' && !Array.isArray(value)) { @@ -118,8 +118,8 @@ export function compose(definitions, app_config = {}) { merge_registry(config[key], value, owners[key], owner, `config.${key}`); } else { // Scalar/function/array config (generate_id, view_classes, - // handle_media_paste, ...): app config wins, definitions may - // not compete for the same key. + // handle_media_paste, ...): app config wins, packages may not + // compete for the same key. if (key in config && owner !== 'app config') { throw new Error(`compose: config key '${key}' from ${owner} is already set.`); } @@ -131,7 +131,7 @@ export function compose(definitions, app_config = {}) { 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 definitions OR provide create_commands_and_keymap, not both.' + 'compose: pass commands/keymap through packages OR provide create_commands_and_keymap, not both.' ); } config.create_commands_and_keymap = (context) => { diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index c29bc24f..1db7ef31 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -340,16 +340,16 @@ export type NodeSchema = TextNodeSchema | NonTextNodeSchema; export type DocumentSchema = Record; /** - * A feature definition groups everything one feature needs (schema entries - * including sub-node types, components, inserters, exporters, commands, - * keymap contributions). Definitions are merged into a flat schema + config - * via `compose`. Any additional object-valued key (e.g. `node_layouts`) is + * A package groups everything one concern needs (schema entries including + * sub-node types, components, inserters, exporters, commands, keymap + * contributions). Packages are merged into a flat schema + config via + * `compose`. Any additional object-valued key (e.g. `node_layouts`) is * merged as an app-specific registry. */ -export type FeatureDefinition = { +export type Package = { /** Optional name used in compose error messages */ name?: string; - /** Schema entries contributed by this feature (loosely typed so plain + /** Schema entries contributed by this package (loosely typed so plain * object literals don't require const assertions; the merged result is * validated at Session construction) */ schema?: Record; diff --git a/src/routes/create_demo_session.js b/src/routes/create_demo_session.js index 3f629c83..40e39096 100644 --- a/src/routes/create_demo_session.js +++ b/src/routes/create_demo_session.js @@ -5,20 +5,20 @@ import doc from './demo_doc.js'; // System components import Overlays from './components/Overlays.svelte'; -// Feature definitions: each groups everything one feature needs (schema -// entries incl. sub-node types, components, inserters, exporters, commands, -// keymaps). `compose` merges them into the flat schema + config a Session -// expects. Container wiring (which node types the page body accepts) lives in -// the page feature — features define node types, the app composes them. -import page from './features/page.js'; -import text_blocks from './features/text_blocks.js'; -import marks from './features/marks.js'; -import marker from './features/marker.js'; -import story from './features/story.js'; -import list from './features/list.js'; -import image_grid from './features/image_grid.js'; -import hero from './features/hero.js'; -import core from './features/core.js'; +// Packages: each groups everything one concern needs (schema entries incl. +// sub-node types, components, inserters, exporters, commands, keymaps). +// `compose` merges them into the flat schema + config a Session expects. +// Container wiring (which node types the page body accepts) lives in the +// page package — packages define node types, the app composes them. +import page from './packages/page.js'; +import text_blocks from './packages/text_blocks.js'; +import marks from './packages/marks.js'; +import marker from './packages/marker.js'; +import story from './packages/story.js'; +import list from './packages/list.js'; +import image_grid from './packages/image_grid.js'; +import hero from './packages/hero.js'; +import core from './packages/core.js'; const { schema, config } = compose( [page, text_blocks, marks, marker, story, list, image_grid, hero, core], diff --git a/src/routes/features/core.js b/src/routes/packages/core.js similarity index 100% rename from src/routes/features/core.js rename to src/routes/packages/core.js diff --git a/src/routes/features/hero.js b/src/routes/packages/hero.js similarity index 100% rename from src/routes/features/hero.js rename to src/routes/packages/hero.js diff --git a/src/routes/features/image_grid.js b/src/routes/packages/image_grid.js similarity index 100% rename from src/routes/features/image_grid.js rename to src/routes/packages/image_grid.js diff --git a/src/routes/features/list.js b/src/routes/packages/list.js similarity index 100% rename from src/routes/features/list.js rename to src/routes/packages/list.js diff --git a/src/routes/features/marker.js b/src/routes/packages/marker.js similarity index 100% rename from src/routes/features/marker.js rename to src/routes/packages/marker.js diff --git a/src/routes/features/marks.js b/src/routes/packages/marks.js similarity index 95% rename from src/routes/features/marks.js rename to src/routes/packages/marks.js index 38ac9f49..091ffeec 100644 --- a/src/routes/features/marks.js +++ b/src/routes/packages/marks.js @@ -7,7 +7,7 @@ import Highlight from '../components/Highlight.svelte'; import Link from '../components/Link.svelte'; import Section from '../components/Section.svelte'; -// Mark bundles other features reference from their text properties +// Mark bundles other packages reference from their text properties export const ALL_MARKS = ['strong', 'emphasis', 'highlight', 'link']; export const TITLE_MARKS = ['emphasis', 'highlight']; diff --git a/src/routes/features/page.js b/src/routes/packages/page.js similarity index 85% rename from src/routes/features/page.js rename to src/routes/packages/page.js index cb2fd3ba..fe54bb50 100644 --- a/src/routes/features/page.js +++ b/src/routes/packages/page.js @@ -1,8 +1,8 @@ import Page from '../components/Page.svelte'; -// The page feature owns all container wiring: which node types are allowed in +// The page package owns all container wiring: which node types are allowed in // the body, which marks/annotations may wrap body ranges, and what gets -// inserted by default. Features define node types; the app composes them. +// inserted by default. Packages define node types; the app composes them. export default { name: 'page', schema: { diff --git a/src/routes/features/story.js b/src/routes/packages/story.js similarity index 100% rename from src/routes/features/story.js rename to src/routes/packages/story.js diff --git a/src/routes/features/text_blocks.js b/src/routes/packages/text_blocks.js similarity index 100% rename from src/routes/features/text_blocks.js rename to src/routes/packages/text_blocks.js diff --git a/src/test/compose.svelte.test.js b/src/test/compose.svelte.test.js index 2853611c..4edad9fa 100644 --- a/src/test/compose.svelte.test.js +++ b/src/test/compose.svelte.test.js @@ -10,7 +10,7 @@ class NoopCommand extends Command { execute() {} } -const page_definition = { +const page_package = { name: 'page', schema: { page: { @@ -27,7 +27,7 @@ const page_definition = { node_components: { page: Page } }; -const paragraph_definition = { +const paragraph_package = { name: 'paragraph', schema: { paragraph: { @@ -43,7 +43,7 @@ const paragraph_definition = { describe('compose', () => { it('merges schema and object-valued config registries', () => { - const { schema, config } = compose([page_definition, paragraph_definition], { + const { schema, config } = compose([page_package, paragraph_package], { generate_id: () => 'id_1', view_classes: false }); @@ -58,48 +58,48 @@ describe('compose', () => { expect(config.view_classes).toBe(false); }); - it('throws on duplicate schema keys across definitions', () => { - expect(() => compose([paragraph_definition, paragraph_definition])).toThrow( + it('throws on duplicate schema keys across packages', () => { + expect(() => compose([paragraph_package, paragraph_package])).toThrow( "schema key 'paragraph' from paragraph conflicts with paragraph" ); }); - it('throws on duplicate registry keys and names the definitions', () => { + it('throws on duplicate registry keys and names the packages', () => { const other = { name: 'other', node_components: { paragraph: Page } }; - expect(() => compose([paragraph_definition, other])).toThrow( + expect(() => compose([paragraph_package, other])).toThrow( "config.node_components key 'paragraph' from other conflicts with paragraph" ); }); - it('throws when two definitions set the same scalar config key', () => { + it('throws when two packages set the same scalar config key', () => { const a = { name: 'a', view_classes: true }; const b = { name: 'b', view_classes: false }; expect(() => compose([a, b])).toThrow("config key 'view_classes' from b is already set"); }); - it('lets app config override scalar keys from definitions', () => { + it('lets app config override scalar keys from packages', () => { const a = { name: 'a', view_classes: true }; const { config } = compose([a], { view_classes: false }); expect(config.view_classes).toBe(false); }); it('builds create_commands_and_keymap from command factories and name-based keymaps', () => { - const feature_a = { + const package_a = { name: 'a', commands: (context) => ({ command_a: new NoopCommand(context) }), keymap: { 'meta+j,ctrl+j': ['command_a'], enter: ['command_a'] } }; - const feature_b = { + const package_b = { name: 'b', commands: (context) => ({ command_b: new NoopCommand(context) }), - // Same key from multiple definitions concatenates in order + // Same key from multiple packages concatenates in order keymap: { enter: ['command_b'] } }; - const { config } = compose([feature_a, feature_b]); + const { config } = compose([package_a, package_b]); const { commands, keymap } = config.create_commands_and_keymap({ session: null }); expect(Object.keys(commands)).toEqual(['command_a', 'command_b']); @@ -107,7 +107,7 @@ describe('compose', () => { expect(keymap['enter']).toEqual([commands.command_a, commands.command_b]); }); - it('throws on duplicate command names across definitions', () => { + it('throws on duplicate command names across packages', () => { const a = { name: 'a', commands: (context) => ({ go: new NoopCommand(context) }) }; const b = { name: 'b', commands: (context) => ({ go: new NoopCommand(context) }) }; const { config } = compose([a, b]); @@ -124,7 +124,7 @@ describe('compose', () => { ); }); - it('rejects mixing definition commands with a custom create_commands_and_keymap', () => { + it('rejects mixing package commands with a custom create_commands_and_keymap', () => { const a = { name: 'a', commands: (context) => ({ go: new NoopCommand(context) }) }; expect(() => compose([a], { create_commands_and_keymap: () => ({}) })).toThrow( 'not both' @@ -132,7 +132,7 @@ describe('compose', () => { }); it('produces a config a Session can be constructed from', () => { - const { schema, config } = compose([page_definition, paragraph_definition], { + const { schema, config } = compose([page_package, paragraph_package], { generate_id: () => `id_${Math.random().toString(36).slice(2)}` }); From b90507904d8fd966a7e1c44e061428be9eeebf2f Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 19:32:26 +0200 Subject: [PATCH 04/10] Make packages more explicit --- README.md | 4 +- src/lib/compose.js | 69 ++++++++++++++++++++------------- src/lib/types.d.ts | 13 ++++--- src/test/compose.svelte.test.js | 31 ++++++++++----- 4 files changed, 73 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 38d3b74f..8eeda8ef 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes w ## 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`. In the simplest case a new type is just a schema entry plus a component: +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 plus a component: ```js // packages/quote.js @@ -272,7 +272,7 @@ 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 registry keys (components, inserters, exporters, and any app-specific registry 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. 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. +`compose` merges everything into one flat result: schema entries and known registry keys (`node_components`, `system_components`, `inserters`, `html_exporters`, `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 package keys throw, while app config passed as the second argument remains flexible. 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: diff --git a/src/lib/compose.js b/src/lib/compose.js index 02ccf7d5..a91c8b5a 100644 --- a/src/lib/compose.js +++ b/src/lib/compose.js @@ -9,11 +9,12 @@ * 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`). - * - Object-valued config keys (node_components, inserters, html_exporters, - * and any app-specific registry like node_layouts) merge key-by-key; - * duplicate keys across packages throw. + * - Known registry keys (node_components, system_components, inserters, + * html_exporters, node_layouts) merge key-by-key; duplicate keys across + * packages throw. * - `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. @@ -26,7 +27,14 @@ import { define_keymap } from './KeyMapper.svelte.js'; /** Package keys that are not plain config registries */ -const SPECIAL_KEYS = ['name', 'schema', 'commands', 'keymap']; +const REGISTRY_KEYS = [ + 'node_components', + 'system_components', + 'inserters', + 'html_exporters', + 'node_layouts' +]; +const PACKAGE_KEYS = ['name', 'schema', 'commands', 'keymap', ...REGISTRY_KEYS]; /** * @param {Package} pkg @@ -34,7 +42,10 @@ const SPECIAL_KEYS = ['name', 'schema', 'commands', 'keymap']; * @returns {string} Package name for error messages */ function package_name(pkg, index) { - return pkg.name ?? `package #${index + 1}`; + 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; } /** @@ -81,16 +92,21 @@ export function compose(packages, app_config = {}) { const command_factories = []; /** @type {Record} */ 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); - const all_entries = [ - ...packages.map((pkg, index) => ({ - pkg, - owner: package_name(pkg, index) - })), - { pkg: app_config, owner: 'app config' } - ]; + for (const key of Object.keys(pkg)) { + if (!PACKAGE_KEYS.includes(key)) { + throw new Error(`compose: unknown package key '${key}' in ${owner}.`); + } + } - for (const { pkg, owner } of all_entries) { if (pkg.schema) { merge_registry(schema, pkg.schema, owners.schema, owner, 'schema'); } @@ -107,27 +123,26 @@ export function compose(packages, app_config = {}) { } } - for (const [key, value] of Object.entries(pkg)) { - if (SPECIAL_KEYS.includes(key)) continue; - - if (value && typeof value === 'object' && !Array.isArray(value)) { - // Registry-style config (node_components, inserters, - // html_exporters, node_layouts, ...): merge key-by-key. + for (const key of REGISTRY_KEYS) { + const value = pkg[key]; + if (value) { config[key] ??= {}; owners[key] ??= {}; merge_registry(config[key], value, owners[key], owner, `config.${key}`); - } else { - // Scalar/function/array config (generate_id, view_classes, - // handle_media_paste, ...): app config wins, packages may not - // compete for the same key. - if (key in config && owner !== 'app config') { - throw new Error(`compose: config key '${key}' from ${owner} is already set.`); - } - config[key] = value; } } } + for (const [key, value] of Object.entries(app_config)) { + if (value && typeof value === 'object' && !Array.isArray(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( diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 1db7ef31..2b395d9d 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -343,18 +343,19 @@ export type DocumentSchema = Record; * A package groups everything one concern needs (schema entries including * sub-node types, components, inserters, exporters, commands, keymap * contributions). Packages are merged into a flat schema + config via - * `compose`. Any additional object-valued key (e.g. `node_layouts`) is - * merged as an app-specific registry. + * `compose`. */ export type Package = { - /** Optional name used in compose error messages */ - name?: string; + /** Unique package name used in compose diagnostics */ + name: string; /** Schema entries contributed by this package (loosely typed so plain * object literals don't require const assertions; the merged result is * validated at Session construction) */ schema?: Record; /** Components per node type, merged into config.node_components */ node_components?: Record; + /** System components, merged into config.system_components */ + system_components?: Record; /** Custom inserters per node type, merged into config.inserters */ inserters?: Record void>; /** HTML exporters per node type, merged into config.html_exporters */ @@ -363,8 +364,8 @@ export type Package = { commands?: (context: any) => Record; /** Key combos mapped to arrays of command names */ keymap?: Record; - /** Additional object-valued keys merge as registries; other values are set directly */ - [key: string]: any; + /** Layout ids per node type, merged into config.node_layouts */ + node_layouts?: Record; }; /** diff --git a/src/test/compose.svelte.test.js b/src/test/compose.svelte.test.js index 4edad9fa..3d885f34 100644 --- a/src/test/compose.svelte.test.js +++ b/src/test/compose.svelte.test.js @@ -59,8 +59,10 @@ describe('compose', () => { }); it('throws on duplicate schema keys across packages', () => { - expect(() => compose([paragraph_package, paragraph_package])).toThrow( - "schema key 'paragraph' from paragraph conflicts with paragraph" + expect(() => + compose([paragraph_package, { ...paragraph_package, name: 'other_paragraph' }]) + ).toThrow( + "schema key 'paragraph' from other_paragraph conflicts with paragraph" ); }); @@ -74,15 +76,26 @@ describe('compose', () => { ); }); - it('throws when two packages set the same scalar config key', () => { - const a = { name: 'a', view_classes: true }; - const b = { name: 'b', view_classes: false }; - expect(() => compose([a, b])).toThrow("config key 'view_classes' from b is already set"); + it('throws when a package has no name', () => { + expect(() => compose([/** @type {any} */ ({ schema: {} })])).toThrow( + 'package #1 must have a non-empty name' + ); + }); + + it('throws when two packages use the same name', () => { + expect(() => compose([{ name: 'a' }, { name: 'a' }])).toThrow( + "duplicate package name 'a'" + ); + }); + + it('throws on unknown package keys', () => { + expect(() => compose([/** @type {any} */ ({ name: 'a', view_classes: true })])).toThrow( + "unknown package key 'view_classes' in a" + ); }); - it('lets app config override scalar keys from packages', () => { - const a = { name: 'a', view_classes: true }; - const { config } = compose([a], { view_classes: false }); + it('lets app config provide scalar keys', () => { + const { config } = compose([{ name: 'a' }], { view_classes: false }); expect(config.view_classes).toBe(false); }); From 85f7f10ba526a052e53d864075980059a3aafc79 Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 19:50:57 +0200 Subject: [PATCH 05/10] Structure all code in packages --- README.md | 10 +++--- src/routes/create_demo_session.js | 18 +++++----- .../packages/{core.js => core/package.js} | 2 +- .../{components => packages/hero}/Hero.svelte | 0 .../packages/{hero.js => hero/package.js} | 4 +-- .../image_grid}/ImageGrid.svelte | 0 .../image_grid}/ImageGridItem.svelte | 0 .../{image_grid.js => image_grid/package.js} | 6 ++-- .../{components => packages/list}/List.svelte | 0 .../list}/ListItem.svelte | 0 .../packages/{list.js => list/package.js} | 6 ++-- .../packages/{marker.js => marker/package.js} | 0 .../marks}/Emphasis.svelte | 0 .../marks}/Highlight.svelte | 2 +- .../marks}/Link.svelte | 2 +- .../marks}/Section.svelte | 0 .../marks}/Strong.svelte | 0 .../packages/{marks.js => marks/package.js} | 12 +++---- .../{components => packages/page}/Page.svelte | 0 .../packages/{page.js => page/package.js} | 2 +- .../story}/Button.svelte | 0 .../story}/Story.svelte | 0 .../packages/{story.js => story/package.js} | 24 +++++++++++-- .../text_blocks}/Heading1.svelte | 0 .../text_blocks}/Heading2.svelte | 0 .../text_blocks}/Heading3.svelte | 0 .../text_blocks}/Paragraph.svelte | 0 .../package.js} | 10 +++--- src/test/compose.svelte.test.js | 4 +-- src/test/create_test_session.js | 34 +++++++++++++------ 30 files changed, 84 insertions(+), 52 deletions(-) rename src/routes/packages/{core.js => core/package.js} (93%) rename src/routes/{components => packages/hero}/Hero.svelte (100%) rename src/routes/packages/{hero.js => hero/package.js} (89%) rename src/routes/{components => packages/image_grid}/ImageGrid.svelte (100%) rename src/routes/{components => packages/image_grid}/ImageGridItem.svelte (100%) rename src/routes/packages/{image_grid.js => image_grid/package.js} (92%) rename src/routes/{components => packages/list}/List.svelte (100%) rename src/routes/{components => packages/list}/ListItem.svelte (100%) rename src/routes/packages/{list.js => list/package.js} (91%) rename src/routes/packages/{marker.js => marker/package.js} (100%) rename src/routes/{components => packages/marks}/Emphasis.svelte (100%) rename src/routes/{components => packages/marks}/Highlight.svelte (86%) rename src/routes/{components => packages/marks}/Link.svelte (85%) rename src/routes/{components => packages/marks}/Section.svelte (100%) rename src/routes/{components => packages/marks}/Strong.svelte (100%) rename src/routes/packages/{marks.js => marks/package.js} (79%) rename src/routes/{components => packages/page}/Page.svelte (100%) rename src/routes/packages/{page.js => page/package.js} (94%) rename src/routes/{components => packages/story}/Button.svelte (100%) rename src/routes/{components => packages/story}/Story.svelte (100%) rename src/routes/packages/{story.js => story/package.js} (72%) rename src/routes/{components => packages/text_blocks}/Heading1.svelte (100%) rename src/routes/{components => packages/text_blocks}/Heading2.svelte (100%) rename src/routes/{components => packages/text_blocks}/Heading3.svelte (100%) rename src/routes/{components => packages/text_blocks}/Paragraph.svelte (100%) rename src/routes/packages/{text_blocks.js => text_blocks/package.js} (85%) diff --git a/README.md b/README.md index 8eeda8ef..2e6376a2 100644 --- a/README.md +++ b/README.md @@ -229,8 +229,8 @@ Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes w 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 plus a component: ```js -// packages/quote.js -import Quote from '../components/Quote.svelte'; +// packages/quote/package.js +import Quote from './Quote.svelte'; export default { name: 'quote', @@ -251,8 +251,8 @@ Then compose your packages into the flat schema + config a `Session` expects, an ```js import { compose, Session } from 'svedit'; -import page from './packages/page.js'; -import quote from './packages/quote.js'; +import page from './packages/page/package.js'; +import quote from './packages/quote/package.js'; const { schema, config } = compose([page, quote], { generate_id: nanoid @@ -280,7 +280,7 @@ To catch omissions early, `Session` runs completeness checks in dev mode and log - 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 file in [`src/routes/packages/`](src/routes/packages/) is one self-contained package, and [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) composes them. +The demo app is the reference for this setup style — each folder in [`src/routes/packages/`](src/routes/packages/) is one self-contained package, and [`src/routes/create_demo_session.js`](src/routes/create_demo_session.js) composes them. ## Document diff --git a/src/routes/create_demo_session.js b/src/routes/create_demo_session.js index 40e39096..86316cbd 100644 --- a/src/routes/create_demo_session.js +++ b/src/routes/create_demo_session.js @@ -10,15 +10,15 @@ import Overlays from './components/Overlays.svelte'; // `compose` merges them into the flat schema + config a Session expects. // Container wiring (which node types the page body accepts) lives in the // page package — packages define node types, the app composes them. -import page from './packages/page.js'; -import text_blocks from './packages/text_blocks.js'; -import marks from './packages/marks.js'; -import marker from './packages/marker.js'; -import story from './packages/story.js'; -import list from './packages/list.js'; -import image_grid from './packages/image_grid.js'; -import hero from './packages/hero.js'; -import core from './packages/core.js'; +import page from './packages/page/package.js'; +import text_blocks from './packages/text_blocks/package.js'; +import marks from './packages/marks/package.js'; +import marker from './packages/marker/package.js'; +import story from './packages/story/package.js'; +import list from './packages/list/package.js'; +import image_grid from './packages/image_grid/package.js'; +import hero from './packages/hero/package.js'; +import core from './packages/core/package.js'; const { schema, config } = compose( [page, text_blocks, marks, marker, story, list, image_grid, hero, core], diff --git a/src/routes/packages/core.js b/src/routes/packages/core/package.js similarity index 93% rename from src/routes/packages/core.js rename to src/routes/packages/core/package.js index 823e72ac..78f1c500 100644 --- a/src/routes/packages/core.js +++ b/src/routes/packages/core/package.js @@ -7,7 +7,7 @@ import { RedoCommand, SelectParentCommand } from 'svedit'; -import { CycleLayoutCommand, CycleNodeTypeCommand } from '../commands.svelte.js'; +import { CycleLayoutCommand, CycleNodeTypeCommand } from '../../commands.svelte.js'; // App-level editing commands and their keybindings, independent of any // specific node type. diff --git a/src/routes/components/Hero.svelte b/src/routes/packages/hero/Hero.svelte similarity index 100% rename from src/routes/components/Hero.svelte rename to src/routes/packages/hero/Hero.svelte diff --git a/src/routes/packages/hero.js b/src/routes/packages/hero/package.js similarity index 89% rename from src/routes/packages/hero.js rename to src/routes/packages/hero/package.js index 13907275..8918e6d2 100644 --- a/src/routes/packages/hero.js +++ b/src/routes/packages/hero/package.js @@ -1,5 +1,5 @@ -import Hero from '../components/Hero.svelte'; -import { ALL_MARKS, TITLE_MARKS } from './marks.js'; +import Hero from './Hero.svelte'; +import { ALL_MARKS, TITLE_MARKS } from '../marks/package.js'; // The hero needs no inserter: the generic schema-driven inserter creates it // from schema defaults. diff --git a/src/routes/components/ImageGrid.svelte b/src/routes/packages/image_grid/ImageGrid.svelte similarity index 100% rename from src/routes/components/ImageGrid.svelte rename to src/routes/packages/image_grid/ImageGrid.svelte diff --git a/src/routes/components/ImageGridItem.svelte b/src/routes/packages/image_grid/ImageGridItem.svelte similarity index 100% rename from src/routes/components/ImageGridItem.svelte rename to src/routes/packages/image_grid/ImageGridItem.svelte diff --git a/src/routes/packages/image_grid.js b/src/routes/packages/image_grid/package.js similarity index 92% rename from src/routes/packages/image_grid.js rename to src/routes/packages/image_grid/package.js index d78249c1..f0caf88c 100644 --- a/src/routes/packages/image_grid.js +++ b/src/routes/packages/image_grid/package.js @@ -1,6 +1,6 @@ -import ImageGrid from '../components/ImageGrid.svelte'; -import ImageGridItem from '../components/ImageGridItem.svelte'; -import { ALL_MARKS, TITLE_MARKS } from './marks.js'; +import ImageGrid from './ImageGrid.svelte'; +import ImageGridItem from './ImageGridItem.svelte'; +import { ALL_MARKS, TITLE_MARKS } from '../marks/package.js'; export default { name: 'image_grid', diff --git a/src/routes/components/List.svelte b/src/routes/packages/list/List.svelte similarity index 100% rename from src/routes/components/List.svelte rename to src/routes/packages/list/List.svelte diff --git a/src/routes/components/ListItem.svelte b/src/routes/packages/list/ListItem.svelte similarity index 100% rename from src/routes/components/ListItem.svelte rename to src/routes/packages/list/ListItem.svelte diff --git a/src/routes/packages/list.js b/src/routes/packages/list/package.js similarity index 91% rename from src/routes/packages/list.js rename to src/routes/packages/list/package.js index e6593c71..0d939f63 100644 --- a/src/routes/packages/list.js +++ b/src/routes/packages/list/package.js @@ -1,6 +1,6 @@ -import List from '../components/List.svelte'; -import ListItem from '../components/ListItem.svelte'; -import { ALL_MARKS } from './marks.js'; +import List from './List.svelte'; +import ListItem from './ListItem.svelte'; +import { ALL_MARKS } from '../marks/package.js'; export default { name: 'list', diff --git a/src/routes/packages/marker.js b/src/routes/packages/marker/package.js similarity index 100% rename from src/routes/packages/marker.js rename to src/routes/packages/marker/package.js diff --git a/src/routes/components/Emphasis.svelte b/src/routes/packages/marks/Emphasis.svelte similarity index 100% rename from src/routes/components/Emphasis.svelte rename to src/routes/packages/marks/Emphasis.svelte diff --git a/src/routes/components/Highlight.svelte b/src/routes/packages/marks/Highlight.svelte similarity index 86% rename from src/routes/components/Highlight.svelte rename to src/routes/packages/marks/Highlight.svelte index 93552b63..3cef85e5 100644 --- a/src/routes/components/Highlight.svelte +++ b/src/routes/packages/marks/Highlight.svelte @@ -1,6 +1,6 @@ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a10f5580..049d7ec5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,7 @@ diff --git a/src/test/testing_components/SveditTestWithKeymap.svelte b/src/test/testing_components/SveditTestWithKeymap.svelte index f04946cb..48de4bf9 100644 --- a/src/test/testing_components/SveditTestWithKeymap.svelte +++ b/src/test/testing_components/SveditTestWithKeymap.svelte @@ -2,7 +2,7 @@ import { setContext } from 'svelte'; import Svedit from '../../lib/Svedit.svelte'; import { KeyMapper } from '../../lib/index.js'; - import Layout from '../../routes/components/Layout.svelte'; + import Layout from '../../routes/Layout.svelte'; let { session } = $props(); From 73e1aa15725506fbb33e3066e6169df972f016f3 Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 20:16:53 +0200 Subject: [PATCH 08/10] Fix relative paths --- src/routes/Layout.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/Layout.svelte b/src/routes/Layout.svelte index 6998399c..fdf5a2b1 100644 --- a/src/routes/Layout.svelte +++ b/src/routes/Layout.svelte @@ -1,10 +1,10 @@ From aeee1ffef0f6b4ad0d3159bbfa878dab5ee53248 Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 20:22:11 +0200 Subject: [PATCH 09/10] Document node_layouts --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3e2bb6ba..f9abae8b 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes w ## 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 plus a component: +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, a component, and its layout count: ```js // src/packages/quote/package.js @@ -243,7 +243,8 @@ export default { } } }, - node_components: { quote: Quote } + node_components: { quote: Quote }, + node_layouts: { quote: 1 } }; ``` From 93a89518f9283fe48d8708829611ea3c4c8f285c Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Mon, 6 Jul 2026 20:31:30 +0200 Subject: [PATCH 10/10] Improve package API --- README.md | 6 +++-- src/lib/compose.js | 42 ++++++++++++++++----------------- src/lib/types.d.ts | 4 ++-- src/test/compose.svelte.test.js | 24 ++++++++++++++++++- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f9abae8b..cfb77132 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ Mark types are defined as nodes with `kind: 'mark'`, annotation types as nodes w ## 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, a component, and its layout count: +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 @@ -244,6 +244,7 @@ export default { } }, node_components: { quote: Quote }, + // Demo-app metadata; Svedit itself does not know about layouts. node_layouts: { quote: 1 } }; ``` @@ -267,13 +268,14 @@ That's usually all. Everything else is optional and only needed when the default - **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 known registry keys (`node_components`, `system_components`, `inserters`, `html_exporters`, `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 package keys throw, while app config passed as the second argument remains flexible. 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. +`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: diff --git a/src/lib/compose.js b/src/lib/compose.js index a91c8b5a..fb80baf6 100644 --- a/src/lib/compose.js +++ b/src/lib/compose.js @@ -12,9 +12,10 @@ * - 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 registry keys (node_components, system_components, inserters, - * html_exporters, node_layouts) merge key-by-key; duplicate keys across - * packages throw. + * - 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. @@ -27,14 +28,7 @@ import { define_keymap } from './KeyMapper.svelte.js'; /** Package keys that are not plain config registries */ -const REGISTRY_KEYS = [ - 'node_components', - 'system_components', - 'inserters', - 'html_exporters', - 'node_layouts' -]; -const PACKAGE_KEYS = ['name', 'schema', 'commands', 'keymap', ...REGISTRY_KEYS]; +const SPECIAL_KEYS = ['name', 'schema', 'commands', 'keymap']; /** * @param {Package} pkg @@ -69,6 +63,10 @@ function merge_registry(target, source, owners, owner, registry) { } } +function is_plain_object(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + /** * Compose packages and app config into a flat schema and config. * @@ -102,8 +100,12 @@ export function compose(packages, app_config = {}) { package_names.add(owner); for (const key of Object.keys(pkg)) { - if (!PACKAGE_KEYS.includes(key)) { - throw new Error(`compose: unknown package key '${key}' in ${owner}.`); + 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.` + ); } } @@ -123,18 +125,16 @@ export function compose(packages, app_config = {}) { } } - for (const key of REGISTRY_KEYS) { - const value = pkg[key]; - if (value) { - config[key] ??= {}; - owners[key] ??= {}; - merge_registry(config[key], value, owners[key], owner, `config.${key}`); - } + 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 (value && typeof value === 'object' && !Array.isArray(value)) { + if (is_plain_object(value)) { config[key] ??= {}; owners[key] ??= {}; merge_registry(config[key], value, owners[key], 'app config', `config.${key}`); diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index 2b395d9d..0a88ed32 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -364,8 +364,8 @@ export type Package = { commands?: (context: any) => Record; /** Key combos mapped to arrays of command names */ keymap?: Record; - /** Layout ids per node type, merged into config.node_layouts */ - node_layouts?: Record; + /** Custom app-owned registries, merged into config by key */ + [key: string]: any; }; /** diff --git a/src/test/compose.svelte.test.js b/src/test/compose.svelte.test.js index dbac8114..1065db51 100644 --- a/src/test/compose.svelte.test.js +++ b/src/test/compose.svelte.test.js @@ -51,13 +51,35 @@ describe('compose', () => { expect(Object.keys(schema)).toEqual(['page', 'paragraph']); expect(config.node_components.page).toBe(Page); expect(config.node_components.paragraph).toBe(Paragraph); - // Unknown app-specific registries merge too + // App-specific package registries merge like Svedit registries expect(config.node_layouts).toEqual({ paragraph: 1 }); // App-level scalars are set directly expect(config.generate_id()).toBe('id_1'); expect(config.view_classes).toBe(false); }); + it('merges custom package registries and app config registries', () => { + const { config } = compose( + [ + { name: 'a', node_layouts: { paragraph: 1 } }, + { name: 'b', node_layouts: { story: 3 } } + ], + { toolbar_groups: { text: ['paragraph'] } } + ); + + expect(config.node_layouts).toEqual({ paragraph: 1, story: 3 }); + expect(config.toolbar_groups).toEqual({ text: ['paragraph'] }); + }); + + it('throws on duplicate custom registry keys', () => { + expect(() => + compose([ + { name: 'a', node_layouts: { paragraph: 1 } }, + { name: 'b', node_layouts: { paragraph: 2 } } + ]) + ).toThrow("config.node_layouts key 'paragraph' from b conflicts with a"); + }); + it('throws on duplicate schema keys across packages', () => { expect(() => compose([paragraph_package, { ...paragraph_package, name: 'other_paragraph' }])