/
githubmirror
/
marktext
Обзор
Документация
Войти
/
githubmirror
/
marktext
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
packages/muya/src/__tests__/frontMatter.spec.ts
282 строки
12 KB
Ran Luo
refactor(muya): decouple engine/block layers from src/ui plugin internals (#4587)
20 июн 2026, 10:04
Не верифицирован
20 июн 2026, 10:04
8ce0314
Код
Авторство
О чём код?
// @vitest-environment happy-dom import type Content from '../block/base/content'; import type Parent from '../block/base/parent'; import type { IFrontmatterState } from '../state/types'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { replaceBlockByLabel } from '../block/blockTransforms'; import { Muya } from '../muya'; // Coverage for the two front-matter entry points — the desktop Paragraph > // Front Matter menu item (`muya.updateParagraph('front-matter')`) and the // paragraph quick-insert menu ("/Front Matter", `replaceBlockByLabel`). Three // migration regressions are guarded here: // G5: front matter must be PREPENDED at document start and be idempotent, // never an in-place replacement of the cursor block (which destroyed the // block's content and produced invalid mid-document front matter). This // must hold for BOTH entry points — they share `insertFrontMatterAtStart`. // G2: the frontmatter `lang`/`style` must follow muya.options.frontmatterType // so '-'/'+'/';'/'{' serialize as yaml `---` / toml `+++` / json `;;;` / // json `{}` instead of always falling through to JSON braces. const bootedHosts: HTMLElement[] = []; let originalVersion: string | undefined; let hadVersion = false; beforeEach(() => { hadVersion = 'MUYA_VERSION' in window; originalVersion = window.MUYA_VERSION; window.MUYA_VERSION = 'test'; }); afterEach(() => { while (bootedHosts.length) { const host = bootedHosts.pop()!; host.remove(); } if (hadVersion) window.MUYA_VERSION = originalVersion as string; else delete (window as Partial<Window>).MUYA_VERSION; }); function bootMuya(markdown: string, frontmatterType?: string): Muya { const host = document.createElement('div'); document.body.appendChild(host); const options = { markdown } as ConstructorParameters<typeof Muya>[1] & { frontmatterType?: string }; if (frontmatterType !== undefined) options.frontmatterType = frontmatterType; const muya = new Muya(host, options); muya.init(); bootedHosts.push(muya.domNode); return muya; } function placeCursorOn(muya: Muya, blockIndex: number): Content { const block = muya.editor.scrollPage!.find(blockIndex) as unknown as Parent; const content = block.firstContentInDescendant()!; muya.editor.activeContentBlock = content; return content; } // The leaf block that directly wraps the cursor content — the `block` the // quick-insert menu passes to `replaceBlockByLabel` (`content.parent`). function leafAt(muya: Muya, blockIndex: number): Parent { const content = placeCursorOn(muya, blockIndex); return (content as unknown as { parent: Parent }).parent; } describe('muya.updateParagraph(\'front-matter\')', () => { it('prepends front matter at document start without touching the cursor block', async () => { const muya = bootMuya('first para\n\nsecond para\n'); // Cursor on the SECOND paragraph — legacy behavior ignores the cursor and // always targets document start. placeCursorOn(muya, 1); muya.updateParagraph('front-matter'); await vi.waitFor(() => { const state = muya.getState(); expect(state.length).toBe(3); expect(state[0].name).toBe('frontmatter'); }); const state = muya.getState(); // The two original paragraphs are preserved intact, in order, after the // new front matter block — none was replaced/destroyed. expect(state[1].name).toBe('paragraph'); expect(state[2].name).toBe('paragraph'); const md = muya.getMarkdown(); expect(md).toContain('first para'); expect(md).toContain('second para'); }); it('is idempotent — does not add a second front matter block', async () => { const muya = bootMuya('---\ntitle: hi\n---\n\nbody\n'); expect(muya.getState()[0].name).toBe('frontmatter'); placeCursorOn(muya, 1); muya.updateParagraph('front-matter'); await new Promise<void>(resolve => requestAnimationFrame(() => resolve())); const state = muya.getState(); const fmCount = state.filter(b => b.name === 'frontmatter').length; expect(fmCount).toBe(1); expect(muya.getMarkdown()).toContain('title: hi'); expect(muya.getMarkdown()).toContain('body'); }); it('default frontmatterType (\'-\') inserts a YAML block (--- fences)', async () => { const muya = bootMuya('body\n'); placeCursorOn(muya, 0); muya.updateParagraph('front-matter'); await vi.waitFor(() => { expect(muya.getState()[0].name).toBe('frontmatter'); }); expect((muya.getState()[0] as IFrontmatterState).meta.lang).toBe('yaml'); expect(muya.getMarkdown().startsWith('---\n')).toBe(true); }); it('frontmatterType \'+\' inserts a TOML block (+++ fences)', async () => { const muya = bootMuya('body\n', '+'); placeCursorOn(muya, 0); muya.updateParagraph('front-matter'); await vi.waitFor(() => { expect(muya.getState()[0].name).toBe('frontmatter'); }); expect((muya.getState()[0] as IFrontmatterState).meta.lang).toBe('toml'); expect(muya.getMarkdown().startsWith('+++\n')).toBe(true); }); it('frontmatterType \';\' inserts a JSON block (;;; fences)', async () => { const muya = bootMuya('body\n', ';'); placeCursorOn(muya, 0); muya.updateParagraph('front-matter'); await vi.waitFor(() => { expect(muya.getState()[0].name).toBe('frontmatter'); }); expect((muya.getState()[0] as IFrontmatterState).meta.lang).toBe('json'); expect(muya.getMarkdown().startsWith(';;;\n')).toBe(true); }); it('frontmatterType \'{\' inserts a JSON block (brace fences)', async () => { const muya = bootMuya('body\n', '{'); placeCursorOn(muya, 0); muya.updateParagraph('front-matter'); await vi.waitFor(() => { expect(muya.getState()[0].name).toBe('frontmatter'); }); const meta = (muya.getState()[0] as IFrontmatterState).meta; expect(meta.lang).toBe('json'); expect(meta.style).toBe('{'); // The `{` style serializes the JSON-braces variant, NOT the `;;;` fences. const md = muya.getMarkdown(); expect(md.startsWith('{\n')).toBe(true); expect(md.startsWith(';;;')).toBe(false); }); }); // Coverage for the OTHER front-matter entry point: the paragraph quick-insert // menu ("/Front Matter"), which routes through `replaceBlockByLabel`. The G5 // fix only covered `Muya.updateParagraph`; the quick-insert path still did an // in-place `block.replaceWith` that could create front matter MID-document by // converting an empty paragraph — front matter is only valid at document start. describe('quick-insert front matter (replaceBlockByLabel)', () => { it('inserts at document start, clears the `/` trigger, keeps other blocks', async () => { // The trigger paragraph carries the `/` the user typed to open the // quick-insert menu (its whole text matches `/^[/、]\S*$/`). const muya = bootMuya('first para\n\n/\n'); // Quick-insert is triggered on the SECOND paragraph (mid-document). The // menu passes the leaf block at the cursor as `block`. const block = leafAt(muya, 1); replaceBlockByLabel({ block, muya, label: 'frontmatter' }); await vi.waitFor(() => { const state = muya.getState(); expect(state[0].name).toBe('frontmatter'); }); const state = muya.getState(); // The new front matter is prepended; the non-trigger paragraph survives // intact (the in-place replace bug would have destroyed it) and the `/` // trigger paragraph remains as an emptied block — its `/` is cleared, // not left behind in the document. expect(state.length).toBe(3); expect(state[1].name).toBe('paragraph'); expect(state[2].name).toBe('paragraph'); const md = muya.getMarkdown(); expect(md).toContain('first para'); expect(md).not.toContain('/'); }); it('is idempotent — no second front matter block when one already exists', async () => { const muya = bootMuya('---\ntitle: hi\n---\n\nbody\n'); expect(muya.getState()[0].name).toBe('frontmatter'); const block = leafAt(muya, 1); replaceBlockByLabel({ block, muya, label: 'frontmatter' }); await new Promise<void>(resolve => requestAnimationFrame(() => resolve())); const state = muya.getState(); expect(state.filter(b => b.name === 'frontmatter').length).toBe(1); expect(muya.getMarkdown()).toContain('title: hi'); expect(muya.getMarkdown()).toContain('body'); }); it('honors frontmatterType when inserting via quick-insert (+ -> toml)', async () => { const muya = bootMuya('body\n', '+'); const block = leafAt(muya, 0); replaceBlockByLabel({ block, muya, label: 'frontmatter' }); await vi.waitFor(() => { expect(muya.getState()[0].name).toBe('frontmatter'); }); expect((muya.getState()[0] as IFrontmatterState).meta.lang).toBe('toml'); expect(muya.getMarkdown().startsWith('+++\n')).toBe(true); }); }); // The focused front-matter block renders before/after delimiter markers via the // CSS rules `pre.mu-active.mu-frontmatter::before/::after` (blockSyntax.css). Two // regressions are guarded: // - The block's class must be `mu-frontmatter`; the CSS once targeted the // non-existent `mu-front-matter`, so the markers never showed (muyajs parity). // - The marker text is driven by the `frontMatterStart` / `frontMatterEnd` // attributes on the <pre>, which mirror the real delimiters `stateToMarkdown` // emits per type (yaml `---`, toml `+++`, json `;;;`, json-braces `{` / `}`), // not a hardcoded `---`. describe('front matter delimiter marker', () => { function frontmatterPre(muya: Muya): HTMLElement { const block = muya.editor.scrollPage!.find(0) as unknown as Parent; return block.domNode!; } async function insertFrontMatter(frontmatterType?: string): Promise<HTMLElement> { const muya = bootMuya('body\n', frontmatterType); placeCursorOn(muya, 0); muya.updateParagraph('front-matter'); await vi.waitFor(() => { expect(muya.getState()[0].name).toBe('frontmatter'); }); return frontmatterPre(muya); } it('tags the front-matter <pre> with the class the marker CSS targets', () => { const muya = bootMuya('---\ntitle: hi\n---\n\nbody\n'); const pre = frontmatterPre(muya); expect(pre.tagName).toBe('PRE'); expect(pre.classList.contains('mu-frontmatter')).toBe(true); }); it('yaml (default \'-\') shows --- before and after', async () => { const pre = await insertFrontMatter(); expect(pre.getAttribute('frontMatterStart')).toBe('---'); expect(pre.getAttribute('frontMatterEnd')).toBe('---'); }); it('toml (\'+\') shows +++ before and after', async () => { const pre = await insertFrontMatter('+'); expect(pre.getAttribute('frontMatterStart')).toBe('+++'); expect(pre.getAttribute('frontMatterEnd')).toBe('+++'); }); it('json (\';\') shows ;;; before and after', async () => { const pre = await insertFrontMatter(';'); expect(pre.getAttribute('frontMatterStart')).toBe(';;;'); expect(pre.getAttribute('frontMatterEnd')).toBe(';;;'); }); it('json braces (\'{\') shows { before and } after', async () => { const pre = await insertFrontMatter('{'); expect(pre.getAttribute('frontMatterStart')).toBe('{'); expect(pre.getAttribute('frontMatterEnd')).toBe('}'); }); });