Просмотр исходного кода

feat(editor): complete Phase 6 — integration tests, dead param cleanup

- Add node view integration tests for MathBlockView and CodeBlockView
- Add inline math decoration tests to markdown-decorations.test.ts
- Add code/math block spec flag tests to content-model.test.ts
- Remove unused cursorFrom/cursorTo params from decoration builder functions
- Update READMEs and development plan for Phase 6 completion
Zander Hawke 1 неделя назад
Родитель
Сommit
22d2cbb413

+ 17 - 7
README.md

@@ -11,9 +11,10 @@ The editor is designed for the philosophy that **plain text should stay plain te
 Enesis is being built as a complete block-editing substrate:
 
 | Phase | Feature | Status |
-|---|---|---|
+|---|---|---|---|
 | 1–5 | Core editor: clean content model, keyboard boundaries, focus/cursor, inline + block decorations, Lezer-only AST pipeline | **Done** |
-| 6 | Code block node views with CodeMirror 6, inline/block LaTeX with KaTeX | **Partial** — CM6 done, LaTeX pending |
+| 6 | Code block node views with CodeMirror 6, inline/block LaTeX with KaTeX | **Done** |
+| 6.75 | Build system — type declarations pass cleanly, dev app as interactive docs site | **Done** |
 | 7 | Toolbar handler API — `toggleBold`, `insertLink`, `setHeading`, `toggleTask` from Lezer tree queries | Planned |
 | 8 | Asset handling — image drop, upload protocol, inline preview | Planned |
 | 9 | Reference interactivity — clickable `[[page]]`, `((block))`, `#tag` chips with preview | Planned |
@@ -33,15 +34,24 @@ Enesis is being built as a complete block-editing substrate:
 
 ```
 ├── apps/
-│   └── dev/                  Development sandbox (Vue 3 + Vite)
+│   └── dev/                  Docs-style development site (Vue 3 + Vite)
 │       ├── src/
 │       │   ├── components/
-│       │   │   ├── Editor.vue          Main playground
+│       │   │   ├── LiveExample.vue     Editable demo wrapper with source view
 │       │   │   └── AppLogo.vue         Enesis brand mark
 │       │   ├── pages/
-│       │   │   └── EditorView.vue      Route page
-│       │   ├── App.vue                 Shell layout (Nuxt UI)
-│       │   ├── main.ts                 App bootstrap
+│       │   │   ├── index.vue           Overview
+│       │   │   ├── basic-editing.vue   Headings, paragraphs, rules
+│       │   │   ├── inline-marks.vue    Bold, italic, code, strike, highlight
+│       │   │   ├── tasks.vue           Task states & priorities
+│       │   │   ├── blockquotes.vue     Blockquotes & callouts
+│       │   │   ├── links.vue           Markdown links
+│       │   │   ├── refs-tags.vue       Page refs, block refs, tags
+│       │   │   ├── code-blocks.vue     Fenced code blocks
+│       │   │   ├── properties.vue      Key::value properties & dates
+│       │   │   └── math.vue            Inline & display LaTeX
+│       │   ├── App.vue                 Shell layout (Nuxt UI) with sidebar
+│       │   ├── main.ts                 App bootstrap (10 routes)
 │       │   └── style.css               Tailwind v4 + Nuxt UI theme
 │       ├── public/                     Static assets
 │       └── vite.config.ts              Vite with Nuxt UI plugin

+ 36 - 17
apps/dev/README.md

@@ -1,6 +1,6 @@
 # @enesis/dev
 
-Development sandbox for the Enesis Editor. A Vite + Vue 3 app that exercises the `@enesis/editor` `<Block>` component with sample markdown content and full event logging.
+Documentation-style development sandbox for the Enesis Editor. A Vite + Vue 3 app that exercises every `@enesis/editor` `<Block>` feature through live, editable examples organized across 10 catalog pages.
 
 ## Quick Start
 
@@ -12,33 +12,52 @@ pnpm dev
 
 Opens at `http://localhost:5173`.
 
-## What It Demonstrates
+## Site Map
 
