|
@@ -119,67 +119,123 @@ Tables use a **two-state rendering pattern**: blurred → source hidden, HTML wi
|
|
|
|
|
|
|
|
## How to Add a New Rule
|
|
## How to Add a New Rule
|
|
|
|
|
|
|
|
-Follow these steps exactly. Do not deviate from this pattern.
|
|
|
|
|
|
|
+There are **three registration points** in the pipeline. Every new syntax touches all three.
|
|
|
|
|
|
|
|
-**1. Identify the pipeline stage.**
|
|
|
|
|
-- Inline syntax (spans within a line, e.g. bold, links) → `InlineRule` (defined as `MarkdownRule<unknown>`)
|
|
|
|
|
-- Line/block syntax (whole-line patterns, e.g. headings, blockquotes) → `BlockRule`
|
|
|
|
|
-- Key-value extraction → add to `parseProperties()` in `engine.ts`
|
|
|
|
|
|
|
+| # | File | Purpose |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| 1 | `markdown-extensions.ts` | Define the Lezer node type via `MarkdownConfig` (so the parser produces AST nodes for your syntax) |
|
|
|
|
|
+| 2 | `inline-rules.ts` / `block-rules.ts` | Create a `MarkdownRule<unknown>` or `BlockRule` factory that matches the new node type and returns decorations |
|
|
|
|
|
+| 3 | `engine.ts` (or consumer code) | Register the rule via the `extraInlineRules`/`extraBlockRules` constructor param, or add to `createInlineRules()`/`createBlockRules()` for built-in syntax |
|
|
|
|
|
+
|
|
|
|
|
+Follow the example below — it adds a `::foo::` inline syntax end to end.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Step 1 — Define the Lezer node (`markdown-extensions.ts`)
|
|
|
|
|
|
|
|
-**2. Implement a self-contained factory function** in the appropriate file under `packages/editor/src/lib/markdown-rules/`. Do not modify `MarkdownRuleEngine.parse()`.
|
|
|
|
|
|
|
+If your syntax isn't already a standard GFM node, add a `MarkdownPattern` entry to `defaultPatterns` or `createMarkdownExtensions()`:
|
|
|
|
|
|
|
|
```typescript
|
|
```typescript
|
|
|
-// Example: a minimal InlineRule factory (add to inline-rules.ts)
|
|
|
|
|
-function createMyInlineRule(): MarkdownRule<unknown> {
|
|
|
|
|
|
|
+// markdown-extensions.ts — add to defaultPatterns array
|
|
|
|
|
+{
|
|
|
|
|
+ name: "FooMarker",
|
|
|
|
|
+ start: "::",
|
|
|
|
|
+ end: "::",
|
|
|
|
|
+ nodeType: "FooMarker",
|
|
|
|
|
+ decoration: {
|
|
|
|
|
+ contentClass: "md-foo",
|
|
|
|
|
+ hiddenClass: "md-hidden",
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+This creates a `"FooMarker"` Lezer inline node for text between `::` delimiters. For block-level syntax (e.g. `::component{attrs}`), use a `LeafBlockParser` instead — see the existing `TaskParser` or [MdcParser in the MDC plan](.agents/plans/mdc.md).
|
|
|
|
|
+
|
|
|
|
|
+**Reference:** [@lezer/markdown MarkdownConfig API](https://lezer.codemirror.net/docs/ref/#markdown.MarkdownConfig)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Step 2 — Create a decoration rule (`inline-rules.ts` or `block-rules.ts`)
|
|
|
|
|
+
|
|
|
|
|
+Build a factory function that matches your Lezer node type and returns `DecorationRange` objects:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// inline-rules.ts
|
|
|
|
|
+function createFooRule(): MarkdownRule<unknown> {
|
|
|
return {
|
|
return {
|
|
|
- name: "my-rule",
|
|
|
|
|
- // Lezer node type(s) this rule targets
|
|
|
|
|
- nodeTypes: ["MyNode"],
|
|
|
|
|
|
|
+ name: "foo",
|
|
|
|
|
+ nodeTypes: ["FooMarker"],
|
|
|
match(node) {
|
|
match(node) {
|
|
|
- return node.type.name === "MyNode"
|
|
|
|
|
|
|
+ return node.type.name === "FooMarker"
|
|
|
},
|
|
},
|
|
|
run(node, ctx) {
|
|
run(node, ctx) {
|
|
|
- return [{
|
|
|
|
|
- type: "content",
|
|
|
|
|
- from: node.from,
|
|
|
|
|
- to: node.to,
|
|
|
|
|
- className: "my-decoration",
|
|
|
|
|
- }]
|
|
|
|
|
|
|
+ // Return two ranges: hidden delimiters + visible content
|
|
|
|
|
+ return [
|
|
|
|
|
+ { type: "hidden", from: node.from, to: node.from + 2, className: "md-hidden" },
|
|
|
|
|
+ { type: "content", from: node.from + 2, to: node.to - 2, className: "md-foo" },
|
|
|
|
|
+ { type: "hidden", from: node.to - 2, to: node.to, className: "md-hidden" },
|
|
|
|
|
+ ]
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-**3. Register it in the `MarkdownRuleEngine` constructor** in `engine.ts`.
|
|
|
|
|
|
|
+For block-level syntax, create a `BlockRule` instead (see `createHeadingRule`, `createTableRule` in `block-rules.ts`).
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Step 3 — Register the rule
|
|
|
|
|
+
|
|
|
|
|
+**For external/plugin code** (recommended — no source changes needed):
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+import { MarkdownRuleEngine } from "@enesis/editor"
|
|
|
|
|
+
|
|
|
|
|
+const engine = new MarkdownRuleEngine({
|
|
|
|
|
+ extraInlineRules: [createFooRule()],
|
|
|
|
|
+ // extraBlockRules: [createMyBlockRule()],
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**For built-in syntax** (modify `engine.ts`):
|
|
|
|
|
|
|
|
```typescript
|
|
```typescript
|
|
|
-constructor() {
|
|
|
|
|
- this.inlineRules = [
|
|
|
|
|
- ...createInlineRules(),
|
|
|
|
|
- createMyInlineRule(), // ← add here
|
|
|
|
|
- ]
|
|
|
|
|
-}
|
|
|
|
|
|
|
+// engine.ts constructor
|
|
|
|
|
+this.inlineRules = [
|
|
|
|
|
+ ...createInlineRules(),
|
|
|
|
|
+ createFooRule(), // ← add here
|
|
|
|
|
+]
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-**4. Write a mirror test** in `packages/editor/src/lib/__tests__/markdown-parser.test.ts`. Follow the existing pattern:
|
|
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Step 4 — Write a test
|
|
|
|
|
+
|
|
|
|
|
+Add a test in `packages/editor/src/lib/__tests__/markdown-parser.test.ts`:
|
|
|
|
|
|
|
|
```typescript
|
|
```typescript
|
|
|
-describe("MyInlineRule", () => {
|
|
|
|
|
- it("decorates correctly", () => {
|
|
|
|
|
- const result = engine.parse("some ~example~ text")
|
|
|
|
|
|
|
+describe("FooRule", () => {
|
|
|
|
|
+ it("decorates foo content correctly", () => {
|
|
|
|
|
+ const result = engine.parse("hello ::world:: foo")
|
|
|
expect(result.content).toContainEqual({
|
|
expect(result.content).toContainEqual({
|
|
|
type: "content",
|
|
type: "content",
|
|
|
- from: 5,
|
|
|
|
|
- to: 12,
|
|
|
|
|
- className: "my-decoration",
|
|
|
|
|
|
|
+ from: 8,
|
|
|
|
|
+ to: 13,
|
|
|
|
|
+ className: "md-foo",
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-**5. Verify cursor behavior.** For any rule with `hidden` ranges (e.g. delimiters that hide when cursor is inside): confirm that `from`/`to` offsets are computed relative to the block's document offset, not the raw fragment string. Check the existing `createDelimitedRule` implementation as the reference.
|
|
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Step 5 — Cursor behavior
|
|
|
|
|
+
|
|
|
|
|
+For any rule with `hidden` ranges: confirm that `from`/`to` offsets are computed relative to the block's document offset, not the raw fragment string. Check `createDelimitedRule` as the reference.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
|
|
|
-**6. Run checks.**
|
|
|
|
|
|
|
+### Step 6 — Run checks
|
|
|
|
|
|
|
|
```bash
|
|
```bash
|
|
|
biome check packages/editor/src/lib/markdown-rules/
|
|
biome check packages/editor/src/lib/markdown-rules/
|