mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Merge branch 'stack-reconcile' into stack-final
# Conflicts: # src/renderer/src/components/editor/rich-markdown-extensions.ts # src/renderer/src/components/editor/rich-markdown-literal-serialization.ts
This commit is contained in:
@@ -163,7 +163,7 @@ export function EditorPanelShell({
|
||||
showMarkdownFrontmatter={markdownFrontmatterVisible}
|
||||
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
|
||||
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
|
||||
onOpenMarkdownPreview={onOpenMarkdownPreview}
|
||||
onOpenMarkdownPreview={model.canOpenPreviewToSide ? onOpenMarkdownPreview : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
<UntitledFileRenameDialog
|
||||
|
||||
@@ -112,7 +112,8 @@ export function findDetailsBlockStart(content: string): number {
|
||||
if (isInsideRange(index, fenceRanges) || isInsideRange(index, codeSpanRanges)) {
|
||||
continue
|
||||
}
|
||||
const lineStart = content.lastIndexOf('\n', index - 1) + 1
|
||||
const lineStart =
|
||||
Math.max(content.lastIndexOf('\n', index - 1), content.lastIndexOf('\r', index - 1)) + 1
|
||||
const indent = content.slice(lineStart, index)
|
||||
if (indent.length <= 3 && /^ *$/.test(indent)) {
|
||||
return index
|
||||
|
||||
@@ -77,7 +77,9 @@ describe('getEditorPanelRenderModel rich-mode fallback toggle', () => {
|
||||
it('offers Preview in Source view once a stored fault matches the current content', () => {
|
||||
const model = renderModel({
|
||||
markdownViewMode: { '/repo/README.md': 'source' },
|
||||
editorDrafts: { '/repo/README.md': '[reference]: https://example.com' },
|
||||
fileContents: {
|
||||
'/repo/README.md': textContent({ content: '[reference]: https://example.com' })
|
||||
},
|
||||
markdownRichModeFaultedContent: {
|
||||
'/repo/README.md': '[reference]: https://example.com'
|
||||
}
|
||||
@@ -100,7 +102,9 @@ describe('getEditorPanelRenderModel rich-mode fallback toggle', () => {
|
||||
|
||||
it('offers Preview when Rich view classifies live and this content falls back', () => {
|
||||
const model = renderModel({
|
||||
editorDrafts: { '/repo/README.md': '[reference]: https://example.com' }
|
||||
fileContents: {
|
||||
'/repo/README.md': textContent({ content: '[reference]: https://example.com' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(model.availableEditorToggleModes).toEqual(['source', 'rich', 'preview', 'changes'])
|
||||
@@ -301,3 +305,11 @@ describe('getEditorPanelRenderModel markdown export affordance', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('hides disk preview while a fallback draft is unsaved', () => {
|
||||
const model = renderModel({
|
||||
editorDrafts: { '/repo/README.md': '[reference]: https://example.com' }
|
||||
})
|
||||
expect(model.availableEditorToggleModes).not.toContain('preview')
|
||||
expect(model.canOpenPreviewToSide).toBe(false)
|
||||
})
|
||||
|
||||
@@ -144,11 +144,16 @@ export function getEditorPanelRenderModel({
|
||||
richModeEligibility !== null
|
||||
? richModeEligibility.exceedsSizeLimit || richModeUnsupportedMessage !== null
|
||||
: richModeFaultedForCurrentContent
|
||||
const hasUnsavedMarkdownDraft =
|
||||
viewerLanguage === 'markdown' &&
|
||||
activeFile.mode === 'edit' &&
|
||||
editorDrafts[activeFile.id] !== undefined &&
|
||||
editorDrafts[activeFile.id] !== inlineFileContent?.content
|
||||
const editorToggleModes = getEditorToggleModes({
|
||||
language: viewerLanguage,
|
||||
mode: activeFile.mode,
|
||||
diffSource: activeFile.diffSource,
|
||||
richModeFallsBackToSource
|
||||
richModeFallsBackToSource: richModeFallsBackToSource && !hasUnsavedMarkdownDraft
|
||||
})
|
||||
const availableEditorToggleModes =
|
||||
isBinaryEditSurface || !canUseChangesModeForFile(activeFile)
|
||||
@@ -200,6 +205,7 @@ export function getEditorPanelRenderModel({
|
||||
// files and commit diffs whose content may not match the working tree).
|
||||
canOpenPreviewToSide:
|
||||
canOpenWorkspaceFileBrowser &&
|
||||
!hasUnsavedMarkdownDraft &&
|
||||
canPreviewLanguage(viewerLanguage) &&
|
||||
(activeFile.mode === 'edit' || (isSingleDiff && openFileState.canOpen)),
|
||||
mdViewMode,
|
||||
|
||||
@@ -173,3 +173,9 @@ describe('rich editing of Markdown documents with HTML comments', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let image-alt comments bypass reference definitions', () => {
|
||||
expect(
|
||||
getMarkdownRichModeUnsupportedReason('\n\n[id]: https://example.com')
|
||||
).toBe('reference-links')
|
||||
})
|
||||
|
||||
@@ -320,10 +320,10 @@ describe('getMarkdownRichModeUnsupportedMessage', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('conservatively blocks a pre-filter match in a document past the parse-size guard', () => {
|
||||
it('does not infer a reference definition from document size', () => {
|
||||
const content = `${'a'.repeat(50_001)}\n[Bug]: text with spaces\n`
|
||||
|
||||
expect(getMarkdownRichModeUnsupportedMessage(content)).not.toBeNull()
|
||||
expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull()
|
||||
})
|
||||
|
||||
it('still parses to confirm a pre-filter match at or under the parse-size guard', () => {
|
||||
|
||||
@@ -131,12 +131,6 @@ export function getMarkdownRichModeUnsupportedReason(
|
||||
const htmlMatcher = UNSUPPORTED_PATTERNS.find((m) => m.reason === 'html-or-jsx')
|
||||
const hasHtml = htmlMatcher && hasHtmlOrJsx(contentWithoutCode, htmlMatcher.pattern)
|
||||
|
||||
// HTML comments inside image alt text are Markdown label content, not embedded
|
||||
// document HTML; they remain literal through the rich serializer.
|
||||
if (hasHtml && hasOnlyImageAltComments(contentWithoutCode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const matcher of UNSUPPORTED_PATTERNS) {
|
||||
if (matcher.reason === 'html-or-jsx') {
|
||||
continue
|
||||
@@ -150,7 +144,9 @@ export function getMarkdownRichModeUnsupportedReason(
|
||||
return matcher.reason
|
||||
}
|
||||
|
||||
if (hasHtml) {
|
||||
// HTML comments inside image alt text are Markdown label content, not embedded
|
||||
// document HTML; they remain literal through the rich serializer.
|
||||
if (hasHtml && !hasOnlyImageAltComments(contentWithoutCode)) {
|
||||
// The source codec recognizes multiline code spans that the cheap scan can misclassify.
|
||||
const htmlOutput = getRichMarkdownHtmlValidationOutput(body)
|
||||
if (htmlOutput && preservesEmbeddedHtml(body, htmlOutput)) {
|
||||
@@ -214,7 +210,14 @@ function hasLinkReferenceDefinition(content: string): boolean {
|
||||
return false
|
||||
}
|
||||
if (commentStripped.length > 50_000) {
|
||||
return true
|
||||
// Probe candidate paragraphs separately; document size alone is not syntax evidence.
|
||||
return commentStripped
|
||||
.split(/\r?\n[ \t]*\r?\n/)
|
||||
.some(
|
||||
(block) =>
|
||||
/^[ \t>*+\-\d.)]*\[[^\]]+\]:/m.test(block) &&
|
||||
containsDefinitionNode(linkReferenceDefinitionProcessor.parse(block))
|
||||
)
|
||||
}
|
||||
const tree = linkReferenceDefinitionProcessor.parse(commentStripped)
|
||||
return containsDefinitionNode(tree)
|
||||
|
||||
@@ -7,166 +7,30 @@ import type { SlashCommandId } from './rich-markdown-slash-commands'
|
||||
import { slashCommands } from './rich-markdown-slash-commands'
|
||||
|
||||
const REPORT_DOCUMENT_FIXTURE = [
|
||||
'# Fix report: details blocks disabling rich mode',
|
||||
'# Markdown details',
|
||||
'',
|
||||
'## Decision: option A (stop unconditionally writing the styling class into markdown output)',
|
||||
'A paragraph with `class="orca-details"` and a nested toggle.',
|
||||
'',
|
||||
'Traced every consumer of `class="orca-details"` before choosing:',
|
||||
'<details class="orca-details">',
|
||||
'<summary>Outer</summary>',
|
||||
'',
|
||||
"- The rendered DOM node gets the class independently, from `OrcaDetails.configure({ HTMLAttributes: { class: 'orca-details' } })` in `rich-markdown-details-extension.ts` — this does not read the markdown source string at all.",
|
||||
'- `renderDetailsAttributes` (`details-markdown-html.ts`) had exactly one call site: the markdown serializer itself. Nothing else in the pipeline reads the class from serialized markdown text.',
|
||||
'- The tokenizer already treats the class as optional: `hasOnlySupportedDetailsAttributes` strips it if present but never requires it, and `isEditableDetailsHtmlBlock` accepts a bare `<details>` with no class. Any other class value (or any other unrecognized attribute, e.g. `id="x"`) fails that check entirely and the block is kept as passthrough HTML, whose raw source bytes are never touched by the details serializer.',
|
||||
"- The read-only preview (`MarkdownPreviewBody.tsx`) allowlists `className: 'orca-details'` through `rehype-sanitize` *if the source HTML already has it* — it does not inject the class. A GitHub-authored file with no class already rendered unstyled in preview before this fix; that is unchanged, not something this fix touches.",
|
||||
'Body with **bold** text, a [link](https://example.com), and a list:',
|
||||
'',
|
||||
"A newly created or user-authored `<details>` block should never have Orca write a styling class into its markdown source — GitHub-flavored files never carry that class, and the DOM styling path doesn't need it in the source. But a file already saved by an earlier Orca version does carry the class in its source, and dropping it unconditionally caused a second bug: the round-trip eligibility check compares serialized output against the source's literal bytes, so a legacy file that loses its class on save stops matching and regresses back to Source mode.",
|
||||
'- one',
|
||||
'- two',
|
||||
'',
|
||||
'The final design: `parseDetailsAttributes` records whether the block\'s opening tag had the legacy class (`hasLegacyStylingClass`), that flag is carried on the TipTap node as a genuine schema attribute (declared in `OrcaDetails.addAttributes`, alongside the existing `variant` attribute, so it survives edits and undo), and `renderDetailsAttributes` re-emits `class="orca-details"` only when the flag is set. A block with no class in its source stays classless through every subsequent save; a block that already had the class keeps it.',
|
||||
'',
|
||||
"User-provided attributes (a custom `class`, an `id`, etc.) don't need special handling under this design — they never reach the details tokenizer at all. `isEditableDetailsHtmlBlock` rejects any attribute outside its allowlist (`open`, the legacy class, `data-orca-toggle`), so those blocks are treated as opaque passthrough HTML and their source bytes round-trip verbatim, untouched by `renderDetailsAttributes`. Verified directly (see below).",
|
||||
'',
|
||||
'## Commits',
|
||||
'',
|
||||
'- `93f807fbfd59a9854ecb3bf24c0a3a1051a9b79f` — initial fix: serializer stops always writing the class.',
|
||||
'- `6213aa9a99fb7717a5d968b6bbd21a00d66e2374` — follow-up: preserves the class when the source already had it, fixing a legacy-file regression the initial commit introduced.',
|
||||
'',
|
||||
'Both on branch `fb/rtme-details-roundtrip` in `/Users/fbarthelemy/Code/orca/fb-rtme-details/`.',
|
||||
'',
|
||||
'## Review findings and verification',
|
||||
'',
|
||||
"Before making the follow-up change, ran three ad hoc probe tests against the first commit's code to check for regressions, all removed after verification:",
|
||||
'',
|
||||
'- Case — Source — Result before fix #2',
|
||||
'- Legacy Orca-saved file — `<details class="orca-details">…` — `getMarkdownRichModeUnsupportedReason` returned `\'html-or-jsx\'` — **confirmed regression**, the file would fall back to Source mode.',
|
||||
'- User custom class — `<details class="my-notes" open>…` — Round-tripped byte-identical to input; eligibility `null`. Passthrough HTML, unaffected.',
|
||||
'- User `id` attribute — `<details id="x">…` — Round-tripped byte-identical to input; eligibility `null`. Passthrough HTML, unaffected.',
|
||||
'',
|
||||
'After the follow-up commit, re-ran the same three probes plus a bare-details control:',
|
||||
'',
|
||||
'- Case — Result after fix #2',
|
||||
'- Legacy Orca-saved file — `getMarkdownRichModeUnsupportedReason` returns `null`; round-trips byte-identical to source.',
|
||||
"- Bare `<details>` with no class — Still gets no class on save (fix #1's intent preserved).",
|
||||
'- User custom class / `id` — Unchanged — still passthrough, still byte-identical.',
|
||||
'',
|
||||
'All four passed. These are now permanent tests: `markdown-rich-mode.test.ts` ("allows a details block already carrying the legacy orca-details class"), `markdown-round-trip.test.ts` ("round-trips an orca-authored nested toggle unchanged", restored to its original byte-identical assertion, plus a new "does not backfill the legacy styling class onto a freshly nested toggle" case covering a legacy outer block wrapping a freshly authored inner block).',
|
||||
'',
|
||||
'## Diff summary',
|
||||
'',
|
||||
'**Commit 1** (`93f807fb`):',
|
||||
'',
|
||||
'- `details-markdown-html.ts`: `renderDetailsAttributes` stops prepending `class="orca-details"` unconditionally.',
|
||||
'- `rich-markdown-details-extension.ts`: `renderMarkdown` only adds a leading space before the attribute string when attributes exist, avoiding a stray `<details >`.',
|
||||
'- Test files updated to match the new (classless) serialized output.',
|
||||
'',
|
||||
'**Commit 2** (`6213aa9a`):',
|
||||
'',
|
||||
"- `rich-markdown-details-extension.ts`: `OrcaDetails.addAttributes` gains `hasLegacyStylingClass` (default `false`, not a DOM attribute — `parseHTML`/`renderHTML` are no-ops since it's markdown-only state).",
|
||||
'- `details-markdown-html.ts`: `parseDetailsAttributes` sets `hasLegacyStylingClass` from a new `LEGACY_STYLING_CLASS_PATTERN` regex matching only the exact `class="orca-details"` form (the only class value `hasOnlySupportedDetailsAttributes` tolerates). `renderDetailsAttributes` re-emits the class when that flag is `true`.',
|
||||
'- `markdown-rich-mode.test.ts`: added the legacy-class eligibility regression test.',
|
||||
'- `markdown-round-trip.test.ts`: restored the nested-legacy-toggle test to its original byte-identical assertion (previously incorrectly rewritten in commit 1 to expect the class dropped); added the backfill-prevention test.',
|
||||
'',
|
||||
'## Commands run (final state, after both commits)',
|
||||
'',
|
||||
'- Command — Result',
|
||||
'- `pnpm test src/renderer/src/components/editor` — pass — 208 files, 1416 tests, 2 skipped',
|
||||
'- `pnpm tc` — pass — no type errors',
|
||||
'- `npx oxlint` (full repo) — pass — exit 0, no findings',
|
||||
'- `npx oxfmt --write` on changed files — applied, no changes needed on the second pass',
|
||||
'- pre-commit hook (oxlint + oxfmt via lint-staged), both commits — pass, no manual fixes needed',
|
||||
'',
|
||||
'## Draft: GitHub issue',
|
||||
'',
|
||||
'**Title:** `[Bug]: Markdown files with <details> blocks always open in Source mode`',
|
||||
'',
|
||||
'**Body:**',
|
||||
'',
|
||||
'### Operating system',
|
||||
'',
|
||||
'macOS',
|
||||
'',
|
||||
'### Orca version',
|
||||
'',
|
||||
'1.4.198',
|
||||
'',
|
||||
'### Details',
|
||||
'',
|
||||
'Opening a markdown file that contains a `<details>` disclosure block always lands in Source mode with the "this file contains HTML, JSX, or MDX" banner, even for the smallest possible block and even though Orca has a dedicated rich-editor extension for details blocks that never gets a chance to run.',
|
||||
'',
|
||||
'Minimal reproduction: create a markdown file containing exactly this and open it in Orca.',
|
||||
'',
|
||||
'```markdown',
|
||||
'<details>',
|
||||
'<summary>x</summary>',
|
||||
'',
|
||||
'body',
|
||||
'',
|
||||
'</details>',
|
||||
'```text',
|
||||
'<details> this is code',
|
||||
'```',
|
||||
'',
|
||||
'Expected: the file opens in the rich editor with the details block rendered as a collapsible toggle.',
|
||||
'<details>',
|
||||
'<summary>Inner</summary>',
|
||||
'',
|
||||
'Actual: the file falls back to Source mode with the HTML/JSX/MDX banner.',
|
||||
'Nested body.',
|
||||
'',
|
||||
'The mechanism is that the markdown serializer always adds `class="orca-details"` to the `<details>` tag it writes out ([`renderDetailsAttributes`](https://github.com/stablyai/orca/blob/7dd183d82dafb768bb2506d161438a6d0894fd53/src/renderer/src/components/editor/details-markdown-html.ts#L79-L92)), and the rich-mode eligibility check compares that serialized output against the file\'s literal opening tag byte-for-byte ([`preservesEmbeddedHtml`](https://github.com/stablyai/orca/blob/7dd183d82dafb768bb2506d161438a6d0894fd53/src/renderer/src/components/editor/markdown-rich-mode.ts#L211-L221)), so a source file without that class never matches and the file is treated as unsupported HTML.',
|
||||
'</details>',
|
||||
'',
|
||||
'## Draft: PR body',
|
||||
'',
|
||||
'## ELI5',
|
||||
'',
|
||||
'Any markdown file with a `<details>` disclosure block used to always open in Source mode instead of the rich editor, even for a two-line block with no attributes.',
|
||||
'',
|
||||
'## What Changed',
|
||||
'',
|
||||
'The markdown serializer only writes `class="orca-details"` into a saved `<details>` tag when the file\'s own source already had that class. A block authored fresh, or one that came from a plain markdown file, never gets the class written into it, since the class is already applied to the rendered editor node independently through the extension\'s `HTMLAttributes` configuration.',
|
||||
'',
|
||||
'## Why',
|
||||
'',
|
||||
"Rich-mode eligibility detects embedded HTML and then verifies it survives a round trip through the editor by comparing the re-serialized output against the source's literal opening tag. The serializer used to always add a class the source didn't have, so that comparison failed for every `<details>` block that didn't already carry the class, and the file fell back to Source mode. Making the class conditional on the source removes the mismatch at its origin, and keeps files already saved by Orca (which do carry the class) round-tripping unchanged.",
|
||||
'',
|
||||
'## Linked Issue',
|
||||
'',
|
||||
'Fixes #TBD',
|
||||
'',
|
||||
'## Visual Proof',
|
||||
'',
|
||||
'**Before:** opening a markdown file containing a details block lands in Source mode with the HTML banner, and the collapsible toggle is not interactive.',
|
||||
'',
|
||||
'**After:** the same file opens in the rich editor with the details block rendered as a collapsible block.',
|
||||
'',
|
||||
'## Testing',
|
||||
'',
|
||||
'- [ ] I manually tested these changes locally',
|
||||
'- [x] Automated tests added/updated, or explained why not below',
|
||||
'',
|
||||
"Added regression tests in `markdown-rich-mode.test.ts` for a minimal bare details block, one with `open`, one already carrying the legacy styling class, and a full document shape (front matter, prose, a details block with bold text/link/list/fenced code, a second `<details open>` block). Updated existing round-trip assertions in `markdown-round-trip.test.ts` and `rich-markdown-details-keyboard.test.ts` to match; added a test confirming a legacy-class outer block doesn't backfill the class onto a freshly authored nested block; confirmed by test that user-provided attributes like a custom `class` or `id` are unaffected, since those blocks are treated as passthrough HTML rather than going through this serializer. `pnpm test`, `pnpm tc`, and `oxlint` all pass.",
|
||||
'',
|
||||
'## AI Disclosure',
|
||||
'',
|
||||
'Authored with Claude Code (Claude Fable 5.1 and Sonnet teammates) under my review.',
|
||||
'',
|
||||
'## Review',
|
||||
'',
|
||||
"Removes a source-mutating side effect from the details-block markdown serializer: it now writes the styling class into a saved file's `<details>` tag only when the file's source already carried that class, since the class is otherwise applied independently to the rendered DOM node. The unconditional injection was why any file with a details block failed the rich-mode round-trip check and fell back to Source mode; making it conditional both fixes new files and keeps files already saved by Orca round-tripping unchanged. User-provided attributes are unaffected, since blocks with attributes outside the small supported set are treated as opaque passthrough HTML rather than going through this serializer.",
|
||||
'',
|
||||
'## Agent skill upstream boundary',
|
||||
'',
|
||||
'- [x] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.',
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance',
|
||||
'',
|
||||
'## Checklist',
|
||||
'',
|
||||
'- [x] This PR is small and focused',
|
||||
'- [x] I explained what changed and why (including ELI5)',
|
||||
'- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason',
|
||||
'- [x] Self-reviewed for correctness, security, and performance',
|
||||
'- [x] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)',
|
||||
'- [x] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)',
|
||||
'',
|
||||
'## Author',
|
||||
'',
|
||||
'X: [@fbartho](https://x.com/fbartho). I prefer Mastodon: [@fbartho@mastodon.social](https://mastodon.social/@fbartho).'
|
||||
'</details>'
|
||||
].join('\n')
|
||||
|
||||
function roundTripMarkdown(content: string): string {
|
||||
@@ -970,3 +834,10 @@ describe('rich markdown round trip', () => {
|
||||
expect(roundTripMarkdown(input)).toBe('```\n[[not-a-link]]\n```')
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves code spacing inside a table across repeated saves', () => {
|
||||
const source = '| code |\n| --- |\n| `a b` |'
|
||||
const once = roundTripMarkdown(source)
|
||||
expect(once).toContain('`a b`')
|
||||
expect(roundTripMarkdown(once)).toBe(once)
|
||||
})
|
||||
|
||||
@@ -124,22 +124,25 @@ describe('findDetailsBlockStart cost on documents without a toggle', () => {
|
||||
expect(codeSpanSpy).toHaveBeenCalledTimes(paragraphs.length)
|
||||
})
|
||||
|
||||
it.skipIf(process.env.ORCA_DETAILS_SCAN_BENCH !== '1')('benchmarks a large toggle-free document', () => {
|
||||
const paragraphs = Array.from(
|
||||
{ length: 300 },
|
||||
(_, index) => `Paragraph ${index} ${'lorem ipsum dolor sit amet '.repeat(25)}`
|
||||
)
|
||||
const document = paragraphs.join('\n\n')
|
||||
it.skipIf(process.env.ORCA_DETAILS_SCAN_BENCH !== '1')(
|
||||
'benchmarks a large toggle-free document',
|
||||
() => {
|
||||
const paragraphs = Array.from(
|
||||
{ length: 300 },
|
||||
(_, index) => `Paragraph ${index} ${'lorem ipsum dolor sit amet '.repeat(25)}`
|
||||
)
|
||||
const document = paragraphs.join('\n\n')
|
||||
|
||||
const started = performance.now()
|
||||
let offset = 0
|
||||
for (const paragraph of paragraphs) {
|
||||
expect(findDetailsBlockStart(document.slice(offset))).toBe(-1)
|
||||
offset += paragraph.length + 2
|
||||
const started = performance.now()
|
||||
let offset = 0
|
||||
for (const paragraph of paragraphs) {
|
||||
expect(findDetailsBlockStart(document.slice(offset))).toBe(-1)
|
||||
offset += paragraph.length + 2
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ elapsedMs: performance.now() - started })}\n`)
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ elapsedMs: performance.now() - started })}\n`)
|
||||
})
|
||||
)
|
||||
|
||||
it('still finds a toggle that follows a long run of prose', () => {
|
||||
const prose = Array.from({ length: 300 }, (_, index) => `Paragraph ${index}.`).join('\n\n')
|
||||
@@ -195,3 +198,8 @@ describe('findDetailsBlockStart with a non-breaking space after a fence closer',
|
||||
expect(findDetailsBlockStart(content)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hide text behind escaped backticks or invalid fence info', () => {
|
||||
expect(markdownCodeSpanRanges('\\`literal\\`', [])).toEqual([])
|
||||
expect(markdownFenceRanges('```bad`info\nbody')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -23,6 +23,13 @@ export function markdownFenceRanges(content: string): MarkdownFenceRanges {
|
||||
} else {
|
||||
const openingFenceMatch = lineText.match(/^ {0,3}(`{3,}|~{3,})/u)
|
||||
if (openingFenceMatch?.[1]) {
|
||||
if (
|
||||
openingFenceMatch[1][0] === '`' &&
|
||||
lineText.slice(openingFenceMatch[0].length).includes('`')
|
||||
) {
|
||||
offset += line.length
|
||||
continue
|
||||
}
|
||||
openFence = {
|
||||
closingPattern: new RegExp(
|
||||
// CommonMark 4.5: a closing fence may be followed only by spaces or
|
||||
@@ -82,6 +89,15 @@ export function markdownCodeSpanRanges(
|
||||
continue
|
||||
}
|
||||
|
||||
let backslashes = 0
|
||||
for (let cursor = index - 1; cursor >= 0 && content[cursor] === '\\'; cursor -= 1) {
|
||||
backslashes += 1
|
||||
}
|
||||
if (backslashes % 2 === 1) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let tickCount = 0
|
||||
while (content[index + tickCount] === '`') {
|
||||
tickCount += 1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderRichMarkdownCodeBlock } from './rich-markdown-code-block-markdown'
|
||||
|
||||
describe('code fence info boundaries', () => {
|
||||
it.each([
|
||||
['foo`bar', 'body', '~~~foo`bar\nbody\n~~~'],
|
||||
['~foo', 'x```y', '~~~ ~foo\nx```y\n~~~']
|
||||
])('preserves language %s', (language, body, expected) => {
|
||||
expect(
|
||||
renderRichMarkdownCodeBlock({ attrs: { language } }, { renderChildren: () => body })
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -18,10 +18,10 @@ function longestFenceRun(text: string, character: '`' | '~'): number {
|
||||
return longest
|
||||
}
|
||||
|
||||
function chooseFence(text: string): { character: '`' | '~'; length: number } {
|
||||
function chooseFence(text: string, language: string): { character: '`' | '~'; length: number } {
|
||||
const backtickLength = Math.max(3, longestFenceRun(text, '`') + 1)
|
||||
const tildeLength = Math.max(3, longestFenceRun(text, '~') + 1)
|
||||
return tildeLength < backtickLength
|
||||
return language.includes('`') || tildeLength < backtickLength
|
||||
? { character: '~', length: tildeLength }
|
||||
: { character: '`', length: backtickLength }
|
||||
}
|
||||
@@ -32,7 +32,11 @@ export function renderRichMarkdownCodeBlock(
|
||||
): string {
|
||||
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
|
||||
const body = helpers.renderChildren(node.content ?? [])
|
||||
const fence = chooseFence(body)
|
||||
const fence = chooseFence(body, language)
|
||||
const marker = fence.character.repeat(fence.length)
|
||||
return [`${marker}${language}`, body, marker].join('\n')
|
||||
return [
|
||||
`${marker}${language.startsWith(fence.character) ? ' ' : ''}${language}`,
|
||||
body,
|
||||
marker
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ describe('code span padding round trip', () => {
|
||||
['Read `Anexo v2.docx ` and write up.'],
|
||||
['Read ` Anexo v2.docx` and write up.'],
|
||||
['Plain `code` here.'],
|
||||
['Authored \uE000\uE001\uE002\uE003 and ` padded` text'],
|
||||
['`a\uE000b` and ` padded`'],
|
||||
['**bold** and `code` and *it*'],
|
||||
['`a` and `b`'],
|
||||
['[`label`](https://example.com)'],
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
// Why: a private-use code point cannot appear in a markdown document, so it can stand
|
||||
// in for padding while the mark-boundary walk runs and be restored afterwards.
|
||||
const PADDING_PLACEHOLDER = String.fromCharCode(0xe000)
|
||||
|
||||
type MarkdownNodeLike = {
|
||||
type?: string
|
||||
text?: string
|
||||
@@ -32,30 +28,39 @@ function hasCodeMark(node: MarkdownNodeLike): boolean {
|
||||
* which is right for emphasis (`** text **` is not emphasis) and wrong for a code
|
||||
* span, where CommonMark strips one pad on render and the source keeps its bytes.
|
||||
*/
|
||||
export function maskCodeSpanPadding(nodes: MarkdownNodeLike[]): MarkdownNodeLike[] {
|
||||
function paddingPlaceholder(nodes: MarkdownNodeLike[]): string {
|
||||
const source = JSON.stringify(nodes)
|
||||
let placeholder = '\uE000'
|
||||
while (source.includes(placeholder)) {
|
||||
placeholder += '\uE000'
|
||||
}
|
||||
return placeholder
|
||||
}
|
||||
|
||||
export function maskCodeSpanPadding(
|
||||
nodes: MarkdownNodeLike[],
|
||||
placeholder = paddingPlaceholder(nodes)
|
||||
): MarkdownNodeLike[] {
|
||||
return nodes.map((node) => {
|
||||
if (node?.type !== 'text' || !hasCodeMark(node)) {
|
||||
return node
|
||||
}
|
||||
const text = node.text ?? ''
|
||||
const leading = text.match(/^(\s+)/)?.[1] ?? ''
|
||||
const trailing = text.match(/(\s+)$/)?.[1] ?? ''
|
||||
const trailing = text.slice(leading.length).match(/(\s+)$/)?.[1] ?? ''
|
||||
if (!leading && !trailing) {
|
||||
return node
|
||||
}
|
||||
const body = text.slice(leading.length, trailing ? text.length - trailing.length : text.length)
|
||||
return {
|
||||
...node,
|
||||
text:
|
||||
PADDING_PLACEHOLDER.repeat(leading.length) +
|
||||
body +
|
||||
PADDING_PLACEHOLDER.repeat(trailing.length)
|
||||
text: placeholder.repeat(leading.length) + body + placeholder.repeat(trailing.length)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreCodeSpanPadding(markdown: string): string {
|
||||
return markdown.split(PADDING_PLACEHOLDER).join(' ')
|
||||
export function restoreCodeSpanPadding(markdown: string, placeholder = '\uE000'): string {
|
||||
return markdown.split(placeholder).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,8 +87,9 @@ export const RichMarkdownCodeSpanPadding = Extension.create({
|
||||
nodes: MarkdownNodeLike[],
|
||||
...rest: unknown[]
|
||||
) {
|
||||
const rendered = walk.call(this, maskCodeSpanPadding(nodes ?? []), ...rest)
|
||||
return typeof rendered === 'string' ? restoreCodeSpanPadding(rendered) : rendered
|
||||
const placeholder = paddingPlaceholder(nodes ?? [])
|
||||
const rendered = walk.call(this, maskCodeSpanPadding(nodes ?? [], placeholder), ...rest)
|
||||
return typeof rendered === 'string' ? restoreCodeSpanPadding(rendered, placeholder) : rendered
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
|
||||
export const RichMarkdownLink = Link.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
rawHref: { default: null, rendered: false },
|
||||
originalHref: { default: null, rendered: false }
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) =>
|
||||
helpers.applyMark('link', helpers.parseInline(token.tokens || []), {
|
||||
href: token.href,
|
||||
title: token.title || null,
|
||||
rawHref: extractRawDestination(token.raw, token.href),
|
||||
originalHref: token.href
|
||||
}),
|
||||
renderMarkdown: (node, helpers) => {
|
||||
const href =
|
||||
node.attrs?.href === node.attrs?.originalHref
|
||||
? (node.attrs?.rawHref ?? node.attrs?.href ?? '')
|
||||
: (node.attrs?.href ?? '')
|
||||
const title = node.attrs?.title ?? ''
|
||||
const text = helpers.renderChildren(node)
|
||||
return title ? `[${text}](${href} "${title}")` : `[${text}](${href})`
|
||||
}
|
||||
})
|
||||
|
||||
function extractRawDestination(raw: string | undefined, href: string | undefined): string | null {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const open = raw.indexOf('](')
|
||||
const close = raw.lastIndexOf(')')
|
||||
if (open === -1 || close <= open + 2) {
|
||||
return null
|
||||
}
|
||||
const destination = raw.slice(open + 2, close).trim()
|
||||
const titleStart = destination.search(/\s+["']|\s+\(/)
|
||||
const candidate = titleStart >= 0 ? destination.slice(0, titleStart) : destination
|
||||
const decoded = candidate
|
||||
.replace(/^<(.*)>$/, '$1')
|
||||
.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/g, '$1')
|
||||
return decoded === href ? candidate : null
|
||||
}
|
||||
|
||||
export const RichMarkdownImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
rawSrc: { default: null, rendered: false },
|
||||
originalSrc: { default: null, rendered: false }
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) =>
|
||||
helpers.createNode('image', {
|
||||
src: token.href,
|
||||
alt: token.text || '',
|
||||
title: token.title,
|
||||
rawSrc: extractRawDestination(token.raw, token.href),
|
||||
originalSrc: token.href
|
||||
}),
|
||||
renderMarkdown: (node) => {
|
||||
const src =
|
||||
node.attrs?.src === node.attrs?.originalSrc
|
||||
? (node.attrs?.rawSrc ?? node.attrs?.src ?? '')
|
||||
: (node.attrs?.src ?? '')
|
||||
const alt = node.attrs?.alt ?? ''
|
||||
const title = node.attrs?.title ?? ''
|
||||
return title ? `` : ``
|
||||
}
|
||||
})
|
||||
@@ -117,7 +117,7 @@ describe('rich markdown escape round trip', () => {
|
||||
|
||||
it('does not invent a backslash on money-like $5', () => {
|
||||
expect(expectStable('cost $5\n')).toBe('cost $5')
|
||||
expect(expectStable('cost \\$5\n')).not.toContain('\\$')
|
||||
expect(expectStable('cost \\$5\n')).toBe('cost \\$5')
|
||||
expect(inspect('cost $5\n').inlineMath).toBe(0)
|
||||
})
|
||||
|
||||
@@ -260,3 +260,23 @@ describe('rich markdown escape round trip', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'\\* `$HOME`',
|
||||
'\\* `a & b`',
|
||||
'\\* Copyright © 2026',
|
||||
'cost \\$5\n\n```\nx && y < z\n```',
|
||||
'a \\& b and [x](http://h/?a=1&b=2)',
|
||||
'[`[text](url)`](https://docs)'
|
||||
])('preserves mixed escaped prose through repeated saves: %s', (source) => {
|
||||
const before = inspect(source)
|
||||
let output = source
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
const next = inspect(output)
|
||||
expect(next.text).toBe(before.text)
|
||||
expect(next.href).toBe(before.href)
|
||||
expect(next.types).toEqual(before.types)
|
||||
output = next.markdown
|
||||
}
|
||||
expect(roundTrip(output)).toBe(output)
|
||||
})
|
||||
|
||||
@@ -42,22 +42,8 @@ export const RichMarkdownEscapedCharacter = Mark.create({
|
||||
{ type: 'text', text: character }
|
||||
])
|
||||
},
|
||||
// Why: a mark's markdown is one prefix for the whole run, so `\*\*` would come out as `\**`;
|
||||
// this is only the fallback for a serializer that bypasses getMarkdown.
|
||||
renderMarkdown: (node, helpers) => {
|
||||
const rendered = helpers.renderChildren(node)
|
||||
const plain = rendered.replace(/^\\+/, '')
|
||||
if (node.marks?.some((mark) => mark.type === 'code')) {
|
||||
return plain
|
||||
}
|
||||
if (plain === '&') {
|
||||
return '&'
|
||||
}
|
||||
if (plain === '<') {
|
||||
return '<'
|
||||
}
|
||||
return escapedCharacterSourceText(plain, false)
|
||||
},
|
||||
// Mark renderers receive a delimiter probe, so text escaping belongs in the text encoder.
|
||||
renderMarkdown: (node, helpers) => helpers.renderChildren(node),
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `span[${MARKER_ATTRIBUTE}]` }]
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { AnyExtension } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Code } from '@tiptap/extension-code'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { RichMarkdownLink, RichMarkdownImage } from './rich-markdown-destinations'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { createRichMarkdownTable } from './rich-markdown-table'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { BlockMath, InlineMath } from '@tiptap/extension-mathematics'
|
||||
import { BlockMath } from '@tiptap/extension-mathematics'
|
||||
import { createRichMarkdownExtension } from './rich-markdown-extension'
|
||||
import { createLowlight, common } from 'lowlight'
|
||||
import {
|
||||
@@ -42,6 +41,7 @@ import { RichMarkdownListItem } from './rich-markdown-list-item'
|
||||
import { RichMarkdownProseEntities } from './rich-markdown-prose-entities'
|
||||
import { RichMarkdownParagraph } from './rich-markdown-paragraph'
|
||||
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
|
||||
import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
|
||||
import { RichMarkdownEscapedCharacter } from './rich-markdown-escaped-character'
|
||||
import { RichMarkdownTaskList } from './rich-markdown-task-list'
|
||||
import { createCachedLowlight } from './rich-markdown-lowlight-cache'
|
||||
@@ -49,63 +49,6 @@ import { renderRichMarkdownCodeBlock } from './rich-markdown-code-block-markdown
|
||||
|
||||
const lowlight = createCachedLowlight(createLowlight(common))
|
||||
|
||||
const RichMarkdownLink = Link.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
rawHref: { default: null, rendered: false },
|
||||
originalHref: { default: null, rendered: false }
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) =>
|
||||
helpers.applyMark('link', helpers.parseInline(token.tokens || []), {
|
||||
href: token.href,
|
||||
title: token.title || null,
|
||||
rawHref: extractRawDestination(token.raw),
|
||||
originalHref: token.href
|
||||
}),
|
||||
renderMarkdown: (node, helpers) => {
|
||||
const href =
|
||||
node.attrs?.rawHref && node.attrs?.href === node.attrs?.originalHref
|
||||
? node.attrs.rawHref
|
||||
: (node.attrs?.href ?? '')
|
||||
const title = node.attrs?.title ?? ''
|
||||
const text = helpers.renderChildren(node)
|
||||
return title ? `[${text}](${href} "${title}")` : `[${text}](${href})`
|
||||
}
|
||||
})
|
||||
|
||||
function extractRawDestination(raw: string | undefined): string | null {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const open = raw.indexOf('](')
|
||||
const close = raw.lastIndexOf(')')
|
||||
if (open === -1 || close <= open + 2) {
|
||||
return null
|
||||
}
|
||||
const destination = raw.slice(open + 2, close).trim()
|
||||
const titleStart = destination.search(/\s+["']|\s+\(/)
|
||||
return titleStart >= 0 ? destination.slice(0, titleStart) : destination
|
||||
}
|
||||
|
||||
// Why: Pandoc's rule keeps money as text — both `$` must touch the formula, the closing one
|
||||
// must not be followed by a digit, and an escaped `\$` never closes.
|
||||
const INLINE_MATH_PATTERN = /^\$(?![\s$])((?:\\[\s\S]|[^$\\])*?)(?<!\s)\$(?!\d)/
|
||||
const RichMarkdownInlineMath = InlineMath.extend({
|
||||
markdownTokenizer: {
|
||||
name: 'inlineMath',
|
||||
level: 'inline',
|
||||
start: (src: string) => src.indexOf('$'),
|
||||
tokenize: (src: string) => {
|
||||
const match = src.match(INLINE_MATH_PATTERN)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
return { type: 'inlineMath', raw: match[0], latex: match[1] }
|
||||
}
|
||||
}
|
||||
})
|
||||
const BLOCK_MATH_START_PATTERN = /\n[ \t]*\$\$/
|
||||
const BLOCK_MATH_PATTERN = /^[ \t]*\$\$((?:(?!\$\$)[\s\S])+?)\$\$/
|
||||
const RichMarkdownBlockMath = BlockMath.extend({
|
||||
@@ -180,31 +123,7 @@ export function createRichMarkdownExtensions({
|
||||
// file:// URLs in <img> tags are blocked by cross-origin restrictions.
|
||||
// A nodeView loads local images via IPC → blob URL, which bypasses this
|
||||
// and works identically in dev and production modes.
|
||||
Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
rawSrc: { default: null, rendered: false },
|
||||
originalSrc: { default: null, rendered: false }
|
||||
}
|
||||
},
|
||||
parseMarkdown: (token, helpers) =>
|
||||
helpers.createNode('image', {
|
||||
src: token.href,
|
||||
alt: token.text || '',
|
||||
title: token.title,
|
||||
rawSrc: extractRawDestination(token.raw),
|
||||
originalSrc: token.href
|
||||
}),
|
||||
renderMarkdown: (node) => {
|
||||
const src =
|
||||
node.attrs?.rawSrc && node.attrs?.src === node.attrs?.originalSrc
|
||||
? node.attrs.rawSrc
|
||||
: (node.attrs?.src ?? '')
|
||||
const alt = node.attrs?.alt ?? ''
|
||||
const title = node.attrs?.title ?? ''
|
||||
return title ? `` : ``
|
||||
},
|
||||
RichMarkdownImage.extend({
|
||||
addStorage() {
|
||||
return {
|
||||
contextVersion: 0,
|
||||
|
||||
@@ -31,6 +31,11 @@ export function splitRichMarkdownHeading(editor: Editor): boolean {
|
||||
if (!empty || $from.parent.type.name !== 'heading') {
|
||||
return false
|
||||
}
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
if (['tableCell', 'tableHeader'].includes($from.node(depth).type.name)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if ($from.parentOffset === 0 || $from.parentOffset === $from.parent.content.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import type { Editor, JSONContent } from '@tiptap/core'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
import { RICH_MARKDOWN_ESCAPED_CHARACTER_MARK } from './rich-markdown-escaped-character'
|
||||
|
||||
const DOLLAR_SKIP_TYPES = new Set(['inlineMath', 'rawMarkdownHtmlInline'])
|
||||
|
||||
const CHEAP_NEEDS_WORK = /\$|\||\\[&<]|\\[[\]]|^( {0,3})(#|-|\d+\.)( |$)/m
|
||||
const CHEAP_NEEDS_WORK = /\$|\||\\[[\]]|^( {0,3})(#|[+-]|\d+\.)( |$)/m
|
||||
const REF_DEF = /^ {0,3}\[[^\n]*\]:/m
|
||||
|
||||
type BlockInfo = { block: ProseMirrorNode; inTableCell: boolean }
|
||||
@@ -54,6 +53,9 @@ function hasBare(markdown: string, chars: string): boolean {
|
||||
}
|
||||
|
||||
function destNeedsEscape(dest: string): boolean {
|
||||
if (/\s/.test(dest)) {
|
||||
return true
|
||||
}
|
||||
let depth = 0
|
||||
let oddBackslash = false
|
||||
for (let i = 0; i < dest.length; i += 1) {
|
||||
@@ -80,7 +82,7 @@ function escapeLineLeading(markdown: string): string {
|
||||
// writes paragraph text "1." and tests assert getMarkdown() === '1.\n\n'.
|
||||
return markdown
|
||||
.replace(/^( {0,3})#(?= |$)/gm, '$1\\#')
|
||||
.replace(/^( {0,3})-(?= )/gm, '$1\\-')
|
||||
.replace(/^( {0,3})([+-])(?= )/gm, '$1\\$2')
|
||||
.replace(/^( {0,3})(\d+)\.(?= )/gm, '$1$2\\.')
|
||||
}
|
||||
|
||||
@@ -170,13 +172,19 @@ function escapeLinkAndImageAttributes(node: JSONContent): void {
|
||||
forEachLinkOrImage(node, (attrs, kind) => {
|
||||
const destKey = kind === 'image' ? 'src' : 'href'
|
||||
const dest = attrs[destKey]
|
||||
const rawDest = kind === 'link' ? attrs.rawHref : attrs.rawSrc
|
||||
const originalDest = kind === 'link' ? attrs.originalHref : attrs.originalSrc
|
||||
if (typeof rawDest === 'string' && dest === originalDest) {
|
||||
attrs[destKey] = rawDest
|
||||
const rawKey = kind === 'image' ? 'rawSrc' : 'rawHref'
|
||||
const originalKey = kind === 'image' ? 'originalSrc' : 'originalHref'
|
||||
const raw = attrs[rawKey]
|
||||
const keepRaw = typeof raw === 'string' && dest === attrs[originalKey]
|
||||
if (keepRaw) {
|
||||
attrs[destKey] = raw
|
||||
attrs[originalKey] = raw
|
||||
}
|
||||
if (typeof dest === 'string' && destNeedsEscape(dest)) {
|
||||
attrs[destKey] = escapeBare(dest, '()')
|
||||
if (!keepRaw && typeof dest === 'string' && destNeedsEscape(dest)) {
|
||||
attrs[destKey] = escapeBare(
|
||||
dest.replace(/\s/g, (character) => encodeURIComponent(character)),
|
||||
'()'
|
||||
)
|
||||
}
|
||||
if (typeof attrs.title === 'string' && hasBare(attrs.title, '"')) {
|
||||
attrs.title = escapeBare(attrs.title, '"')
|
||||
@@ -256,23 +264,10 @@ export function preserveLiteralMarkdownSource(
|
||||
return cached.result
|
||||
}
|
||||
let result = markdown
|
||||
const preservesEscapedCharacters = blockHasEscapedCharacters(info.block)
|
||||
const isPlainEscapedBlock = preservesEscapedCharacters && blockHasOnlyEscapeMarks(info.block)
|
||||
if (isPlainEscapedBlock && !info.inTableCell) {
|
||||
result = result.replace(/\\\$(?=\d)/g, '$')
|
||||
}
|
||||
if (preservesEscapedCharacters) {
|
||||
result = info.inTableCell
|
||||
? result.replace(/(?<!\\)\$(?=\d)/g, '\\$&')
|
||||
: result.replace(/(?<!\\)\$(?=\d,)/g, '\\$&')
|
||||
}
|
||||
if (preservesEscapedCharacters && !blockHasInlineMath(info.block)) {
|
||||
result = result.replace(/\$(?!\d)/g, '\\$&')
|
||||
}
|
||||
if (node.type === 'paragraph' && !info.inTableCell) {
|
||||
result = escapeLineLeading(result)
|
||||
}
|
||||
if (!hasRefDefs && !preservesEscapedCharacters) {
|
||||
if (!hasRefDefs) {
|
||||
const droppedBrackets = dropOptionalEscapes(result, false)
|
||||
if (droppedBrackets !== result && proves(droppedBrackets, info.block)) {
|
||||
result = droppedBrackets
|
||||
@@ -286,12 +281,7 @@ export function preserveLiteralMarkdownSource(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
shouldTryDollar(info.block) &&
|
||||
!isPlainEscapedBlock &&
|
||||
/\$/.test(result) &&
|
||||
!proves(result, info.block)
|
||||
) {
|
||||
if (shouldTryDollar(info.block) && /\$/.test(result) && !proves(result, info.block)) {
|
||||
const dollared = escapeBareDollarsSkippingCode(result)
|
||||
if (dollared !== result && proves(dollared, info.block)) {
|
||||
result = dollared
|
||||
@@ -310,65 +300,16 @@ export function preserveLiteralMarkdownSource(
|
||||
const idle = serialize()
|
||||
hasRefDefs = REF_DEF.test(withoutOptionalEscapes(idle))
|
||||
const json = editor.getJSON()
|
||||
if (
|
||||
!hasRefDefs &&
|
||||
!CHEAP_NEEDS_WORK.test(idle) &&
|
||||
!needsAttrRepair(json) &&
|
||||
!hasEscapedEntityMark(json)
|
||||
) {
|
||||
if (!hasRefDefs && !CHEAP_NEEDS_WORK.test(idle) && !needsAttrRepair(json)) {
|
||||
return idle
|
||||
}
|
||||
escapeLinkAndImageAttributes(json)
|
||||
blocks = new Map()
|
||||
pairBlocks(json, editor.state.doc, false, blocks)
|
||||
try {
|
||||
const output = manager.serialize(json)
|
||||
const withEntities = hasEscapedEntityMark(json)
|
||||
? output.replace(/&(?!amp;|lt;|gt;|#\w+;)/g, '&').replace(/<(?!\/?[A-Za-z])/g, '<')
|
||||
: output
|
||||
return withEntities
|
||||
return manager.serialize(json)
|
||||
} finally {
|
||||
blocks = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasEscapedEntityMark(node: JSONContent): boolean {
|
||||
return Boolean(
|
||||
node.marks?.some((mark) => mark.type === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK) ||
|
||||
node.content?.some((child) => hasEscapedEntityMark(child))
|
||||
)
|
||||
}
|
||||
|
||||
function blockHasEscapedCharacters(block: ProseMirrorNode): boolean {
|
||||
let found = false
|
||||
block.descendants((node) => {
|
||||
if (node.marks.some((mark) => mark.type.name === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK)) {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
function blockHasInlineMath(block: ProseMirrorNode): boolean {
|
||||
let found = false
|
||||
block.descendants((node) => {
|
||||
if (node.type.name === 'inlineMath') {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
function blockHasOnlyEscapeMarks(block: ProseMirrorNode): boolean {
|
||||
let onlyEscapeMarks = true
|
||||
block.descendants((node) => {
|
||||
if (
|
||||
node.isText &&
|
||||
node.marks.some((mark) => mark.type.name !== RICH_MARKDOWN_ESCAPED_CHARACTER_MARK)
|
||||
) {
|
||||
onlyEscapeMarks = false
|
||||
}
|
||||
})
|
||||
return onlyEscapeMarks
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Extension, type JSONContent } from '@tiptap/core'
|
||||
|
||||
import {
|
||||
escapedCharacterSourceText,
|
||||
RICH_MARKDOWN_ESCAPED_CHARACTER_MARK
|
||||
} from './rich-markdown-escaped-character'
|
||||
|
||||
const TAG_OPENING = /^<(?:[a-zA-Z][a-zA-Z0-9-]*|\/[a-zA-Z][a-zA-Z0-9-]*|!|\?)/
|
||||
|
||||
type MarkdownTextEncoder = {
|
||||
encodeTextForMarkdown?: (text: string, node: unknown, parentNode?: unknown) => string
|
||||
encodeTextForMarkdown?: (text: string, node: JSONContent, parentNode?: JSONContent) => string
|
||||
}
|
||||
|
||||
function isMarkdownTextEncoder(value: unknown): value is MarkdownTextEncoder {
|
||||
@@ -53,6 +58,11 @@ export const RichMarkdownProseEntities = Extension.create({
|
||||
return
|
||||
}
|
||||
managerValue.encodeTextForMarkdown = (text, node, parentNode) => {
|
||||
if (node.marks?.some((mark) => mark.type === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK)) {
|
||||
const insideCode =
|
||||
parentNode?.type === 'codeBlock' || node.marks.some((mark) => mark.type === 'code')
|
||||
return escapedCharacterSourceText(text, insideCode)
|
||||
}
|
||||
const encoded = base.call(managerValue, text, node, parentNode)
|
||||
return encoded === text ? text : encodeProseTextForMarkdown(text)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/core'
|
||||
import { markdownCodeSpanRanges } from './markdown-scan-ranges'
|
||||
|
||||
type TableCellAlign = 'left' | 'right' | 'center' | null
|
||||
type TableCell = { text: string; isHeader: boolean; align: TableCellAlign }
|
||||
@@ -10,7 +11,13 @@ const MAX_ALIGNED_TABLE_WIDTH = 160
|
||||
const CELL_LINE_SEPARATOR = '\u001F'
|
||||
|
||||
function collapseWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim()
|
||||
let offset = 0
|
||||
let result = ''
|
||||
for (const [start, end] of markdownCodeSpanRanges(value, [])) {
|
||||
result += value.slice(offset, start).replace(/\s+/g, ' ') + value.slice(start, end)
|
||||
offset = end
|
||||
}
|
||||
return (result + value.slice(offset).replace(/\s+/g, ' ')).trim()
|
||||
}
|
||||
|
||||
function normalizeAlign(attrs: Record<string, unknown> | undefined): TableCellAlign {
|
||||
|
||||
Reference in New Issue
Block a user