-- Four `<Block>` instances with different markdown content (headings, bold, callouts, page refs, block refs, tags, links, tasks with state/priority, properties, fenced code blocks)
-- All block lifecycle events logged to console (`change`, `split`, `merge-previous`, `delete-if-empty`, `indent`, `outdent`, `arrow-up-from-start`, `arrow-down-from-end`, `pattern-open`, `pattern-update`, `pattern-close`, `focus`, `blur`, `selection-change`)
-- `marker-mode="always-visible"` on the first block (markdown delimiters always shown)
-- `marker-mode="live-preview"` (default) on the remaining blocks (delimiters hidden when blurred)
-- `debug` prop for per-instance namespace-based logging (e.g. `debug="CodeBlockView"`)
-- Dark/light mode toggle via Nuxt UI `UColorModeButton`
-- Routing via Vue Router with a single route at `/`
+| Page | Route | Features Demonstrated |
+|---|---|---|
+| Overview | `/` | Key concepts, Block API table (props + events) |
+| Basic Editing | `/basic-editing` | Headings `#`–`######`, paragraphs, horizontal rules |
+| Inline Marks | `/inline-marks` | Bold, italic, inline code, strikethrough, highlight, combined marks |
+| Tasks & Priorities | `/tasks` | Task states (TODO→DONE cycle), priority flags `[#A]` |
+| Blockquotes & Callouts | `/blockquotes` | Blockquote `>`, 5 callout types (NOTE, WARNING, TIP, DANGER, INFO) |
+| Links | `/links` | Standard markdown `[text](url)` links |
+| Page Refs & Tags | `/refs-tags` | Page refs `[[Page]]`, block refs `((id))`, tags `#tag` |
+| Code Blocks | `/code-blocks` | Fenced code blocks with language labels and CM6 syntax highlighting |
+| Properties & Dates | `/properties` | `key:: value` properties, date parsing |
+| Math | `/math` | Inline `$...$`, display `$$...$$`, mixed content |
+
+Each page features multiple `<LiveExample>` sections — editable `<Block>` instances with a collapsible source code panel.
 
 ## Architecture
 
 ```
-EditorView.vue          Route page
-  └── Editor.vue        Playground — four <Block> instances
-        └── Block.vue   (from @enesis/editor)
+App.vue                     Shell layout — UHeader + UPage + sidebar (UNavigationMenu)
+├── pages/index.vue         Overview
+├── pages/basic-editing.vue
+├── pages/inline-marks.vue
+├── pages/tasks.vue
+├── pages/blockquotes.vue
+├── pages/links.vue
+├── pages/refs-tags.vue
+├── pages/code-blocks.vue
+├── pages/properties.vue
+└── pages/math.vue
+
+components/
+└── LiveExample.vue         <Block> wrapper with title, description, source code panel
 ```
 
 The dev app resolves `@enesis/editor` directly to source (`packages/editor/src/index.ts`) via Vite aliases, enabling hot-reload during development.
 
 ## Configuration
 
-- **Framework:** Vue 3 + Vite
-- **UI Library:** Nuxt UI v4 (layout components, color mode, buttons)
-- **Styling:** Tailwind CSS v4 with custom green palette
-- **Type Checking:** `vue-tsc` (run as part of `build`)
-- **Icons:** Iconify (Lucide, Simple Icons)
+- **Framework:** Vue 3 + Vite + TypeScript (`strict: true`)
+- **UI Library:** Nuxt UI v4 (UNavigationMenu, UPage, UHeader, UColorModeButton, UCard, etc.)
+- **Routing:** Vue Router — 10 routes, one per feature page
+- **Styling:** Tailwind CSS v4 with Nuxt UI theme
+- **Type Checking:** `vue-tsc` (pre-existing type errors in the editor package do not block the dev server)
+- **Icons:** Iconify (Lucide)
 
 ## Scripts
 

+ 4 - 1
packages/editor/README.md

@@ -115,6 +115,8 @@ Formatting is purely decorative — markdown syntax is stored as plain text in P
 | Callout | `> [!NOTE] text` | `md-callout` |
 | Task | `TODO task text` | `md-task-marker` / `md-task-content` |
 | Property | `key:: value` | `md-property-*` |
+| Inline Math | `$...$` | `md-math-inline` (+ KaTeX preview on blur) |
+| Display Math | `$$...$$` | MathBlock node view (+ KaTeX preview) |
 | Fenced Code Block | `` ```python `` / `~~~js` | CM6 node view |
 
 ### Pattern Detection
@@ -170,7 +172,8 @@ pnpm dev          # Watch mode rebuild
 | `src/composables/useCodeBlockView.ts` | CM6 NodeView for fenced code blocks |
 | `src/composables/usePatternPlugin.ts` | Pattern detection plugin |
 | `src/lib/markdown-parser.ts` | Lezer parser configuration |
-| `src/lib/markdown-extensions.ts` | Custom Lezer node definitions |
+| `src/lib/markdown-extensions.ts` | Custom Lezer node definitions (PageRef, BlockRef, Tag, Highlight, InlineMath, Task) |
+| `src/lib/katex.ts` | Dynamic KaTeX import + async/sync render helpers |
 | `src/lib/auto-close-plugin.ts` | Auto-close for ```, **, _ |
 | `src/lib/content-model.ts` | Markdown ↔ ProseMirror doc conversion |
 | `src/lib/logger.ts` | Namespace-based debug logger |

