diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index ea624b62..f8fc51bc 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -13,12 +13,18 @@ import { closeTables } from './table.ts' * @param options - `frontmatter` completes an unclosed leading frontmatter block. * `syntax: false` disables Comark component-fence handling (`::` closers and * props braces), for input parsed without the components plugin. + * `attributes: true` still treats `{...}` as an attribute scope (so `_` / `*` + * inside values are not closed) without enabling component fences. * @returns The markdown with unclosed syntax closed */ -export function autoCloseMarkdown(markdown: string, options: { frontmatter?: boolean; syntax?: boolean } = {}): string { +export function autoCloseMarkdown( + markdown: string, + options: { frontmatter?: boolean; syntax?: boolean; attributes?: boolean } = {} +): string { if (!markdown || markdown === '') return markdown const syntaxEnabled = options.syntax !== false + const attributesEnabled = options.attributes ?? syntaxEnabled const lines = markdown.split('\n') const n = lines.length @@ -152,7 +158,7 @@ export function autoCloseMarkdown(markdown: string, options: { frontmatter?: boo // Fix inline markers on last line (skip inside block-level structures) const lastIdx = n - 1 if (!inFrontmatter && !inBlockMath && lines[lastIdx].trim() !== '$$') { - lines[lastIdx] = closeInlineMarkersLinear(lines[lastIdx], syntaxEnabled) + lines[lastIdx] = closeInlineMarkersLinear(lines[lastIdx], attributesEnabled) } let result = lines.join('\n') @@ -256,9 +262,9 @@ function scanDelimiterRun(line: string, start: number, marker: string) { * Closes inline markers (*, **, ***, ~~, `, $, $$, [, () on the last line * without using regex - pure character scanning in O(n) time * - * With `syntax` false, `{...}` is literal text instead of an attribute scope. + * With `attributesEnabled` false, `{...}` is literal text instead of an attribute scope. */ -function closeInlineMarkersLinear(line: string, syntax: boolean): string { +function closeInlineMarkersLinear(line: string, attributesEnabled: boolean): string { const len = line.length if (len === 0) return line @@ -318,11 +324,11 @@ function closeInlineMarkersLinear(line: string, syntax: boolean): string { continue } - if (syntax && ch === '{' && prevCh !== ' ') { + if (attributesEnabled && ch === '{' && prevCh !== ' ') { inAttributes++ continue } - if (syntax && ch === '}') { + if (attributesEnabled && ch === '}') { if (inAttributes > 0) inAttributes-- continue } diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index b85d296e..9fda91e9 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -144,6 +144,7 @@ export function createMarkdownParser { expect(autoCloseMarkdown('text{.cls **bold', { syntax: false })).toBe('text{.cls **bold**') }) + it('skips _ inside attribute values when attributes are enabled without component syntax', () => { + expect( + autoCloseMarkdown('this is a [link with an attribute](https://example.com){target="_blank"}', { + syntax: false, + attributes: true, + }) + ).toBe('this is a [link with an attribute](https://example.com){target="_blank"}') + }) + + it('does not treat unclosed :: as a component when only attributes are enabled', () => { + expect(autoCloseMarkdown('::alert\nContent', { syntax: false, attributes: true })).toBe('::alert\nContent') + }) + it('still completes frontmatter with syntax: false', () => { expect(autoCloseMarkdown('---\ntitle: Hello', { frontmatter: true, syntax: false })).toBe('---\ntitle: Hello\n---') }) diff --git a/packages/comark/test/plugins/default-plugins.test.ts b/packages/comark/test/plugins/default-plugins.test.ts index 4738d907..2db2dbaf 100644 --- a/packages/comark/test/plugins/default-plugins.test.ts +++ b/packages/comark/test/plugins/default-plugins.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { parseMarkdown } from '../../src/parse' +import attributes from '../../src/plugins/attributes' import components from '../../src/plugins/components' import frontmatter from '../../src/plugins/frontmatter' import html from '../../src/plugins/html' @@ -128,5 +129,27 @@ describe('default plugin options', () => { }) expect(tree.nodes).toEqual([['alert', {}, 'Content']]) }) + + it('renders attributes when only attributes enabled', async () => { + const tree = await parseMarkdown('this is a [link with an attribute](https://example.com){target="_blank"}', { + registerDefaultPlugins: false, + plugins: [attributes()], + }) + expect(tree.nodes).toEqual([ + [ + 'p', + {}, + 'this is a ', + [ + 'a', + { + href: 'https://example.com', + target: '_blank', + }, + 'link with an attribute', + ], + ], + ]) + }) }) })