diff --git a/packages/comark-angular/src/components/markdown.component.ts b/packages/comark-angular/src/components/markdown.component.ts index efe3108c..b6f56dc1 100644 --- a/packages/comark-angular/src/components/markdown.component.ts +++ b/packages/comark-angular/src/components/markdown.component.ts @@ -44,7 +44,7 @@ export class Markdown implements OnChanges { @Input() value?: string | MarkdownDocumentType /** Parser options (excluding plugins) */ - @Input() options: Exclude = {} + @Input() options: Omit = {} /** Additional plugins to use */ @Input() plugins: ParserOptions['plugins'] = [] diff --git a/packages/comark-react/src/components/Markdown.tsx b/packages/comark-react/src/components/Markdown.tsx index 2e40b999..1f843bde 100644 --- a/packages/comark-react/src/components/Markdown.tsx +++ b/packages/comark-react/src/components/Markdown.tsx @@ -20,7 +20,7 @@ export interface MarkdownProps { /** * Parser options (excluding plugins) */ - options?: Exclude + options?: Omit /** * Parser to use instead of one resolved from `options` and `plugins` diff --git a/packages/comark-react/src/index.ts b/packages/comark-react/src/index.ts index c6e4c994..c4519055 100644 --- a/packages/comark-react/src/index.ts +++ b/packages/comark-react/src/index.ts @@ -60,7 +60,7 @@ export function defineMarkdownComponent(config: DefineMarkdownComponentOptions = } = config const MarkdownComponent: React.FC = (props) => { - const mergedOptions: Exclude = { + const mergedOptions: Omit = { ...parseOptions, ...props.options, } diff --git a/packages/comark-svelte/src/types.ts b/packages/comark-svelte/src/types.ts index 2f510a51..191446ea 100644 --- a/packages/comark-svelte/src/types.ts +++ b/packages/comark-svelte/src/types.ts @@ -37,7 +37,7 @@ export interface MarkdownDocumentProps { export interface MarkdownProps { /** The markdown content to parse and render, or a pre-parsed MarkdownDocument */ value?: string | MarkdownDocument - options?: Exclude + options?: Omit plugins?: ComarkPlugin[] /** diff --git a/packages/comark-vue/src/components/Markdown.ts b/packages/comark-vue/src/components/Markdown.ts index e7f28d6c..5967e3dd 100644 --- a/packages/comark-vue/src/components/Markdown.ts +++ b/packages/comark-vue/src/components/Markdown.ts @@ -17,7 +17,7 @@ export interface MarkdownProps { /** * Parser options (excluding plugins) */ - options?: Exclude + options?: Omit /** * Additional plugins to use @@ -68,10 +68,125 @@ export interface MarkdownProps { * Additional data to pass to the renderer */ data?: Record + + /** + * Document key used to subscribe to live updates via `globalThis.comarkContext` + */ + documentKey?: string } type MarkdownComponent = ReturnType> +/** + * Runtime prop declarations for ``. + * + * Exported so `defineMarkdownComponent` can reuse them verbatim instead of + * hand-copying the table, which had drifted: `value` was declared `String` + * only, and `data` and `documentKey` were missing entirely. + */ +export const markdownProps = { + /** + * The markdown content to parse and render, or a pre-parsed MarkdownDocument + */ + value: { + type: [String, Object] as PropType, + default: undefined, + }, + + /** + * Parser options + */ + options: { + type: Object as PropType>, + default: () => ({}), + }, + + /** + * Additional plugins to use + */ + plugins: { + type: Array as PropType, + default: () => [], + }, + + /** + * Parser to use instead of one resolved from `options` and `plugins` + */ + parser: { + type: Function as PropType, + default: undefined, + }, + + /** + * Strip wrapper tags from the top level of the document — shorthand for + * `options.unwrap`. `true` unwraps `

`; a space-separated string or array + * unwraps the listed tags. + */ + unwrap: { + type: [Boolean, String, Array] as PropType, + default: false, + }, + + /** + * Custom component mappings for element tags + * Key: tag name (e.g., 'h1', 'p', 'MyComponent') + * Value: Vue component + */ + components: { + type: Object as PropType>, + default: () => ({}), + }, + + /** + * Dynamic component resolver function + * Used to resolve components that aren't in the components map + */ + componentsManifest: { + type: Function as PropType, + default: undefined, + }, + + /** + * Enable streaming mode with stream-specific components + */ + streaming: { + type: Boolean as PropType, + default: false, + }, + + /** + * If document has a comment, only render the content before the comment + */ + summary: { + type: Boolean as PropType, + default: false, + }, + + /** + * If caret is true, a caret will be appended to the document's last text node + */ + caret: { + type: [Boolean, Object] as PropType, + default: false, + }, + + /** + * Additional data to pass to the renderer + */ + data: { + type: Object as PropType>, + default: () => ({}), + }, + + /** + * Document key used to subscribe to live updates via `globalThis.comarkContext` + */ + documentKey: { + type: String as PropType, + default: undefined, + }, +} as const + /** * Markdown component * @@ -107,100 +222,7 @@ type MarkdownComponent = ReturnType> export const Markdown: MarkdownComponent = defineComponent({ name: 'Markdown', - props: { - /** - * The markdown content to parse and render, or a pre-parsed MarkdownDocument - */ - value: { - type: [String, Object] as PropType, - default: undefined, - }, - - /** - * Parser options - */ - options: { - type: Object as PropType>, - default: () => ({}), - }, - - /** - * Additional plugins to use - */ - plugins: { - type: Array as PropType, - default: () => [], - }, - - /** - * Parser to use instead of one resolved from `options` and `plugins` - */ - parser: { - type: Function as PropType, - default: undefined, - }, - - /** - * Strip wrapper tags from the top level of the document — shorthand for - * `options.unwrap`. `true` unwraps `

`; a space-separated string or array - * unwraps the listed tags. - */ - unwrap: { - type: [Boolean, String, Array] as PropType, - default: false, - }, - - /** - * Custom component mappings for element tags - * Key: tag name (e.g., 'h1', 'p', 'MyComponent') - * Value: Vue component - */ - components: { - type: Object as PropType>, - default: () => ({}), - }, - - /** - * Dynamic component resolver function - * Used to resolve components that aren't in the components map - */ - componentsManifest: { - type: Function as PropType, - default: undefined, - }, - - /** - * Enable streaming mode with stream-specific components - */ - streaming: { - type: Boolean as PropType, - default: false, - }, - - /** - * If document has a comment, only render the content before the comment - */ - summary: { - type: Boolean as PropType, - default: false, - }, - - /** - * If caret is true, a caret will be appended to the document's last text node - */ - caret: { - type: [Boolean, Object] as PropType, - default: false, - }, - - /** - * Additional data to pass to the renderer - */ - data: { - type: Object as PropType>, - default: () => ({}), - }, - }, + props: markdownProps, async setup(props, ctx) { const markdown = computed(() => { @@ -269,6 +291,7 @@ export const Markdown: MarkdownComponent = defineComponent({ class: props.streaming ? 'comark-stream' : '', caret: props.caret, data: props.data, + documentKey: props.documentKey, }) } @@ -281,6 +304,7 @@ export const Markdown: MarkdownComponent = defineComponent({ class: props.streaming ? 'comark-stream' : '', caret: props.caret, data: props.data, + documentKey: props.documentKey, }) } }, diff --git a/packages/comark-vue/src/components/MarkdownDocument.ts b/packages/comark-vue/src/components/MarkdownDocument.ts index bb48216d..530870c3 100644 --- a/packages/comark-vue/src/components/MarkdownDocument.ts +++ b/packages/comark-vue/src/components/MarkdownDocument.ts @@ -280,6 +280,75 @@ export interface MarkdownDocumentProps { type MarkdownDocumentComponent = ReturnType> +/** + * Runtime prop declarations for ``. + * + * Exported so `defineMarkdownDocumentComponent` can reuse them verbatim + * instead of hand-copying the table, which was missing `data` and + * `documentKey`. + */ +export const markdownDocumentProps = { + /** + * The parsed Markdown document to render + */ + value: { + type: Object as PropType, + default: undefined, + }, + + /** + * Custom component mappings for element tags + * Key: tag name (e.g., 'h1', 'p', 'MyComponent') + * Value: Vue component + */ + components: { + type: Object as PropType>, + default: () => ({}), + }, + + /** + * Dynamic component resolver function + * Used to resolve components that aren't in the components map + */ + componentsManifest: { + type: Function as PropType, + default: undefined, + }, + + /** + * Enable streaming mode with stream-specific components + */ + streaming: { + type: Boolean as PropType, + default: false, + }, + + /** + * If caret is true, a caret will be appended to the document's last text node + * If caret is an object, it will be appended with the given class + */ + caret: { + type: [Boolean, Object] as PropType, + default: false, + }, + + /** + * Additional data to pass to the renderer + */ + data: { + type: Object as PropType>, + default: () => ({}), + }, + + /** + * Document key used to subscribe to live updates via `globalThis.comarkContext` + */ + documentKey: { + type: String as PropType, + default: undefined, + }, +} as const + /** * MarkdownDocument component * @@ -308,67 +377,7 @@ type MarkdownDocumentComponent = ReturnType, - default: undefined, - }, - - /** - * Custom component mappings for element tags - * Key: tag name (e.g., 'h1', 'p', 'MyComponent') - * Value: Vue component - */ - components: { - type: Object as PropType>, - default: () => ({}), - }, - - /** - * Dynamic component resolver function - * Used to resolve components that aren't in the components map - */ - componentsManifest: { - type: Function as PropType, - default: undefined, - }, - - /** - * Enable streaming mode with stream-specific components - */ - streaming: { - type: Boolean as PropType, - default: false, - }, - - /** - * If caret is true, a caret will be appended to the document's last text node - * If caret is an object, it will be appended with the given class - */ - caret: { - type: [Boolean, Object] as PropType, - default: false, - }, - - /** - * Additional data to pass to the renderer - */ - data: { - type: Object as PropType>, - default: () => ({}), - }, - - /** - * Document key used to subscribe to live updates via `globalThis.comarkContext` - */ - documentKey: { - type: String as PropType, - default: undefined, - }, - }, + props: markdownDocumentProps, async setup(props) { const inputDocument = computed( diff --git a/packages/comark-vue/src/index.ts b/packages/comark-vue/src/index.ts index 8a789e17..51df5cbf 100644 --- a/packages/comark-vue/src/index.ts +++ b/packages/comark-vue/src/index.ts @@ -1,8 +1,7 @@ -import type { PropType } from 'vue' import { computed, defineComponent, h } from 'vue' -import { Markdown } from './components/Markdown.ts' -import type { MarkdownDocument as MarkdownDocumentType, ComponentManifest, ParserOptions } from 'comark' -import { MarkdownDocument } from './components/MarkdownDocument.ts' +import { Markdown, markdownProps } from './components/Markdown.ts' +import type { ParserOptions } from 'comark' +import { MarkdownDocument, markdownDocumentProps } from './components/MarkdownDocument.ts' export { Markdown } from './components/Markdown.ts' export type { MarkdownProps } from './components/Markdown.ts' @@ -36,84 +35,7 @@ export function defineMarkdownComponent(config: DefineMarkdownComponentOptions = return defineComponent({ name: name ?? 'MarkdownComponent', - props: { - /** - * The markdown content to parse and render - */ - value: { - type: String as PropType, - default: undefined, - }, - - /** - * Parser options - */ - options: { - type: Object as PropType>, - default: () => ({}), - }, - - /** - * Additional plugins to use - */ - plugins: { - type: Array as PropType, - default: () => [], - }, - - /** - * Strip wrapper tags from the top level of the document — shorthand for - * `options.unwrap`. `true` unwraps `

`; a space-separated string or - * array unwraps the listed tags. - */ - unwrap: { - type: [Boolean, String, Array] as PropType, - default: false, - }, - - /** - * Custom component mappings for element tags - * Key: tag name (e.g., 'h1', 'p', 'MyComponent') - * Value: Vue component - */ - components: { - type: Object as PropType>, - default: () => ({}), - }, - - /** - * Dynamic component resolver function - * Used to resolve components that aren't in the components map - */ - componentsManifest: { - type: Function as PropType, - default: undefined, - }, - - /** - * Enable streaming mode with stream-specific components - */ - streaming: { - type: Boolean as PropType, - default: false, - }, - - /** - * If document has a comment, only render the content before the comment - */ - summary: { - type: Boolean as PropType, - default: false, - }, - - /** - * If caret is true, a caret will be appended to the document's last text node - */ - caret: { - type: [Boolean, Object] as PropType, - default: false, - }, - }, + props: markdownProps, setup(props, { slots }) { const options = computed(() => ({ ...parseOptions, @@ -129,18 +51,15 @@ export function defineMarkdownComponent(config: DefineMarkdownComponentOptions = return () => { const component = config.extends || Markdown + // Spread rather than list every prop: the hand-written list had drifted + // from `` three times, dropping `data` and `documentKey`. return h( component, { - value: props.value, + ...props, options: options.value, plugins: plugins.value, - unwrap: props.unwrap, components: components.value, - componentsManifest: props.componentsManifest, - streaming: props.streaming, - summary: props.summary, - caret: props.caret, class: config.class, }, { @@ -155,50 +74,7 @@ export function defineMarkdownComponent(config: DefineMarkdownComponentOptions = export function defineMarkdownDocumentComponent(config: DefineMarkdownDocumentOptions = {}): typeof MarkdownDocument { return defineComponent({ name: config.name ?? 'MarkdownDocumentComponent', - props: { - /** - * The parsed Markdown document to render - */ - value: { - type: Object as PropType, - default: undefined, - }, - - /** - * Custom component mappings for element tags - * Key: tag name (e.g., 'h1', 'p', 'MyComponent') - * Value: Vue component - */ - components: { - type: Object as PropType>, - default: () => ({}), - }, - - /** - * Dynamic component resolver function - * Used to resolve components that aren't in the components map - */ - componentsManifest: { - type: Function as PropType, - default: undefined, - }, - - /** - * Enable streaming mode with stream-specific components - */ - streaming: { - type: Boolean as PropType, - default: false, - }, - - /** - * If caret is true, a caret will be appended to the document's last text node - */ - caret: { - type: [Boolean, Object] as PropType, - default: false, - }, - }, + props: markdownDocumentProps, setup(props, { slots }) { const components = computed(() => ({ ...config.components, @@ -210,11 +86,8 @@ export function defineMarkdownDocumentComponent(config: DefineMarkdownDocumentOp return h( component, { - value: props.value, + ...props, components: components.value, - componentsManifest: props.componentsManifest, - streaming: props.streaming, - caret: props.caret, class: config.class, }, { diff --git a/packages/comark-vue/test/define-component-props.test.ts b/packages/comark-vue/test/define-component-props.test.ts new file mode 100644 index 00000000..786ee7a5 --- /dev/null +++ b/packages/comark-vue/test/define-component-props.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import { parseMarkdown } from 'comark' +import { defineMarkdownComponent, defineMarkdownDocumentComponent } from '../src/index' + +async function render(component: any, props: Record = {}) { + const app = createSSRApp({ + setup() { + return () => h(component, props) + }, + }) + return renderToString(app as any) +} + +describe('defineMarkdownComponent props', () => { + it('accepts a pre-parsed document without a type warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const Custom = defineMarkdownComponent({ name: 'Custom' }) + const document = await parseMarkdown('Hello **world**') + + const html = await render(Custom, { value: document }) + + expect(html).toContain('world') + const typeWarnings = warn.mock.calls.filter((call) => String(call[0]).includes('type check failed')) + expect(typeWarnings).toEqual([]) + warn.mockRestore() + }) + + it('still renders a string value', async () => { + const Custom = defineMarkdownComponent({ name: 'Custom' }) + expect(await render(Custom, { value: 'Hello **world**' })).toContain('world') + }) + + it('forwards data to the renderer', async () => { + const Custom = defineMarkdownComponent({ name: 'Custom' }) + const html = await render(Custom, { + value: 'Value is :span{:text="data.answer"}', + data: { answer: '42' }, + }) + expect(html).toContain('42') + }) + + it('forwards documentKey through to MarkdownDocument', async () => { + const Custom = defineMarkdownComponent({ name: 'Custom' }) + // The prop must be declared, otherwise it silently falls through as an attr. + expect(Object.keys((Custom as any).props)).toContain('documentKey') + expect(await render(Custom, { value: 'Hi', documentKey: 'doc-1' })).toContain('Hi') + }) + + it('keeps config options and per-instance options merged', async () => { + const Custom = defineMarkdownComponent({ name: 'Custom', unwrap: 'p' }) + const html = await render(Custom, { value: 'Hello **world**' }) + expect(html).not.toContain('

') + expect(html).toContain('world') + }) +}) + +describe('defineMarkdownDocumentComponent props', () => { + it('forwards data and documentKey', async () => { + const Custom = defineMarkdownDocumentComponent({ name: 'CustomDocument' }) + expect(Object.keys((Custom as any).props)).toContain('data') + expect(Object.keys((Custom as any).props)).toContain('documentKey') + + const document = await parseMarkdown('Value is :span{:text="data.answer"}') + const html = await render(Custom, { value: document, data: { answer: '42' } }) + expect(html).toContain('42') + }) +}) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index c8c43e8e..cf32b2a8 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -66,7 +66,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/nuxt": "11.8k (58 files)", "@comark/react": "37.3k (74 files)", "@comark/svelte": "45.1k (82 files)", - "@comark/vue": "52.4k (78 files)", + "@comark/vue": "55.6k (78 files)", "comark": "373k (162 files)", } `)