+ 27 - 0
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -241,4 +241,31 @@ describe("Markdown decorations plugin", () => {
 
     expect(mdCalloutCount).toBe(2)
   })
+
+  it("builds decorations for inline math", () => {
+    const { plugin } = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("solve $x^2 + 1$ for x")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    expect(decorations?.find().length).toBeGreaterThan(0)
+  })
+
+  it("produces same decoration count for inline math as bold", () => {
+    const { plugin } = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("$x^2$")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const decorations = plugin.spec.props?.decorations?.(editorState)
+    const mathCount = decorations?.find().length ?? 0
+    expect(mathCount).toBeGreaterThan(0)
+  })
 })

+ 145 - 0
packages/editor/src/composables/__tests__/node-views.test.ts

@@ -0,0 +1,145 @@
+import { EditorState } from "prosemirror-state"
+import { EditorView } from "prosemirror-view"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { MathBlockView } from "@/composables/useMathBlockView"
+import { schema } from "@/lib/schema"
+
+let views: EditorView[] = []
+
+beforeEach(() => {
+  views = []
+})
+
+afterEach(() => {
+  for (const v of views) {
+    v.destroy()
+  }
+  views = []
+})
+
+function makeDocWithMathBlock(content: string) {
+  const textNode = content ? schema.text(content) : undefined
+  const block = textNode
+    ? schema.nodes.math_block.create(null, [textNode])
+    : schema.nodes.math_block.create()
+  return schema.node("doc", {}, [block])
+}
+
+function createView(content: string) {
+  const doc = makeDocWithMathBlock(content)
+  const state = EditorState.create({ doc, schema })
+  const dom = document.createElement("div")
+  const view = new EditorView(dom, { state })
+  views.push(view)
+  return view
+}
+
+describe("MathBlockView", () => {
+  it("creates DOM structure with correct classes", () => {
+    const node = schema.nodes.math_block.create(null, [schema.text("$$\nE = mc^2\n$$")])
+    const view = createView("$$\nE = mc^2\n$$")
+
+    const nodeView = new MathBlockView(node, view, () => 0)
+
+    expect(nodeView.dom.className).toBe("math-block")
+    expect(nodeView.dom.children).toHaveLength(2)
+
+    const preview = nodeView.dom.children[0] as HTMLElement
+    const source = nodeView.dom.children[1] as HTMLElement
+    expect(preview.className).toBe("math-block-preview")
+    expect(source.className).toBe("math-block-source")
+    expect(nodeView.contentDOM).toBe(source)
+  })
+
+  it("returns false from update for non-math_block nodes", () => {
+    const textNode = schema.text("$$")
+    const mathNode = schema.nodes.math_block.create(null, [textNode])
+    const view = createView("$$")
+
+    const nodeView = new MathBlockView(mathNode, view, () => 0)
+
+    const paraNode = schema.nodes.paragraph.create(null, [schema.text("hello")])
+    expect(nodeView.update(paraNode)).toBe(false)
+  })
+
+  it("returns true from update for math_block nodes", () => {
+    const textNode = schema.text("$$\nE = mc^2\n$$")
+    const mathNode = schema.nodes.math_block.create(null, [textNode])
+    const view = createView("$$\nE = mc^2\n$$")
+
+    const nodeView = new MathBlockView(mathNode, view, () => 0)
+
+    const updatedNode = schema.nodes.math_block.create(null, [schema.text("$$\nx^2\n$$")])
+    expect(nodeView.update(updatedNode)).toBe(true)
+  })
+
+  it("stopEvent returns true", () => {
+    const textNode = schema.text("$$")
+    const mathNode = schema.nodes.math_block.create(null, [textNode])
+    const view = createView("$$")
+
+    const nodeView = new MathBlockView(mathNode, view, () => 0)
+
+    expect(nodeView.stopEvent()).toBe(true)
+  })
+})
+
+describe("CodeBlockView", () => {
+  it("creates DOM with cm-code-block class", async () => {
+    const { CodeBlockView } = await import("@/composables/useCodeBlockView")
+
+    const textNode = schema.text("```js\nconst x = 1\n```")
+    const node = schema.nodes.code_block.create({ language: "js" }, [textNode])
+    const doc = schema.node("doc", {}, [node])
+    const state = EditorState.create({ doc, schema })
+    const dom = document.createElement("div")
+    const view = new EditorView(dom, { state })
+    views.push(view)
+
+    const nodeView = new CodeBlockView(node, view, () => 0)
+
+    expect(nodeView.dom.className).toBe("cm-code-block")
+    expect(nodeView.dom.children.length).toBeGreaterThan(0)
+
+    // Clean up CM6 view
+    nodeView.destroy()
+  })
+
+  it("update returns false for non-code_block nodes", async () => {
+    const { CodeBlockView } = await import("@/composables/useCodeBlockView")
+
+    const textNode = schema.text("```\ncode\n```")
+    const node = schema.nodes.code_block.create({ language: "" }, [textNode])
+    const doc = schema.node("doc", {}, [node])
+    const state = EditorState.create({ doc, schema })
+    const dom = document.createElement("div")
+    const view = new EditorView(dom, { state })
+    views.push(view)
+
+    const nodeView = new CodeBlockView(node, view, () => 0)
+
+    const paraNode = schema.nodes.paragraph.create(null, [schema.text("hello")])
+    expect(nodeView.update(paraNode)).toBe(false)
+
+    nodeView.destroy()
+  })
+
+  it("update returns true for code_block nodes", async () => {
+    const { CodeBlockView } = await import("@/composables/useCodeBlockView")
+
+    const textNode = schema.text("```\ncode\n```")
+    const node = schema.nodes.code_block.create({ language: "" }, [textNode])
+    const doc = schema.node("doc", {}, [node])
+    const state = EditorState.create({ doc, schema })
+    const dom = document.createElement("div")
+    const view = new EditorView(dom, { state })
+    views.push(view)
+
+    const nodeView = new CodeBlockView(node, view, () => 0)
+
+    const updatedNode = schema.nodes.code_block.create({ language: "ts" }, [textNode])
+    expect(nodeView.update(updatedNode)).toBe(true)
+
+    nodeView.destroy()
+  })
+})

