Files
gitmost/packages/prosemirror-markdown/test/textalign.test.ts
claude code agent 227 f1ab76e879 feat(prosemirror-markdown): serialize textAlign as attached comment (#293 canon #9)
Move paragraph/heading textAlign off the HTML-wrapper form
(<p style="text-align:…"> / <hN style=…>) onto a trailing attached HTML
comment on the block line: `text <!--attrs {"textAlign":"center"}-->`. This
keeps the readable markdown block form (plain `text` / `## Title`) while
preserving alignment losslessly. "left"/null stay bare (no churn).

Adds a reusable attached-comment primitive (attached-comment.ts) that #4
(image) and #8 (media) will reuse:
- attachedCommentFor(name, json) -> `<!--name {compact-json}-->`, escaping any
  `--` pair inside the JSON as -- so the payload can never close the
  comment early;
- parseAttachedComment(data) with grammar `^\s*([A-Za-z][\w-]*)(?:\s+({…}))?\s*$`
  whose name excludes `:`, so envelope comments (docmost:meta / docmost:comments)
  never match — fail-open on anything malformed.

On import, applyAttachedComments runs AFTER marked.parse but BEFORE generateJSON
(parse5 drops comments), re-expressing the attrs comment as an inline
text-align style on the parent block, then removing the comment node.

Guards: emit only when there is a visible element to attach to — paragraph
requires non-empty text, heading requires non-empty headingText (symmetry:
an empty aligned heading stays bare `##`, no orphan comment).

Goldens in markdown-converter-golden/gaps updated deliberately to the
attached-comment form (assertions stay strict: exact output + lossless
round-trip). New textalign.test.ts (19 tests) covers center/right/justify on
paragraph and heading, byte-stable re-export, and fail-open branches.

Raw-HTML containers (columns/cells/callout via blockToHtml) keep the inline
text-align form intentionally — comments are dropped inside raw HTML.

package vitest: 462 passed | 1 expected-fail; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:39:46 +03:00

161 lines
7.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
// collaboration.ts and mutates global DOM at import time).
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
import {
attachedCommentFor,
parseAttachedComment,
} from '../src/lib/attached-comment.js';
// #293 canon decision #9: paragraph/heading `textAlign` serializes as an
// ATTACHED HTML comment at the END of the block line —
// `some text <!--attrs {"textAlign":"center"}-->`
// — replacing the old `<div align>` / `<p style="text-align:…">` wrappers, which
// did NOT round-trip cleanly (alignment was lost on the first stabilize pass).
// These tests are non-vacuous: they assert the EXACT emitted markdown (so they
// fail against any wrapper form) AND that the alignment survives a full
// PM -> MD -> PM round trip (which the old `<div align>` never did).
const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes });
const text = (t: string) => ({ type: 'text', text: t });
const para = (align: string | null, ...inline: any[]) => ({
type: 'paragraph',
attrs: align === null ? {} : { textAlign: align },
content: inline,
});
const heading = (level: number, align: string | null, ...inline: any[]) => ({
type: 'heading',
attrs: align === null ? { level } : { level, textAlign: align },
content: inline,
});
// Find the first paragraph/heading node in a generated doc (skips the doc root).
const firstBlock = (d: any) => d.content?.[0];
describe('attached-comment primitives (reusable for #9/#4/#8)', () => {
it('attachedCommentFor emits a compact `<!--name {json}-->`', () => {
expect(attachedCommentFor('attrs', { textAlign: 'center' })).toBe(
'<!--attrs {"textAlign":"center"}-->',
);
});
it('attachedCommentFor escapes a `--` pair so it cannot close the comment early', () => {
// A string value containing `--` would otherwise inject `-->`. Each hyphen of
// the pair is emitted as the JSON unicode escape -; JSON.parse restores
// the original hyphens on the reading side.
const s = attachedCommentFor('img', { alt: 'a--b' });
expect(s).toBe('<!--img {"alt":"a\\u002d\\u002db"}-->');
// No premature `--` inside the payload (between the `<!--` opener and the
// `-->` closer), so the comment cannot terminate early.
expect(s.slice(4, -3)).not.toContain('--');
// Round-trip through the parser primitive restores the exact value.
const inner = s.slice('<!--'.length, -'-->'.length);
expect(parseAttachedComment(inner)).toEqual({ name: 'img', attrs: { alt: 'a--b' } });
});
it('parseAttachedComment fails open on malformed JSON and non-objects', () => {
expect(parseAttachedComment('attrs {not json}')).toBeNull();
expect(parseAttachedComment('attrs [1,2]')).toBeNull();
expect(parseAttachedComment(' ')).toBeNull();
// Name-only comment is a valid marker with empty attrs.
expect(parseAttachedComment('attrs')).toEqual({ name: 'attrs', attrs: {} });
});
});
describe('paragraph.textAlign serialization (#293 #9)', () => {
for (const align of ['center', 'right', 'justify']) {
it(`paragraph textAlign "${align}" -> trailing <!--attrs--> comment`, () => {
expect(convertProseMirrorToMarkdown(doc(para(align, text('hello'))))).toBe(
`hello <!--attrs {"textAlign":"${align}"}-->`,
);
});
}
it('default textAlign (null) emits NO comment', () => {
expect(convertProseMirrorToMarkdown(doc(para(null, text('hello'))))).toBe('hello');
});
it('"left" (visual default) emits NO comment', () => {
expect(convertProseMirrorToMarkdown(doc(para('left', text('hello'))))).toBe('hello');
});
});
describe('heading.textAlign serialization (#293 #9)', () => {
it('heading keeps "## text" and attaches the alignment comment', () => {
expect(convertProseMirrorToMarkdown(doc(heading(2, 'center', text('Title'))))).toBe(
'## Title <!--attrs {"textAlign":"center"}-->',
);
});
it('default heading emits the bare "## text" form', () => {
expect(convertProseMirrorToMarkdown(doc(heading(3, null, text('Plain'))))).toBe('### Plain');
});
});
describe('paragraph.textAlign round-trip PM -> MD -> PM (#293 #9)', () => {
for (const align of ['center', 'right', 'justify']) {
it(`preserves paragraph textAlign "${align}"`, async () => {
const md1 = convertProseMirrorToMarkdown(doc(para(align, text('hello'))));
expect(md1).toBe(`hello <!--attrs {"textAlign":"${align}"}-->`);
const doc2 = await markdownToProseMirror(md1);
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign).toBe(align);
// Text is intact (the trailing space before the comment is trimmed).
expect(block.content?.[0]?.text).toBe('hello');
// Byte-stable second export closes the loop.
expect(convertProseMirrorToMarkdown(doc2)).toBe(md1);
});
}
it('default paragraph re-imports with textAlign null (no comment survives)', async () => {
const md1 = convertProseMirrorToMarkdown(doc(para(null, text('hello'))));
const doc2 = await markdownToProseMirror(md1);
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign ?? null).toBeNull();
});
});
describe('heading.textAlign round-trip PM -> MD -> PM (#293 #9)', () => {
for (const [level, align] of [
[2, 'center'],
[3, 'right'],
[1, 'justify'],
] as [number, string][]) {
it(`preserves h${level} textAlign "${align}"`, async () => {
const md1 = convertProseMirrorToMarkdown(doc(heading(level, align, text('Head'))));
expect(md1).toBe(`${'#'.repeat(level)} Head <!--attrs {"textAlign":"${align}"}-->`);
const doc2 = await markdownToProseMirror(md1);
const block = firstBlock(doc2);
expect(block.type).toBe('heading');
expect(block.attrs.level).toBe(level);
expect(block.attrs.textAlign).toBe(align);
expect(convertProseMirrorToMarkdown(doc2)).toBe(md1);
});
}
});
describe('attached-comment fail-open in the import pipeline (#293 #9)', () => {
it('a malformed attrs comment is ignored (default attrs kept)', async () => {
const doc2 = await markdownToProseMirror('hello <!--attrs {bad json}-->');
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign ?? null).toBeNull();
expect(block.content?.[0]?.text).toBe('hello');
});
it('an unknown key in a valid attrs comment is ignored, no comment leaks', async () => {
const doc2 = await markdownToProseMirror('hello <!--attrs {"bogus":"x"}-->');
const block = firstBlock(doc2);
expect(block.type).toBe('paragraph');
expect(block.attrs.textAlign ?? null).toBeNull();
// The unknown key and the comment marker must not survive into the body.
expect(block.content?.[0]?.text).toBe('hello');
const serialized = JSON.stringify(doc2);
expect(serialized).not.toContain('bogus');
expect(serialized).not.toContain('<!--');
});
});