mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Merge branch 'stack-structure' into stack-preview
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -200,7 +200,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 {
|
||||
@@ -755,3 +619,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { InlineMath } from '@tiptap/extension-mathematics'
|
||||
|
||||
// Why: the common dialect requires a non-space next to each delimiter and forbids a
|
||||
// newline inside, which is what keeps `US$ 5,000 and R$ 40,000` out of a math span.
|
||||
const INLINE_MATH = /^\$(?![\s$])((?:[^$\n]*[^\s$])?)\$(?!\$)/
|
||||
const INLINE_MATH = /^\$(?![\s$])((?:\\[^\n]|[^$\\\n])*?)(?<!\s)\$(?![\d$])/
|
||||
|
||||
const baseTokenizer = InlineMath.config.markdownTokenizer
|
||||
if (!baseTokenizer) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
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 }
|
||||
@@ -79,7 +79,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\\.')
|
||||
}
|
||||
|
||||
|
||||
@@ -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