diff --git a/package.json b/package.json index 1d202a1922..7d92b048fc 100644 --- a/package.json +++ b/package.json @@ -323,6 +323,7 @@ "winston-daily-rotate-file": "^5.0.0", "word-extractor": "^1.0.4", "y-protocols": "^1.0.6", + "yaml": "^2.8.1", "yjs": "^13.6.27", "youtubei.js": "^15.0.1", "zipread": "^1.3.3", diff --git a/src/renderer/src/components/RichEditor/components/YamlFrontMatterNodeView.tsx b/src/renderer/src/components/RichEditor/components/YamlFrontMatterNodeView.tsx new file mode 100644 index 0000000000..67d2c2126b --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/YamlFrontMatterNodeView.tsx @@ -0,0 +1,761 @@ +import { loggerService } from '@logger' +import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react' +import { Checkbox, Dropdown, Input, type MenuProps } from 'antd' +import { Calendar, Check, FileText, Hash, MoreHorizontal, Plus, Tag as TagIcon, Trash2, Type, X } from 'lucide-react' +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' +import { parse, stringify } from 'yaml' + +const logger = loggerService.withContext('YamlFrontMatterNodeView') + +interface ParsedProperty { + key: string + value: any + type: 'string' | 'array' | 'date' | 'number' | 'boolean' +} + +const YamlFrontMatterNodeView: React.FC = ({ node, updateAttributes }) => { + const { t } = useTranslation() + const [editingProperty, setEditingProperty] = useState(null) + const [newPropertyName, setNewPropertyName] = useState('') + const [showAddProperty, setShowAddProperty] = useState(false) + const [openDropdown, setOpenDropdown] = useState(null) + const [arrayInputValues, setArrayInputValues] = useState>({}) + const [showArrayInput, setShowArrayInput] = useState>({}) + + // Parse YAML content into structured properties + const parsedProperties = useMemo((): ParsedProperty[] => { + try { + const content = node.attrs.content || '' + const yamlContent = content.replace(/\n---\s*$/, '') // Remove closing fence + + if (!yamlContent.trim()) return [] + + const parsed = parse(yamlContent) + if (!parsed || typeof parsed !== 'object') return [] + + return Object.entries(parsed).map(([key, value]): ParsedProperty => { + let type: ParsedProperty['type'] = 'string' + + if (Array.isArray(value)) { + type = 'array' + } else if (typeof value === 'number') { + type = 'number' + } else if (typeof value === 'boolean') { + type = 'boolean' + } else if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) { + type = 'date' + } + + return { key, value, type } + }) + } catch (error) { + logger.warn('Failed to parse YAML front matter:', error as Error) + return [] + } + }, [node.attrs.content]) + + // Get icon for property type + const getPropertyIcon = (type: ParsedProperty['type']) => { + switch (type) { + case 'array': + return + case 'date': + return + case 'number': + return + case 'string': + return + case 'boolean': + return + default: + return + } + } + + // Update YAML content from properties + const updateYamlFromProperties = useCallback( + (properties: ParsedProperty[]) => { + try { + const yamlObject = properties.reduce( + (acc, prop) => { + acc[prop.key] = prop.value + return acc + }, + {} as Record + ) + + const yamlContent = stringify(yamlObject).trim() + const contentWithFence = yamlContent + '\n---' + updateAttributes({ content: contentWithFence }) + } catch (error) { + logger.error('Failed to update YAML:', error as Error) + } + }, + [updateAttributes] + ) + + // Handle property value change + const handlePropertyChange = useCallback( + (key: string, newValue: any) => { + const updatedProperties = parsedProperties.map((prop) => (prop.key === key ? { ...prop, value: newValue } : prop)) + updateYamlFromProperties(updatedProperties) + setEditingProperty(null) + }, + [parsedProperties, updateYamlFromProperties] + ) + + // Handle array item removal + const handleRemoveArrayItem = useCallback( + (key: string, index: number) => { + const property = parsedProperties.find((p) => p.key === key) + if (property && Array.isArray(property.value)) { + const newArray = property.value.filter((_, i) => i !== index) + handlePropertyChange(key, newArray) + } + }, + [parsedProperties, handlePropertyChange] + ) + + // Handle array item addition + const handleAddArrayItem = useCallback( + (key: string, value: string) => { + if (!value.trim()) return + + const property = parsedProperties.find((p) => p.key === key) + if (property && Array.isArray(property.value)) { + const newArray = [...property.value, value.trim()] + handlePropertyChange(key, newArray) + setArrayInputValues((prev) => ({ ...prev, [key]: '' })) + setShowArrayInput((prev) => ({ ...prev, [key]: false })) + } + }, + [parsedProperties, handlePropertyChange] + ) + + // Add new property + const handleAddProperty = useCallback(() => { + if (newPropertyName.trim()) { + const updatedProperties = [ + ...parsedProperties, + { + key: newPropertyName.trim(), + value: '', + type: 'string' as const + } + ] + updateYamlFromProperties(updatedProperties) + setNewPropertyName('') + setShowAddProperty(false) + } + }, [newPropertyName, parsedProperties, updateYamlFromProperties]) + + // Delete property + const handleDeleteProperty = useCallback( + (propertyKey: string) => { + const updatedProperties = parsedProperties.filter((prop) => prop.key !== propertyKey) + updateYamlFromProperties(updatedProperties) + }, + [parsedProperties, updateYamlFromProperties] + ) + + // Change property type + const handleChangePropertyType = useCallback( + (propertyKey: string, newType: ParsedProperty['type']) => { + const updatedProperties = parsedProperties.map((prop) => { + if (prop.key === propertyKey) { + let newValue = prop.value + // Convert value based on new type + switch (newType) { + case 'array': + newValue = Array.isArray(prop.value) ? prop.value : [String(prop.value)] + break + case 'number': + newValue = typeof prop.value === 'number' ? prop.value : Number(String(prop.value)) || 0 + break + case 'boolean': + newValue = typeof prop.value === 'boolean' ? prop.value : String(prop.value).toLowerCase() === 'true' + break + case 'string': + newValue = String(prop.value) + break + case 'date': + newValue = prop.value instanceof Date ? prop.value.toISOString().split('T')[0] : String(prop.value) + break + } + return { ...prop, type: newType, value: newValue } + } + return prop + }) + updateYamlFromProperties(updatedProperties) + }, + [parsedProperties, updateYamlFromProperties] + ) + + // Create context menu for property + const getPropertyMenu = useCallback( + (property: ParsedProperty): MenuProps => { + return { + items: [ + { + key: 'edit', + label: ( + + + {t('richEditor.frontMatter.editValue')} + + ), + onClick: () => { + setEditingProperty(property.key) + setOpenDropdown(null) + } + }, + { + type: 'divider' + }, + { + key: 'type', + label: t('richEditor.frontMatter.changeType'), + children: [ + { + key: 'string', + label: ( + + + {t('richEditor.frontMatter.changeToText')} + + ), + disabled: property.type === 'string', + onClick: () => { + handleChangePropertyType(property.key, 'string') + setOpenDropdown(null) + } + }, + { + key: 'number', + label: ( + + + {t('richEditor.frontMatter.changeToNumber')} + + ), + disabled: property.type === 'number', + onClick: () => { + handleChangePropertyType(property.key, 'number') + setOpenDropdown(null) + } + }, + { + key: 'boolean', + label: ( + + + {t('richEditor.frontMatter.changeToBoolean')} + + ), + disabled: property.type === 'boolean', + onClick: () => { + handleChangePropertyType(property.key, 'boolean') + setOpenDropdown(null) + } + }, + { + key: 'array', + label: ( + + + {t('richEditor.frontMatter.changeToTags')} + + ), + disabled: property.type === 'array', + onClick: () => { + handleChangePropertyType(property.key, 'array') + setOpenDropdown(null) + } + }, + { + key: 'date', + label: ( + + + {t('richEditor.frontMatter.changeToDate', 'Date')} + + ), + disabled: property.type === 'date', + onClick: () => { + handleChangePropertyType(property.key, 'date') + setOpenDropdown(null) + } + } + ] + }, + { + type: 'divider' + }, + { + key: 'delete', + label: ( + + + {t('richEditor.frontMatter.deleteProperty')} + + ), + onClick: () => { + handleDeleteProperty(property.key) + setOpenDropdown(null) + } + } + ] + } + }, + [t, handleChangePropertyType, handleDeleteProperty] + ) + + // Render property value based on type + const renderPropertyValue = (property: ParsedProperty) => { + const isEditing = editingProperty === property.key + + if (property.type === 'array' && Array.isArray(property.value)) { + const isShowingInput = showArrayInput[property.key] + + return ( + + {property.value.map((item, index) => ( + + {String(item)} + handleRemoveArrayItem(property.key, index)}> + + + + ))} + {isShowingInput ? ( + setArrayInputValues((prev) => ({ ...prev, [property.key]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddArrayItem(property.key, arrayInputValues[property.key] || '') + } else if (e.key === 'Escape') { + setShowArrayInput((prev) => ({ ...prev, [property.key]: false })) + setArrayInputValues((prev) => ({ ...prev, [property.key]: '' })) + } + }} + onBlur={() => { + const value = arrayInputValues[property.key] || '' + if (value.trim()) { + handleAddArrayItem(property.key, value) + } else { + setShowArrayInput((prev) => ({ ...prev, [property.key]: false })) + } + }} + autoFocus + /> + ) : ( + setShowArrayInput((prev) => ({ ...prev, [property.key]: true }))}> + + + )} + + ) + } + + if (property.type === 'boolean') { + return ( + handlePropertyChange(property.key, e.target.checked)} + /> + ) + } + + if (isEditing) { + return ( + { + let newValue: any = e.target.value + if (property.type === 'number') { + newValue = Number(newValue) || 0 + } else if (property.type === 'boolean') { + newValue = newValue.toLowerCase() === 'true' + } + handlePropertyChange(property.key, newValue) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } else if (e.key === 'Escape') { + setEditingProperty(null) + } + }} + autoFocus + /> + ) + } + + return ( + setEditingProperty(property.key)}> + {property.value ? ( + String(property.value) + ) : ( + {t('richEditor.frontMatter.empty')} + )} + + ) + } + + return ( + { + // Only prevent if the context menu is triggered on the wrapper itself + if (e.target === e.currentTarget) { + e.preventDefault() + } + }}> + { + // Prevent node selection when clicking inside properties + e.stopPropagation() + }}> + {parsedProperties.map((property) => ( + { + setOpenDropdown(open ? `context-${property.key}` : null) + }}> + { + e.stopPropagation() + }}> + {getPropertyIcon(property.type)} + {property.key} + {renderPropertyValue(property)} + + { + setOpenDropdown(open ? `action-${property.key}` : null) + }}> + e.stopPropagation()} title={t('richEditor.frontMatter.moreActions')}> + + + + + + + ))} + + {showAddProperty ? ( + + + + + setNewPropertyName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddProperty() + } else if (e.key === 'Escape') { + setShowAddProperty(false) + setNewPropertyName('') + } + }} + onBlur={() => { + if (newPropertyName.trim()) { + handleAddProperty() + } else { + setShowAddProperty(false) + } + }} + autoFocus + /> + + ) : ( + setShowAddProperty(true)}> + + + + {t('richEditor.frontMatter.addProperty')} + + )} + + + ) +} + +const PropertiesContainer = styled.div` + margin: 16px 0; + padding: 0; + display: flex; + flex-direction: column; +` + +const PropertyRow = styled.div` + display: flex; + align-items: center; + padding: 6px 8px; + margin: 0 -8px; + min-height: 32px; + border-radius: 6px; + + &:hover { + background-color: var(--color-hover); + } +` + +const PropertyIcon = styled.div` + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + color: var(--color-icon); +` + +const PropertyName = styled.div` + min-width: 100px; + max-width: 100px; + font-size: 14px; + font-family: var(--font-family); + font-weight: 500; + color: var(--color-text); + margin-right: 12px; + text-transform: capitalize; +` + +const PropertyValue = styled.div` + flex: 1; + font-size: 14px; + font-family: var(--font-family); + color: var(--color-text); + cursor: pointer; + padding: 6px 8px; + margin: -2px 0; + border-radius: 4px; + min-height: 20px; + display: flex; + align-items: center; +` + +const TagContainer = styled.div` + flex: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +` + +const Tag = styled.span` + display: inline-flex; + align-items: center; + padding: 2px 8px; + background-color: var(--color-background-mute); + border-radius: 12px; + font-size: 12px; + font-family: var(--font-family); + color: var(--color-text); + gap: 4px; + + &:hover { + background-color: var(--color-background-soft); + } +` + +const TagRemove = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--color-icon); + display: flex; + align-items: center; + + &:hover { + color: var(--color-error); + } +` + +const StyledInput = styled(Input)` + border: none !important; + outline: none !important; + background: transparent !important; + font-size: 14px; + font-family: var(--font-family); + color: var(--color-text); + width: 100%; + padding: 6px 8px; + margin: -2px 0; + border-radius: 4px; + min-height: 20px; + cursor: text; + box-shadow: none !important; + + &::placeholder { + color: #9ca3af; + } + + &:hover { + background-color: var(--color-hover) !important; + border: none !important; + box-shadow: none !important; + } + + &:focus { + border: none !important; + box-shadow: none !important; + } + + &.ant-input { + border: none !important; + box-shadow: none !important; + } + + &.ant-input:hover { + border: none !important; + box-shadow: none !important; + } + + &.ant-input:focus { + border: none !important; + box-shadow: none !important; + } +` + +const AddTagButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: none; + border: 1px dashed #d1d5db; + border-radius: 10px; + color: #9ca3af; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--color-primary); + color: #3b82f6; + background-color: rgba(59, 130, 246, 0.05); + } +` + +const ArrayInput = styled(Input)` + display: inline-flex; + border: 1px solid #e5e7eb !important; + outline: none !important; + background: transparent !important; + font-size: 12px; + font-family: var(--font-family); + padding: 2px 8px !important; + border-radius: 12px; + min-width: 80px; + max-width: 120px; + height: 24px; + cursor: text; + box-shadow: none !important; + vertical-align: top; + + &::placeholder { + color: #9ca3af; + font-size: 12px; + } + + &:hover { + border-color: var(--color-border) !important; + box-shadow: none !important; + } + + &:focus { + border-color: var(--color-primary) !important; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important; + } + + &.ant-input { + font-size: 12px; + height: 24px; + display: inline-flex; + vertical-align: top; + } + + &.ant-input:hover { + border-color: var(--color-border) !important; + box-shadow: none !important; + } + + &.ant-input:focus { + border-color: var(--color-primary) !important; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important; + } +` + +const AddPropertyRow = styled.button` + display: flex; + align-items: center; + padding: 6px 8px; + margin: 0 -8px; + min-height: 32px; + background: none; + border: none; + cursor: pointer; + border-radius: 6px; + width: 100%; + + &:hover { + background-color: var(--color-hover); + } +` + +const AddPropertyText = styled.div` + font-size: 14px; + font-family: var(--font-family); + color: var(--color-text); +` + +const PropertyActions = styled.div` + display: flex; + align-items: center; + gap: 4px; + opacity: 0; + transition: opacity 0.2s; + margin-left: auto; + margin-right: 4px; + + ${PropertyRow}:hover & { + opacity: 1; + } +` + +const ActionButton = styled.button` + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: #6b7280; + border-radius: 4px; + display: flex; + align-items: center; + + &:hover { + background-color: var(--color-hover); + color: #374151; + } +` + +export default YamlFrontMatterNodeView diff --git a/src/renderer/src/components/RichEditor/extensions/yaml-front-matter.ts b/src/renderer/src/components/RichEditor/extensions/yaml-front-matter.ts new file mode 100644 index 0000000000..d5305bd09e --- /dev/null +++ b/src/renderer/src/components/RichEditor/extensions/yaml-front-matter.ts @@ -0,0 +1,100 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' + +import YamlFrontMatterNodeView from '../components/YamlFrontMatterNodeView' + +declare module '@tiptap/core' { + interface Commands { + yamlFrontMatter: { + insertYamlFrontMatter: (content?: string) => ReturnType + } + } +} + +export const YamlFrontMatter = Node.create({ + name: 'yamlFrontMatter', + group: 'block', + atom: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {} + } + }, + + addAttributes() { + return { + content: { + default: '', + parseHTML: (element) => { + const dataContent = element.getAttribute('data-content') + if (dataContent) { + // Decode HTML entities that might be in the data-content attribute + const textarea = document.createElement('textarea') + textarea.innerHTML = dataContent + return textarea.value + } + return element.textContent || '' + }, + renderHTML: (attributes) => ({ + 'data-content': attributes.content + }) + } + } + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="yaml-front-matter"]', + getAttrs: (element) => { + if (typeof element === 'string') return false + + const htmlElement = element as HTMLElement + const dataContent = htmlElement.getAttribute('data-content') + const textContent = htmlElement.textContent || '' + + return { + content: dataContent || textContent + } + } + } + ] + }, + + renderHTML({ HTMLAttributes, node }) { + const content = node.attrs.content || '' + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-type': 'yaml-front-matter', + 'data-content': content + }), + content + ] + }, + + addCommands() { + return { + insertYamlFrontMatter: + (content = '') => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + content + } + }) + } + } + }, + + addNodeView() { + return ReactNodeViewRenderer(YamlFrontMatterNodeView) + }, + + addInputRules() { + return [] + } +}) diff --git a/src/renderer/src/components/RichEditor/useRichEditor.ts b/src/renderer/src/components/RichEditor/useRichEditor.ts index 8275d3623d..7dae176068 100644 --- a/src/renderer/src/components/RichEditor/useRichEditor.ts +++ b/src/renderer/src/components/RichEditor/useRichEditor.ts @@ -31,6 +31,7 @@ import { EnhancedImage } from './extensions/enhanced-image' import { EnhancedLink } from './extensions/enhanced-link' import { EnhancedMath } from './extensions/enhanced-math' import { Placeholder } from './extensions/placeholder' +import { YamlFrontMatter } from './extensions/yaml-front-matter' import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers/imageUtils' const logger = loggerService.withContext('useRichEditor') @@ -320,6 +321,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor showOnlyCurrent: true, includeChildren: false }), + YamlFrontMatter, Mention.configure({ HTMLAttributes: { class: 'mention' diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 3c233b0578..8b328b1ee9 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "Drag to move", + "frontMatter": { + "addProperty": "Add a property", + "addTag": "Add tag", + "changeToBoolean": "Checkbox", + "changeToDate": "Date", + "changeToNumber": "Number", + "changeToTags": "Tags", + "changeToText": "Text", + "changeType": "Change type", + "deleteProperty": "Delete property", + "editValue": "Edit value", + "empty": "Empty", + "moreActions": "More actions", + "propertyName": "Property name" + }, "image": { "placeholder": "Add a picture" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a630bda814..3ea4d77577 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "拖拽块", + "frontMatter": { + "addProperty": "添加属性", + "addTag": "添加标签", + "changeToBoolean": "复选框", + "changeToDate": "日期", + "changeToNumber": "数字", + "changeToTags": "标签", + "changeToText": "文本", + "changeType": "更改类型", + "deleteProperty": "删除属性", + "editValue": "编辑值", + "empty": "空", + "moreActions": "更多操作", + "propertyName": "属性名称" + }, "image": { "placeholder": "添加图片" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 98bb66b04c..c970cff3e6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2221,6 +2221,21 @@ } }, "dragHandle": "拖拽塊", + "frontMatter": { + "addProperty": "新增屬性", + "addTag": "新增標籤", + "changeToBoolean": "核取方塊", + "changeToDate": "日期", + "changeToNumber": "數字", + "changeToTags": "標籤", + "changeToText": "文字", + "changeType": "更改類型", + "deleteProperty": "刪除屬性", + "editValue": "編輯值", + "empty": "空", + "moreActions": "更多操作", + "propertyName": "屬性名稱" + }, "image": { "placeholder": "添加圖片" }, diff --git a/src/renderer/src/utils/__tests__/markdownConverter.test.ts b/src/renderer/src/utils/__tests__/markdownConverter.test.ts index a172a418aa..3929be608c 100644 --- a/src/renderer/src/utils/__tests__/markdownConverter.test.ts +++ b/src/renderer/src/utils/__tests__/markdownConverter.test.ts @@ -484,4 +484,25 @@ describe('markdownConverter', () => { expect(backToHtml).toBe('

This is a simple paragraph being typed

\n') }) }) + + describe('shoud keep YAML front matter', () => { + it('should keep YAML front matter', () => { + const markdown = `--- +tags: + - 你好 +aliases: + - "1111" + - "222" + - "333" + - "3333" +cssclasses: + - fffff + - ssss + - s12 +---` + const result = markdownToHtml(markdown) + const backToMarkdown = htmlToMarkdown(result) + expect(backToMarkdown).toBe(markdown) + }) + }) }) diff --git a/src/renderer/src/utils/markdownConverter.ts b/src/renderer/src/utils/markdownConverter.ts index 3d5adc83fe..08b6ec506c 100644 --- a/src/renderer/src/utils/markdownConverter.ts +++ b/src/renderer/src/utils/markdownConverter.ts @@ -229,6 +229,87 @@ interface InlineStateLike { push: (type: string, tag: string, nesting: number) => TokenLike & { content?: string } } +function yamlFrontMatterPlugin(md: MarkdownIt) { + // Parser: recognize YAML front matter + md.block.ruler.before( + 'table', + 'yaml_front_matter', + (stateLike: unknown, startLine: number, endLine: number, silent: boolean): boolean => { + const state = stateLike as BlockStateLike + + // Only check at the very beginning of the document + if (startLine !== 0) { + return false + } + + const startPos = state.bMarks[startLine] + state.tShift[startLine] + const maxPos = state.eMarks[startLine] + + // Must begin with --- at document start + if (startPos + 3 > maxPos) return false + if ( + state.src.charCodeAt(startPos) !== 0x2d /* - */ || + state.src.charCodeAt(startPos + 1) !== 0x2d /* - */ || + state.src.charCodeAt(startPos + 2) !== 0x2d /* - */ + ) { + return false + } + + // If requested only to validate existence + if (silent) return true + + // Search for closing --- + let nextLine = startLine + 1 + let found = false + + for (nextLine = startLine + 1; nextLine < endLine; nextLine++) { + const lineStart = state.bMarks[nextLine] + state.tShift[nextLine] + const lineEnd = state.eMarks[nextLine] + const line = state.src.slice(lineStart, lineEnd).trim() + + if (line === '---') { + found = true + break + } + } + + if (!found) { + return false + } + + // Extract YAML content between the --- delimiters, preserving original indentation + const yamlLines: string[] = [] + for (let lineIdx = startLine + 1; lineIdx < nextLine; lineIdx++) { + // Use the original line markers without shift to preserve indentation + const lineStart = state.bMarks[lineIdx] + const lineEnd = state.eMarks[lineIdx] + yamlLines.push(state.src.slice(lineStart, lineEnd)) + } + + // Also capture the closing --- line with its indentation + const closingLineStart = state.bMarks[nextLine] + const closingLineEnd = state.eMarks[nextLine] + const closingLine = state.src.slice(closingLineStart, closingLineEnd) + + const yamlContent = yamlLines.join('\n') + '\n' + closingLine + + const token = state.push('yaml_front_matter', 'div', 0) + token.block = true + token.map = [startLine, nextLine + 1] + token.content = yamlContent + + state.line = nextLine + 1 + return true + } + ) + + // Renderer: output YAML front matter as special HTML element + md.renderer.rules.yaml_front_matter = (tokens: Array<{ content?: string }>, idx: number): string => { + const content = tokens[idx]?.content ?? '' + return `
${content}
` + } +} + function tipTapKatexPlugin(md: MarkdownIt) { // 1) Parser: recognize $$ ... $$ as a block math token md.block.ruler.before( @@ -371,6 +452,8 @@ function tipTapKatexPlugin(md: MarkdownIt) { } } +md.use(yamlFrontMatterPlugin) + md.use(taskListPlugin, { label: true }) @@ -437,6 +520,28 @@ turndownService.addRule('br', { replacement: () => '
' }) +// Custom rule to preserve YAML front matter +turndownService.addRule('yamlFrontMatter', { + filter: (node: Element) => { + return node.nodeName === 'DIV' && node.getAttribute?.('data-type') === 'yaml-front-matter' + }, + replacement: (_content: string, node: Node) => { + const element = node as Element + const yamlContent = element.getAttribute?.('data-content') || '' + const decodedContent = he.decode(yamlContent, { + isAttributeValue: false, + strict: false + }) + // The decodedContent already includes the complete YAML with closing --- + // We just need to add the opening --- if it's not there + if (decodedContent.startsWith('---')) { + return decodedContent + } else { + return `---\n${decodedContent}` + } + } +}) + // Helper function to safely get text content and clean it with LaTeX support function cleanCellContent(content: string, cellElement?: Element): string { // First check for math elements in the cell diff --git a/yarn.lock b/yarn.lock index a471ae026e..f86b71a54e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10127,6 +10127,7 @@ __metadata: winston-daily-rotate-file: "npm:^5.0.0" word-extractor: "npm:^1.0.4" y-protocols: "npm:^1.0.6" + yaml: "npm:^2.8.1" yjs: "npm:^13.6.27" youtubei.js: "npm:^15.0.1" zipread: "npm:^1.3.3" @@ -25505,6 +25506,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.8.1": + version: 2.8.1 + resolution: "yaml@npm:2.8.1" + bin: + yaml: bin.mjs + checksum: 10c0/7c587be00d9303d2ae1566e03bc5bc7fe978ba0d9bf39cc418c3139d37929dfcb93a230d9749f2cb578b6aa5d9ebebc322415e4b653cb83acd8bc0bc321707f3 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1"