diff --git a/src/from-markdown.ts b/src/from-markdown.ts index 0e38983..629def8 100644 --- a/src/from-markdown.ts +++ b/src/from-markdown.ts @@ -7,12 +7,12 @@ import { parseEntities } from 'parse-entities' import { kebabCase } from 'scule' import type { Token, CompileContext, Container, Nodes } from './micromark-extension/types' import type { RemarkMDCOptions } from './types' -import { NON_UNWRAPPABLE_TYPES } from './utils' +import { CONTAINER_NODE_TYPES, NON_UNWRAPPABLE_TYPES } from './utils' export default (opts: RemarkMDCOptions = {}) => { const canContainEols = ['textComponent'] - const experimentalCodeBlockYamlProps = (node: Container) => { + const applyYamlCodeBlockProps = (node: Container) => { const firstSection = node.children[0] as Container if ( firstSection && @@ -27,23 +27,39 @@ export default (opts: RemarkMDCOptions = {}) => { firstSection.children!.splice(0, 1) } } - const experimentalAutoUnwrap = (node: Container) => { - if (opts.experimental?.autoUnwrap && NON_UNWRAPPABLE_TYPES.includes(node.type)) { - const nonSlotChildren = (node.children).filter((child: any) => child.type !== 'componentContainerSection') - if (nonSlotChildren.length === 1 && !NON_UNWRAPPABLE_TYPES.includes(nonSlotChildren[0].type)) { - const nonSlotChildIndex = node.children.indexOf(nonSlotChildren[0]) - node.children.splice(nonSlotChildIndex, 1, ...((nonSlotChildren[0] as Container)?.children || [])) - node.mdc = node.mdc || {} - node.mdc.unwrapped = nonSlotChildren[0].type - } + const applyAutomaticUnwrap = (node: Container, { safeTypes = [] }: Exclude) => { + if (!CONTAINER_NODE_TYPES.has(node.type)) { + // unwrap only applicable for container components + return + } + + const nonSlotChildren = (node.children).filter((child: any) => child.type !== 'componentContainerSection') + if (nonSlotChildren.length !== 1) { + // unwrapp only works when container has only one child (slots are separated children) + return + } + + const child = nonSlotChildren[0] + if (NON_UNWRAPPABLE_TYPES.has(child.type) || safeTypes.includes(child.type)) { + // Ignore child if it's in safe types list + return } + + const childIndex = node.children.indexOf(child) + + node.children.splice(childIndex, 1, ...((child as Container)?.children || [])) + node.mdc = node.mdc || {} + node.mdc.unwrapped = child.type } + const processNode = (node: Container) => { - if (opts.experimental?.componentCodeBlockYamlProps) { - experimentalCodeBlockYamlProps(node) + if (opts.yamlCodeBlockProps) { + applyYamlCodeBlockProps(node) + } + if (opts.autoUnwrap) { + applyAutomaticUnwrap(node, typeof opts.autoUnwrap === 'boolean' ? {} : opts.autoUnwrap) } - experimentalAutoUnwrap(node) } const enter = { componentContainer: enterContainer, diff --git a/src/index.ts b/src/index.ts index 0f4bae0..076c775 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,17 @@ interface ComponentNode extends Node { export default >> function remarkMDC (opts: RemarkMDCOptions = {}) { const data: Record = this.data() + /** + * Convert experimental options + * Will drop experimental options in v5 + */ + if (opts.autoUnwrap === undefined && opts.experimental?.autoUnwrap) { + opts.autoUnwrap = opts.experimental.autoUnwrap ? { safeTypes: [] } : false + } + if (opts.yamlCodeBlockProps === undefined && opts.experimental?.componentCodeBlockYamlProps) { + opts.yamlCodeBlockProps = opts.experimental.componentCodeBlockYamlProps + } + add('micromarkExtensions', syntax()) add('fromMarkdownExtensions', fromMarkdown(opts)) add('toMarkdownExtensions', toMarkdown(opts)) diff --git a/src/to-markdown.ts b/src/to-markdown.ts index 60eac70..b5b6c6d 100644 --- a/src/to-markdown.ts +++ b/src/to-markdown.ts @@ -12,6 +12,8 @@ import type { RemarkMDCOptions } from './types' import { NON_UNWRAPPABLE_TYPES } from './utils' import { type Container } from './micromark-extension/types' +type NodeContainerComponent = Parents & { name: string; fmAttributes?: Record } + const own = {}.hasOwnProperty const shortcut = /^[^\t\n\r "#'.<=>`}]+$/ @@ -39,21 +41,54 @@ function compilePattern (pattern: Unsafe) { type NodeComponentContainerSection = Parents & { name: string } export default (opts: RemarkMDCOptions = {}) => { - const experimentalAutoUnwrap = (node: Container) => { - if (opts?.experimental?.autoUnwrap) { - if (node.mdc?.unwrapped) { - node.children = [ - { - type: node.mdc.unwrapped as any, - children: node.children.filter((child: RootContent) => !NON_UNWRAPPABLE_TYPES.includes(child.type)) - }, - ...node.children.filter((child: RootContent) => NON_UNWRAPPABLE_TYPES.includes(child.type)) - ] - } + const applyAutomaticUnwrap = (node: Container, { safeTypes = [] }: Exclude) => { + const isSafe = (type: string) => NON_UNWRAPPABLE_TYPES.has(type) || safeTypes.includes(type) + if (!node.mdc?.unwrapped) { + return + } + node.children = [ + { + type: node.mdc.unwrapped as any, + children: node.children.filter((child: RootContent) => !isSafe(child.type)) + }, + ...node.children.filter((child: RootContent) => isSafe(child.type)) + ] + } + + const frontmatter = (node: NodeContainerComponent) => { + const entries = Object.entries(node.fmAttributes || {}) + + if (entries.length === 0) { + return '' } + + const attrs = entries + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .reduce((acc, [key, value2]) => { + // Parse only JSON objects. `{":key:": "value"}` can be used for binding data to frontmatter. + if (key?.startsWith(':') && isValidJSON(value2)) { + try { + value2 = JSON.parse(value2) + } catch { + // ignore + } + key = key.slice(1) + } + acc[key] = value2 + return acc + }, {} as Record) + + return '\n' + ( + opts?.yamlCodeBlockProps + ? stringifyCodeBlockProps(attrs).trim() + : stringifyFrontMatter(attrs).trim() + ) } + const processNode = (node: Container) => { - experimentalAutoUnwrap(node) + if (opts.autoUnwrap) { + applyAutomaticUnwrap(node, typeof opts.autoUnwrap === 'boolean' ? {} : opts.autoUnwrap) + } } function componentContainerSection (node: NodeComponentContainerSection, _: any, context: any) { @@ -88,7 +123,6 @@ export default (opts: RemarkMDCOptions = {}) => { return value } - type NodeContainerComponent = Parents & { name: string; fmAttributes?: Record } let nest = 0 function containerComponent (node: NodeContainerComponent, _: any, context: any) { context.indexStack = context.stack @@ -97,37 +131,6 @@ export default (opts: RemarkMDCOptions = {}) => { const exit = context.enter(node.type) let value = prefix + (node.name || '') + label(node, context) - const attributesText = attributes(node, context) - const fmAttributes: Record = node.fmAttributes || {} - - if ((value + attributesText).length > (opts?.maxAttributesLength || 80) || Object.keys(fmAttributes).length > 0 || node.children?.some((child: RootContent) => child.type === 'componentContainerSection')) { - Object.assign(fmAttributes, (node as any).attributes) - } else { - value += attributesText - } - let subvalue - - // Convert attributes to YAML FrontMatter format - if (Object.keys(fmAttributes).length > 0) { - const attrs = Object.entries(fmAttributes) - .sort(([key1], [key2]) => key1.localeCompare(key2)) - .reduce((acc, [key, value2]) => { - // Parse only JSON objects. `{":key:": "value"}` can be used for binding data to frontmatter. - if (key?.startsWith(':') && isValidJSON(value2)) { - try { - value2 = JSON.parse(value2) - } catch { - // ignore - } - key = key.slice(1) - } - acc[key] = value2 - return acc - }, {} as Record) - const fm = opts?.experimental?.componentCodeBlockYamlProps ? stringifyCodeBlockProps(attrs) : stringifyFrontMatter(attrs) - value += '\n' + fm.trim() - } - // Move default slot's children to the beginning of the content const defaultSlotChildren = node.children.filter((child: any) => child.type !== 'componentContainerSection') const slots = node.children.filter((child: any) => child.type === 'componentContainerSection') @@ -137,8 +140,26 @@ export default (opts: RemarkMDCOptions = {}) => { ...slots ] + // ensure fmAttributes exists + node.fmAttributes = node.fmAttributes || {} + const attributesText = attributes(node, context) + if ( + (value + attributesText).length > (opts?.maxAttributesLength || 80) || + Object.keys(node.fmAttributes).length > 0 || // remove: allow using both yaml and inline attributes simentensoly + node.children?.some((child: RootContent) => child.type === 'componentContainerSection') // remove: allow using both yaml and inline attributes simentensoly + ) { + // add attributes to frontmatter + Object.assign(node.fmAttributes, (node as any).attributes) + // clear attributes + ;(node as any).attributes = [] + } + processNode(node as any) + value += attributes(node, context) + value += frontmatter(node) + + let subvalue if ((node.type as string) === 'containerComponent') { subvalue = content(node, context) if (subvalue) { value += '\n' + subvalue } diff --git a/src/types.d.ts b/src/types.d.ts index c52353c..5217f24 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -7,8 +7,18 @@ interface ComponentHandler { export interface RemarkMDCOptions { components?: ComponentHandler[] maxAttributesLength?: number + autoUnwrap?: boolean | { + safeTypes?: Array + } + yamlCodeBlockProps?: boolean experimental?: { + /** + * @deprecated This feature is out of experimental, use `autoUnwrap` + */ autoUnwrap?: boolean + /** + * @deprecated This feature is out of experimental, use `yamlCodeBlockProps` + */ componentCodeBlockYamlProps?: boolean } } diff --git a/src/utils.ts b/src/utils.ts index 6430c27..696a728 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,16 @@ -export const NON_UNWRAPPABLE_TYPES = [ +export const CONTAINER_NODE_TYPES = new Set([ + 'componentContainerSection', + 'containerComponent', + 'leafComponent' +]) + +export const NON_UNWRAPPABLE_TYPES = new Set([ 'componentContainerSection', 'componentContainerDataSection', 'containerComponent', 'leafComponent', 'table', 'pre', - 'code' -] + 'code', + 'textComponent' +])