@input/pen-autoformat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Input B.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # `@input/pen-autoformat`
2
+
3
+ Opt-in markdown autoformat for Pen.
4
+
5
+ This package is intentionally **not** included in `defaultPreset()`. Library users enable it only when they want markdown-style typing shortcuts. It does not ship a renderer or a keymap.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @input/pen @input/pen-autoformat
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createEditor } from "@input/pen";
17
+ import { autoformatExtension } from "@input/pen-autoformat";
18
+
19
+ const editor = createEditor({
20
+ extensions: [autoformatExtension()],
21
+ });
22
+ ```
23
+
24
+ Without the extension:
25
+
26
+ ```ts
27
+ import { createEditor } from "@input/pen";
28
+
29
+ const editor = createEditor();
30
+ ```
31
+
32
+ markdown autoformat remains disabled.
33
+
34
+ ## What it adds
35
+
36
+ Block shortcuts:
37
+
38
+ - `# ` through `###### ` -> headings
39
+ - `- `, `* `, `+ ` -> bullet list
40
+ - `1. ` -> numbered list
41
+ - `[ ] `, `[x] ` -> checklist
42
+ - `> ` -> blockquote
43
+ - ` ``` ` -> code block
44
+ - `---`, `***`, `___` -> divider
45
+ - `> [!note] ` -> callout
46
+
47
+ Inline shortcuts:
48
+
49
+ - `**text**` -> bold
50
+ - `*text*` -> italic
51
+ - `` `text` `` -> code
52
+ - `~~text~~` -> strikethrough
53
+ - `==text==` -> highlight
54
+
55
+ ## Notes
56
+
57
+ - This package is headless and renderer-agnostic.
58
+ - `@input/pen-react` keeps a small fallback list-input convenience path, but full markdown autoformat lives here.
59
+ - If you want custom rules, pass them through `autoformatExtension({ rules, inlineRules })`.
60
+
61
+ ## Options
62
+
63
+ | Option | Default | Effect |
64
+ | --------------------------- | ------- | ------------------------------ |
65
+ | `disableDefaults` | `false` | Skip the built-in block rules |
66
+ | `disableDefaultInlineRules` | `false` | Skip the built-in inline rules |
67
+ | `rules` | unset | Extra block rules |
68
+ | `inlineRules` | unset | Extra inline rules |
69
+
70
+ ## Facets and commands
71
+
72
+ Contributes no facet providers and no commands. The extension publishes a rules engine on `inputRulesEngineFacet` and rewrites matching input from `onBeforeApply`. Requires no other extensions. `defaultPreset()` does not install it.
73
+
74
+ ## Documentation
75
+
76
+ The docs site (the `@input/pen-docs` package) covers this area on the Extensions and facets page (`#/extensions`).
77
+
78
+ The public signatures of record are in `api-report.md` next to this package's source in the Pen repository. The docs site does not host a generated browsable reference.
79
+
80
+ ## License
81
+
82
+ MIT © Input B.V. See [`LICENSE.md`](./LICENSE.md).
package/dist/index.cjs ADDED
@@ -0,0 +1,396 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AUTOFORMAT_EXTENSION_NAME: () => AUTOFORMAT_EXTENSION_NAME,
24
+ autoformatExtension: () => autoformatExtension,
25
+ defaultBlockRules: () => defaultBlockRules,
26
+ defaultInlineRules: () => defaultInlineRules
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/extension.ts
31
+ var import_pen_types = require("@input/pen-types");
32
+
33
+ // src/engine.ts
34
+ var import_pen_core = require("@input/pen-core");
35
+ var InputRuleEngine = class {
36
+ _rules = [];
37
+ _inlineRules = [];
38
+ register(rule) {
39
+ const idx = this._rules.findIndex((r) => r.id === rule.id);
40
+ if (idx >= 0) {
41
+ this._rules[idx] = rule;
42
+ } else {
43
+ this._rules.push(rule);
44
+ }
45
+ }
46
+ unregister(id) {
47
+ this._rules = this._rules.filter((r) => r.id !== id);
48
+ }
49
+ registerInline(rule) {
50
+ const idx = this._inlineRules.findIndex((r) => r.id === rule.id);
51
+ if (idx >= 0) {
52
+ this._inlineRules[idx] = rule;
53
+ } else {
54
+ this._inlineRules.push(rule);
55
+ }
56
+ }
57
+ tryMatch(editor, blockId, insertedText, options = {}) {
58
+ if (insertedText !== " " && insertedText !== "\n") return null;
59
+ const handle = editor.getBlock(blockId);
60
+ if (!handle) return null;
61
+ const blockType = handle.type;
62
+ const fullText = handle.textContent();
63
+ const offset = this._resolveOffset(editor, blockId, options.offset);
64
+ if (offset == null) return null;
65
+ const textBefore = fullText.slice(0, offset) + insertedText;
66
+ const ctx = {
67
+ editor,
68
+ blockId,
69
+ blockType,
70
+ textBefore,
71
+ fullText
72
+ };
73
+ for (const rule of this._rules) {
74
+ if (rule.blockTypes && !rule.blockTypes.includes(blockType))
75
+ continue;
76
+ const match = textBefore.match(rule.match);
77
+ if (!match) continue;
78
+ const ops = rule.handler(match, ctx);
79
+ if (ops && ops.length > 0) return ops;
80
+ }
81
+ return null;
82
+ }
83
+ /**
84
+ * Attempts to match an inline markdown pattern (e.g. **bold**, *italic*).
85
+ * Called when the user types the closing delimiter character.
86
+ */
87
+ tryMatchInline(editor, blockId, insertedText, options = {}) {
88
+ if (insertedText.length !== 1) return null;
89
+ const handle = editor.getBlock(blockId);
90
+ if (!handle) return null;
91
+ const schema = editor.schema.resolve(handle.type);
92
+ if (!(0, import_pen_core.supportsInlineInputRules)(schema)) return null;
93
+ const fullText = handle.textContent();
94
+ const offset = this._resolveOffset(editor, blockId, options.offset);
95
+ if (offset == null) return null;
96
+ const textWithInsert = fullText.slice(0, offset) + insertedText + fullText.slice(offset);
97
+ const cursorAfterInsert = offset + insertedText.length;
98
+ for (const rule of this._inlineRules) {
99
+ if (insertedText !== rule.trigger.slice(-1)) continue;
100
+ if (!editor.schema.resolveInline(rule.markType)) continue;
101
+ const match = rule.pattern.exec(textWithInsert);
102
+ if (!match) continue;
103
+ const matchEnd = match.index + match[0].length;
104
+ if (matchEnd !== cursorAfterInsert) continue;
105
+ const innerText = match[1];
106
+ if (!innerText || innerText.length === 0) continue;
107
+ const matchStart = match.index;
108
+ const fullMatchLength = match[0].length;
109
+ return [
110
+ {
111
+ type: "splice-text",
112
+ blockId,
113
+ from: matchStart,
114
+ to: matchStart + fullMatchLength,
115
+ insert: ""
116
+ },
117
+ {
118
+ type: "splice-text",
119
+ blockId,
120
+ from: matchStart,
121
+ to: matchStart,
122
+ insert: innerText,
123
+ marks: { [rule.markType]: true }
124
+ }
125
+ ];
126
+ }
127
+ return null;
128
+ }
129
+ _resolveOffset(editor, blockId, offsetOverride) {
130
+ if (typeof offsetOverride === "number") {
131
+ return offsetOverride;
132
+ }
133
+ const sel = editor.selection;
134
+ if (!sel || sel.type !== "text" || !(0, import_pen_core.isCollapsed)(sel)) return null;
135
+ if (sel.anchor.blockId !== blockId) return null;
136
+ return sel.anchor.offset;
137
+ }
138
+ };
139
+
140
+ // src/defaultRules.ts
141
+ var CALLOUT_SEVERITY_MAP = {
142
+ note: "info",
143
+ info: "info",
144
+ warning: "warning",
145
+ error: "error"
146
+ };
147
+ var defaultBlockRules = [
148
+ headingRule(1, /^#\s$/),
149
+ headingRule(2, /^##\s$/),
150
+ headingRule(3, /^###\s$/),
151
+ headingRule(4, /^####\s$/),
152
+ headingRule(5, /^#####\s$/),
153
+ headingRule(6, /^######\s$/),
154
+ {
155
+ id: "input-rule:unordered-list",
156
+ match: /^[-*+]\s$/,
157
+ blockTypes: ["paragraph"],
158
+ handler: (_match, ctx) => convertBlockOps(ctx, "bulletListItem", _match[0].length)
159
+ },
160
+ {
161
+ id: "input-rule:ordered-list",
162
+ match: /^(\d+)\.\s$/,
163
+ blockTypes: ["paragraph"],
164
+ handler: (match, ctx) => {
165
+ const start = Number.parseInt(match[1], 10);
166
+ return convertBlockOps(
167
+ ctx,
168
+ "numberedListItem",
169
+ match[0].length,
170
+ start > 1 ? { start } : {}
171
+ );
172
+ }
173
+ },
174
+ {
175
+ id: "input-rule:check-list",
176
+ match: /^\[[\sx]?\]\s$/i,
177
+ blockTypes: ["paragraph"],
178
+ handler: (match, ctx) => {
179
+ const checked = match[0].toLowerCase().includes("x");
180
+ return [
181
+ {
182
+ type: "splice-text",
183
+ blockId: ctx.blockId,
184
+ from: 0,
185
+ to: 0 + match[0].length,
186
+ insert: ""
187
+ },
188
+ {
189
+ type: "set-props",
190
+ blockId: ctx.blockId,
191
+ props: { type: "checkListItem", checked }
192
+ }
193
+ ];
194
+ }
195
+ },
196
+ {
197
+ id: "input-rule:blockquote",
198
+ match: /^>\s$/,
199
+ blockTypes: ["paragraph"],
200
+ handler: (match, ctx) => convertBlockOps(ctx, "blockquote", match[0].length)
201
+ },
202
+ {
203
+ id: "input-rule:code-block",
204
+ match: /^```[\s\n]$/,
205
+ blockTypes: ["paragraph"],
206
+ handler: (match, ctx) => convertBlockOps(ctx, "codeBlock", match[0].length)
207
+ },
208
+ {
209
+ id: "input-rule:divider",
210
+ match: /^(?:---|\*\*\*|___)\s$/,
211
+ blockTypes: ["paragraph"],
212
+ handler: (match, ctx) => [
213
+ {
214
+ type: "splice-text",
215
+ blockId: ctx.blockId,
216
+ from: 0,
217
+ to: 0 + match[0].length,
218
+ insert: ""
219
+ },
220
+ {
221
+ type: "set-props",
222
+ blockId: ctx.blockId,
223
+ props: { type: "divider" }
224
+ }
225
+ ]
226
+ },
227
+ {
228
+ id: "input-rule:callout",
229
+ match: /^>\s*\[!(\w+)\]\s$/i,
230
+ blockTypes: ["paragraph"],
231
+ handler: (match, ctx) => {
232
+ const raw = (match[1] ?? "info").toLowerCase();
233
+ const severity = CALLOUT_SEVERITY_MAP[raw] ?? "info";
234
+ return convertBlockOps(ctx, "callout", match[0].length, {
235
+ severity
236
+ });
237
+ }
238
+ }
239
+ ];
240
+ function headingRule(level, match) {
241
+ return {
242
+ id: `input-rule:heading-${level}`,
243
+ match,
244
+ blockTypes: ["paragraph"],
245
+ handler: (m, ctx) => convertBlockOps(ctx, "heading", m[0].length, { level })
246
+ };
247
+ }
248
+ function convertBlockOps(ctx, newType, deleteLength, newProps = {}) {
249
+ return [
250
+ {
251
+ type: "splice-text",
252
+ blockId: ctx.blockId,
253
+ from: 0,
254
+ to: 0 + deleteLength,
255
+ insert: ""
256
+ },
257
+ {
258
+ type: "set-props",
259
+ blockId: ctx.blockId,
260
+ props: { type: newType, ...newProps }
261
+ }
262
+ ];
263
+ }
264
+
265
+ // src/inlineRules.ts
266
+ var defaultInlineRules = [
267
+ {
268
+ id: "inline-rule:bold",
269
+ trigger: "*",
270
+ pattern: /\*\*(.+?)\*\*$/,
271
+ markType: "bold"
272
+ },
273
+ {
274
+ id: "inline-rule:italic",
275
+ trigger: "*",
276
+ // Single * that isn't preceded by another * (to avoid eating bold patterns).
277
+ // Matches *text* at end of text-before-cursor.
278
+ pattern: /(?<!\*)\*([^*]+?)\*$/,
279
+ markType: "italic"
280
+ },
281
+ {
282
+ id: "inline-rule:code",
283
+ trigger: "`",
284
+ pattern: /`([^`]+?)`$/,
285
+ markType: "code"
286
+ },
287
+ {
288
+ id: "inline-rule:strikethrough",
289
+ trigger: "~",
290
+ pattern: /~~(.+?)~~$/,
291
+ markType: "strikethrough"
292
+ },
293
+ {
294
+ id: "inline-rule:highlight",
295
+ trigger: "=",
296
+ pattern: /==(.+?)==$/,
297
+ markType: "highlight"
298
+ }
299
+ ];
300
+
301
+ // src/extension.ts
302
+ var AUTOFORMAT_EXTENSION_NAME = "autoformat";
303
+ var BYPASS_ORIGINS = /* @__PURE__ */ new Set([
304
+ "input-rule",
305
+ "collaborator",
306
+ "import",
307
+ "history",
308
+ "system"
309
+ ]);
310
+ function autoformatExtension(config = {}) {
311
+ const engine = new InputRuleEngine();
312
+ if (!config.disableDefaults) {
313
+ for (const rule of defaultBlockRules) {
314
+ engine.register(rule);
315
+ }
316
+ }
317
+ if (!config.disableDefaultInlineRules) {
318
+ for (const rule of defaultInlineRules) {
319
+ engine.registerInline(rule);
320
+ }
321
+ }
322
+ if (config.rules) {
323
+ for (const rule of config.rules) {
324
+ engine.register(rule);
325
+ }
326
+ }
327
+ if (config.inlineRules) {
328
+ for (const rule of config.inlineRules) {
329
+ engine.registerInline(rule);
330
+ }
331
+ }
332
+ let unsub = null;
333
+ const produced = /* @__PURE__ */ new WeakSet();
334
+ return {
335
+ name: AUTOFORMAT_EXTENSION_NAME,
336
+ version: "0.0.0",
337
+ activateClient: async (ctx) => {
338
+ const { editor } = ctx;
339
+ unsub?.();
340
+ unsub = editor.onBeforeApply(
341
+ (ops, options) => {
342
+ const origin = options.origin ?? "user";
343
+ if (BYPASS_ORIGINS.has(origin)) return ops;
344
+ if (produced.has(ops)) return ops;
345
+ const next = appendInputRuleTransforms(editor, engine, ops);
346
+ produced.add(next);
347
+ return next;
348
+ },
349
+ { priority: 300 }
350
+ );
351
+ ctx.editor.internals.assignSlot(
352
+ import_pen_types.INPUT_RULES_ENGINE_SLOT_KEY,
353
+ engine
354
+ );
355
+ },
356
+ deactivateClient: async () => {
357
+ unsub?.();
358
+ unsub = null;
359
+ }
360
+ };
361
+ }
362
+ function appendInputRuleTransforms(editor, engine, ops) {
363
+ const transformedOps = [];
364
+ for (const op of ops) {
365
+ transformedOps.push(op);
366
+ if (op.type !== "splice-text" || typeof op.insert !== "string" || op.insert.length !== 1 || op.from !== op.to) {
367
+ continue;
368
+ }
369
+ const blockResult = engine.tryMatch(editor, op.blockId, op.insert, {
370
+ offset: op.from
371
+ });
372
+ if (blockResult) {
373
+ transformedOps.push(...blockResult);
374
+ continue;
375
+ }
376
+ const inlineResult = engine.tryMatchInline(
377
+ editor,
378
+ op.blockId,
379
+ op.insert,
380
+ {
381
+ offset: op.from
382
+ }
383
+ );
384
+ if (inlineResult) {
385
+ transformedOps.push(...inlineResult);
386
+ }
387
+ }
388
+ return transformedOps;
389
+ }
390
+ // Annotate the CommonJS export names for ESM import in node:
391
+ 0 && (module.exports = {
392
+ AUTOFORMAT_EXTENSION_NAME,
393
+ autoformatExtension,
394
+ defaultBlockRules,
395
+ defaultInlineRules
396
+ });
@@ -0,0 +1,35 @@
1
+ import * as _input_pen_types from '@input/pen-types';
2
+ import { Extension, InputRule } from '@input/pen-types';
3
+ export { InputRule, InputRuleContext, InputRuleHandler } from '@input/pen-types';
4
+
5
+ interface InlineInputRule {
6
+ id: string;
7
+ trigger: string;
8
+ pattern: RegExp;
9
+ markType: string;
10
+ }
11
+ interface AutoformatConfig {
12
+ rules?: _input_pen_types.InputRule[];
13
+ inlineRules?: InlineInputRule[];
14
+ disableDefaults?: boolean;
15
+ disableDefaultInlineRules?: boolean;
16
+ }
17
+
18
+ declare const AUTOFORMAT_EXTENSION_NAME = "autoformat";
19
+ declare function autoformatExtension(config?: AutoformatConfig): Extension;
20
+
21
+ declare const defaultBlockRules: InputRule[];
22
+
23
+ /**
24
+ * Inline markdown shortcuts that fire when the user types the closing delimiter.
25
+ *
26
+ * Each pattern captures the inner text as group 1 and must match ending
27
+ * exactly at the cursor position. The engine deletes the full match
28
+ * (including delimiters) and re-inserts the inner text with the mark applied.
29
+ *
30
+ * Patterns use negative lookbehind/lookahead where needed to avoid
31
+ * false positives (e.g. `**` inside a word).
32
+ */
33
+ declare const defaultInlineRules: InlineInputRule[];
34
+
35
+ export { AUTOFORMAT_EXTENSION_NAME, type AutoformatConfig, type InlineInputRule, autoformatExtension, defaultBlockRules, defaultInlineRules };
@@ -0,0 +1,35 @@
1
+ import * as _input_pen_types from '@input/pen-types';
2
+ import { Extension, InputRule } from '@input/pen-types';
3
+ export { InputRule, InputRuleContext, InputRuleHandler } from '@input/pen-types';
4
+
5
+ interface InlineInputRule {
6
+ id: string;
7
+ trigger: string;
8
+ pattern: RegExp;
9
+ markType: string;
10
+ }
11
+ interface AutoformatConfig {
12
+ rules?: _input_pen_types.InputRule[];
13
+ inlineRules?: InlineInputRule[];
14
+ disableDefaults?: boolean;
15
+ disableDefaultInlineRules?: boolean;
16
+ }
17
+
18
+ declare const AUTOFORMAT_EXTENSION_NAME = "autoformat";
19
+ declare function autoformatExtension(config?: AutoformatConfig): Extension;
20
+
21
+ declare const defaultBlockRules: InputRule[];
22
+
23
+ /**
24
+ * Inline markdown shortcuts that fire when the user types the closing delimiter.
25
+ *
26
+ * Each pattern captures the inner text as group 1 and must match ending
27
+ * exactly at the cursor position. The engine deletes the full match
28
+ * (including delimiters) and re-inserts the inner text with the mark applied.
29
+ *
30
+ * Patterns use negative lookbehind/lookahead where needed to avoid
31
+ * false positives (e.g. `**` inside a word).
32
+ */
33
+ declare const defaultInlineRules: InlineInputRule[];
34
+
35
+ export { AUTOFORMAT_EXTENSION_NAME, type AutoformatConfig, type InlineInputRule, autoformatExtension, defaultBlockRules, defaultInlineRules };
package/dist/index.mjs ADDED
@@ -0,0 +1,366 @@
1
+ // src/extension.ts
2
+ import { INPUT_RULES_ENGINE_SLOT_KEY } from "@input/pen-types";
3
+
4
+ // src/engine.ts
5
+ import { isCollapsed, supportsInlineInputRules } from "@input/pen-core";
6
+ var InputRuleEngine = class {
7
+ _rules = [];
8
+ _inlineRules = [];
9
+ register(rule) {
10
+ const idx = this._rules.findIndex((r) => r.id === rule.id);
11
+ if (idx >= 0) {
12
+ this._rules[idx] = rule;
13
+ } else {
14
+ this._rules.push(rule);
15
+ }
16
+ }
17
+ unregister(id) {
18
+ this._rules = this._rules.filter((r) => r.id !== id);
19
+ }
20
+ registerInline(rule) {
21
+ const idx = this._inlineRules.findIndex((r) => r.id === rule.id);
22
+ if (idx >= 0) {
23
+ this._inlineRules[idx] = rule;
24
+ } else {
25
+ this._inlineRules.push(rule);
26
+ }
27
+ }
28
+ tryMatch(editor, blockId, insertedText, options = {}) {
29
+ if (insertedText !== " " && insertedText !== "\n") return null;
30
+ const handle = editor.getBlock(blockId);
31
+ if (!handle) return null;
32
+ const blockType = handle.type;
33
+ const fullText = handle.textContent();
34
+ const offset = this._resolveOffset(editor, blockId, options.offset);
35
+ if (offset == null) return null;
36
+ const textBefore = fullText.slice(0, offset) + insertedText;
37
+ const ctx = {
38
+ editor,
39
+ blockId,
40
+ blockType,
41
+ textBefore,
42
+ fullText
43
+ };
44
+ for (const rule of this._rules) {
45
+ if (rule.blockTypes && !rule.blockTypes.includes(blockType))
46
+ continue;
47
+ const match = textBefore.match(rule.match);
48
+ if (!match) continue;
49
+ const ops = rule.handler(match, ctx);
50
+ if (ops && ops.length > 0) return ops;
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Attempts to match an inline markdown pattern (e.g. **bold**, *italic*).
56
+ * Called when the user types the closing delimiter character.
57
+ */
58
+ tryMatchInline(editor, blockId, insertedText, options = {}) {
59
+ if (insertedText.length !== 1) return null;
60
+ const handle = editor.getBlock(blockId);
61
+ if (!handle) return null;
62
+ const schema = editor.schema.resolve(handle.type);
63
+ if (!supportsInlineInputRules(schema)) return null;
64
+ const fullText = handle.textContent();
65
+ const offset = this._resolveOffset(editor, blockId, options.offset);
66
+ if (offset == null) return null;
67
+ const textWithInsert = fullText.slice(0, offset) + insertedText + fullText.slice(offset);
68
+ const cursorAfterInsert = offset + insertedText.length;
69
+ for (const rule of this._inlineRules) {
70
+ if (insertedText !== rule.trigger.slice(-1)) continue;
71
+ if (!editor.schema.resolveInline(rule.markType)) continue;
72
+ const match = rule.pattern.exec(textWithInsert);
73
+ if (!match) continue;
74
+ const matchEnd = match.index + match[0].length;
75
+ if (matchEnd !== cursorAfterInsert) continue;
76
+ const innerText = match[1];
77
+ if (!innerText || innerText.length === 0) continue;
78
+ const matchStart = match.index;
79
+ const fullMatchLength = match[0].length;
80
+ return [
81
+ {
82
+ type: "splice-text",
83
+ blockId,
84
+ from: matchStart,
85
+ to: matchStart + fullMatchLength,
86
+ insert: ""
87
+ },
88
+ {
89
+ type: "splice-text",
90
+ blockId,
91
+ from: matchStart,
92
+ to: matchStart,
93
+ insert: innerText,
94
+ marks: { [rule.markType]: true }
95
+ }
96
+ ];
97
+ }
98
+ return null;
99
+ }
100
+ _resolveOffset(editor, blockId, offsetOverride) {
101
+ if (typeof offsetOverride === "number") {
102
+ return offsetOverride;
103
+ }
104
+ const sel = editor.selection;
105
+ if (!sel || sel.type !== "text" || !isCollapsed(sel)) return null;
106
+ if (sel.anchor.blockId !== blockId) return null;
107
+ return sel.anchor.offset;
108
+ }
109
+ };
110
+
111
+ // src/defaultRules.ts
112
+ var CALLOUT_SEVERITY_MAP = {
113
+ note: "info",
114
+ info: "info",
115
+ warning: "warning",
116
+ error: "error"
117
+ };
118
+ var defaultBlockRules = [
119
+ headingRule(1, /^#\s$/),
120
+ headingRule(2, /^##\s$/),
121
+ headingRule(3, /^###\s$/),
122
+ headingRule(4, /^####\s$/),
123
+ headingRule(5, /^#####\s$/),
124
+ headingRule(6, /^######\s$/),
125
+ {
126
+ id: "input-rule:unordered-list",
127
+ match: /^[-*+]\s$/,
128
+ blockTypes: ["paragraph"],
129
+ handler: (_match, ctx) => convertBlockOps(ctx, "bulletListItem", _match[0].length)
130
+ },
131
+ {
132
+ id: "input-rule:ordered-list",
133
+ match: /^(\d+)\.\s$/,
134
+ blockTypes: ["paragraph"],
135
+ handler: (match, ctx) => {
136
+ const start = Number.parseInt(match[1], 10);
137
+ return convertBlockOps(
138
+ ctx,
139
+ "numberedListItem",
140
+ match[0].length,
141
+ start > 1 ? { start } : {}
142
+ );
143
+ }
144
+ },
145
+ {
146
+ id: "input-rule:check-list",
147
+ match: /^\[[\sx]?\]\s$/i,
148
+ blockTypes: ["paragraph"],
149
+ handler: (match, ctx) => {
150
+ const checked = match[0].toLowerCase().includes("x");
151
+ return [
152
+ {
153
+ type: "splice-text",
154
+ blockId: ctx.blockId,
155
+ from: 0,
156
+ to: 0 + match[0].length,
157
+ insert: ""
158
+ },
159
+ {
160
+ type: "set-props",
161
+ blockId: ctx.blockId,
162
+ props: { type: "checkListItem", checked }
163
+ }
164
+ ];
165
+ }
166
+ },
167
+ {
168
+ id: "input-rule:blockquote",
169
+ match: /^>\s$/,
170
+ blockTypes: ["paragraph"],
171
+ handler: (match, ctx) => convertBlockOps(ctx, "blockquote", match[0].length)
172
+ },
173
+ {
174
+ id: "input-rule:code-block",
175
+ match: /^```[\s\n]$/,
176
+ blockTypes: ["paragraph"],
177
+ handler: (match, ctx) => convertBlockOps(ctx, "codeBlock", match[0].length)
178
+ },
179
+ {
180
+ id: "input-rule:divider",
181
+ match: /^(?:---|\*\*\*|___)\s$/,
182
+ blockTypes: ["paragraph"],
183
+ handler: (match, ctx) => [
184
+ {
185
+ type: "splice-text",
186
+ blockId: ctx.blockId,
187
+ from: 0,
188
+ to: 0 + match[0].length,
189
+ insert: ""
190
+ },
191
+ {
192
+ type: "set-props",
193
+ blockId: ctx.blockId,
194
+ props: { type: "divider" }
195
+ }
196
+ ]
197
+ },
198
+ {
199
+ id: "input-rule:callout",
200
+ match: /^>\s*\[!(\w+)\]\s$/i,
201
+ blockTypes: ["paragraph"],
202
+ handler: (match, ctx) => {
203
+ const raw = (match[1] ?? "info").toLowerCase();
204
+ const severity = CALLOUT_SEVERITY_MAP[raw] ?? "info";
205
+ return convertBlockOps(ctx, "callout", match[0].length, {
206
+ severity
207
+ });
208
+ }
209
+ }
210
+ ];
211
+ function headingRule(level, match) {
212
+ return {
213
+ id: `input-rule:heading-${level}`,
214
+ match,
215
+ blockTypes: ["paragraph"],
216
+ handler: (m, ctx) => convertBlockOps(ctx, "heading", m[0].length, { level })
217
+ };
218
+ }
219
+ function convertBlockOps(ctx, newType, deleteLength, newProps = {}) {
220
+ return [
221
+ {
222
+ type: "splice-text",
223
+ blockId: ctx.blockId,
224
+ from: 0,
225
+ to: 0 + deleteLength,
226
+ insert: ""
227
+ },
228
+ {
229
+ type: "set-props",
230
+ blockId: ctx.blockId,
231
+ props: { type: newType, ...newProps }
232
+ }
233
+ ];
234
+ }
235
+
236
+ // src/inlineRules.ts
237
+ var defaultInlineRules = [
238
+ {
239
+ id: "inline-rule:bold",
240
+ trigger: "*",
241
+ pattern: /\*\*(.+?)\*\*$/,
242
+ markType: "bold"
243
+ },
244
+ {
245
+ id: "inline-rule:italic",
246
+ trigger: "*",
247
+ // Single * that isn't preceded by another * (to avoid eating bold patterns).
248
+ // Matches *text* at end of text-before-cursor.
249
+ pattern: /(?<!\*)\*([^*]+?)\*$/,
250
+ markType: "italic"
251
+ },
252
+ {
253
+ id: "inline-rule:code",
254
+ trigger: "`",
255
+ pattern: /`([^`]+?)`$/,
256
+ markType: "code"
257
+ },
258
+ {
259
+ id: "inline-rule:strikethrough",
260
+ trigger: "~",
261
+ pattern: /~~(.+?)~~$/,
262
+ markType: "strikethrough"
263
+ },
264
+ {
265
+ id: "inline-rule:highlight",
266
+ trigger: "=",
267
+ pattern: /==(.+?)==$/,
268
+ markType: "highlight"
269
+ }
270
+ ];
271
+
272
+ // src/extension.ts
273
+ var AUTOFORMAT_EXTENSION_NAME = "autoformat";
274
+ var BYPASS_ORIGINS = /* @__PURE__ */ new Set([
275
+ "input-rule",
276
+ "collaborator",
277
+ "import",
278
+ "history",
279
+ "system"
280
+ ]);
281
+ function autoformatExtension(config = {}) {
282
+ const engine = new InputRuleEngine();
283
+ if (!config.disableDefaults) {
284
+ for (const rule of defaultBlockRules) {
285
+ engine.register(rule);
286
+ }
287
+ }
288
+ if (!config.disableDefaultInlineRules) {
289
+ for (const rule of defaultInlineRules) {
290
+ engine.registerInline(rule);
291
+ }
292
+ }
293
+ if (config.rules) {
294
+ for (const rule of config.rules) {
295
+ engine.register(rule);
296
+ }
297
+ }
298
+ if (config.inlineRules) {
299
+ for (const rule of config.inlineRules) {
300
+ engine.registerInline(rule);
301
+ }
302
+ }
303
+ let unsub = null;
304
+ const produced = /* @__PURE__ */ new WeakSet();
305
+ return {
306
+ name: AUTOFORMAT_EXTENSION_NAME,
307
+ version: "0.0.0",
308
+ activateClient: async (ctx) => {
309
+ const { editor } = ctx;
310
+ unsub?.();
311
+ unsub = editor.onBeforeApply(
312
+ (ops, options) => {
313
+ const origin = options.origin ?? "user";
314
+ if (BYPASS_ORIGINS.has(origin)) return ops;
315
+ if (produced.has(ops)) return ops;
316
+ const next = appendInputRuleTransforms(editor, engine, ops);
317
+ produced.add(next);
318
+ return next;
319
+ },
320
+ { priority: 300 }
321
+ );
322
+ ctx.editor.internals.assignSlot(
323
+ INPUT_RULES_ENGINE_SLOT_KEY,
324
+ engine
325
+ );
326
+ },
327
+ deactivateClient: async () => {
328
+ unsub?.();
329
+ unsub = null;
330
+ }
331
+ };
332
+ }
333
+ function appendInputRuleTransforms(editor, engine, ops) {
334
+ const transformedOps = [];
335
+ for (const op of ops) {
336
+ transformedOps.push(op);
337
+ if (op.type !== "splice-text" || typeof op.insert !== "string" || op.insert.length !== 1 || op.from !== op.to) {
338
+ continue;
339
+ }
340
+ const blockResult = engine.tryMatch(editor, op.blockId, op.insert, {
341
+ offset: op.from
342
+ });
343
+ if (blockResult) {
344
+ transformedOps.push(...blockResult);
345
+ continue;
346
+ }
347
+ const inlineResult = engine.tryMatchInline(
348
+ editor,
349
+ op.blockId,
350
+ op.insert,
351
+ {
352
+ offset: op.from
353
+ }
354
+ );
355
+ if (inlineResult) {
356
+ transformedOps.push(...inlineResult);
357
+ }
358
+ }
359
+ return transformedOps;
360
+ }
361
+ export {
362
+ AUTOFORMAT_EXTENSION_NAME,
363
+ autoformatExtension,
364
+ defaultBlockRules,
365
+ defaultInlineRules
366
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@input/pen-autoformat",
3
+ "version": "0.1.0",
4
+ "description": "Markdown shortcut input rules for Pen — auto-format as you type",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/input-systems/pen#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/input-systems/pen/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/input-systems/pen.git",
13
+ "directory": "packages/extensions/autoformat"
14
+ },
15
+ "type": "module",
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org/"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "import": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.mjs"
25
+ },
26
+ "require": {
27
+ "types": "./dist/index.d.cts",
28
+ "default": "./dist/index.cjs"
29
+ }
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "main": "./dist/index.cjs",
34
+ "module": "./dist/index.mjs",
35
+ "types": "./dist/index.d.ts",
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE.md"
40
+ ],
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "sideEffects": false,
45
+ "dependencies": {
46
+ "@input/pen-core": "^0.1.0",
47
+ "@input/pen-types": "^0.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "tsup": "^8.4.0",
51
+ "typescript": "^5.7.3",
52
+ "vitest": "^3.2.7"
53
+ },
54
+ "scripts": {
55
+ "build": "tsup",
56
+ "typecheck": "tsc --noEmit",
57
+ "lint": "eslint .",
58
+ "test": "vitest run",
59
+ "clean": "rm -rf dist *.tsbuildinfo"
60
+ }
61
+ }