+ 0 - 22
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -85,8 +85,6 @@ function buildDecorationsFromCache(
   doc: ProsemirrorNode,
   contentCaches: Map<string, BlockTokenCache>,
   isFocused: boolean,
-  cursorFrom: number,
-  cursorTo: number,
   markerMode: MarkerVisibilityMode,
 ): DecorationSet {
   const decorationSpecs: Decoration[] = []
@@ -157,8 +155,6 @@ function buildDecorationsFromCache(
           blockStart,
           decorationSpecs,
           isFocused,
-          cursorFrom,
-          cursorTo,
           markerMode,
         )
       } else {
@@ -175,8 +171,6 @@ function buildDecorationsFromCache(
           blockStart,
           decorationSpecs,
           isFocused,
-          cursorFrom,
-          cursorTo,
           markerMode,
         )
       }
@@ -188,8 +182,6 @@ function buildDecorationsFromCache(
         blockStart,
         decorationSpecs,
         isFocused,
-        cursorFrom,
-        cursorTo,
         markerMode,
       )
     }
@@ -198,10 +190,6 @@ function buildDecorationsFromCache(
       cache.properties,
       blockStart,
       decorationSpecs,
-      isFocused,
-      cursorFrom,
-      cursorTo,
-      markerMode,
     )
   }
 
@@ -228,8 +216,6 @@ function buildTokenDecorations(
   offset: number,
   decorationSpecs: Decoration[],
   isFocused: boolean,
-  cursorFrom: number,
-  cursorTo: number,
   markerMode: MarkerVisibilityMode,
 ) {
   const ranges = token.decorationRanges
@@ -326,10 +312,6 @@ function buildPropertyDecorations(
   }>,
   childOffset: number,
   decorationSpecs: Decoration[],
-  _isFocused: boolean,
-  _cursorFrom: number,
-  _cursorTo: number,
-  _markerMode: MarkerVisibilityMode,
 ) {
   for (const prop of properties) {
     const [keyFrom, keyTo] = prop.keyRange
@@ -505,14 +487,10 @@ export function createMarkdownDecorationsPlugin(
           return cachedSet
         }
 
-        const { from, to } = state.selection
-
         cachedSet = buildDecorationsFromCache(
           state.doc,
           pluginState.contentCaches,
           pluginState.hasFocus,
-          from,
-          to,
           pluginState.markerMode,
         )
         cachedVersion = pluginState.decorationVersion

+ 16 - 0
packages/editor/src/lib/__tests__/content-model.test.ts

@@ -193,6 +193,22 @@ describe("roundtrip", () => {
     // Fences are preserved as-is in the text content
     expect(docToContent(doc)).toBe(original)
   })
+
+  it("code_block node has code spec flag", () => {
+    const doc = contentToDoc("```\ncode\n```")
+    expect(doc.child(0).type.spec.code).toBe(true)
+  })
+
+  it("math_block node has code spec flag", () => {
+    const doc = contentToDoc("$$\nmath\n$$")
+    expect(doc.child(0).type.spec.code).toBe(true)
+  })
+
+  it("tildes code block preserves language", () => {
+    const doc = contentToDoc("~~~python\ndef foo():\n    pass\n~~~")
+    expect(doc.child(0).type.name).toBe("code_block")
+    expect(doc.child(0).attrs.language).toBe("python")
+  })
 })
 
 describe("math block (content model)", () => {