mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-31 16:49:07 +08:00
fix: add YAML front matter support in markdown processing (#9963)
* feat: add YAML front matter support in markdown processing - Introduced a new plugin to parse and render YAML front matter in markdown documents. - Updated the markdown converter to handle YAML front matter, preserving its structure during conversion. - Added corresponding tests to ensure YAML front matter is retained correctly in markdown to HTML and vice versa. - Enhanced i18n files with new translations for front matter properties in English, Chinese (Simplified and Traditional). - Included the 'yaml' package as a dependency in package.json. * feat(i18n): add new translations for editing properties in Traditional Chinese * chore: fix
This commit is contained in:
parent
1640ba2e9e
commit
cb4b26c8a4
@ -323,6 +323,7 @@
|
|||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"word-extractor": "^1.0.4",
|
"word-extractor": "^1.0.4",
|
||||||
"y-protocols": "^1.0.6",
|
"y-protocols": "^1.0.6",
|
||||||
|
"yaml": "^2.8.1",
|
||||||
"yjs": "^13.6.27",
|
"yjs": "^13.6.27",
|
||||||
"youtubei.js": "^15.0.1",
|
"youtubei.js": "^15.0.1",
|
||||||
"zipread": "^1.3.3",
|
"zipread": "^1.3.3",
|
||||||
|
|||||||
@ -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<NodeViewProps> = ({ node, updateAttributes }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [editingProperty, setEditingProperty] = useState<string | null>(null)
|
||||||
|
const [newPropertyName, setNewPropertyName] = useState('')
|
||||||
|
const [showAddProperty, setShowAddProperty] = useState(false)
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||||
|
const [arrayInputValues, setArrayInputValues] = useState<Record<string, string>>({})
|
||||||
|
const [showArrayInput, setShowArrayInput] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// 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 <TagIcon size={16} />
|
||||||
|
case 'date':
|
||||||
|
return <Calendar size={16} />
|
||||||
|
case 'number':
|
||||||
|
return <Hash size={16} />
|
||||||
|
case 'string':
|
||||||
|
return <FileText size={16} />
|
||||||
|
case 'boolean':
|
||||||
|
return <Check size={16} />
|
||||||
|
default:
|
||||||
|
return <FileText size={16} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, any>
|
||||||
|
)
|
||||||
|
|
||||||
|
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: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Type size={14} />
|
||||||
|
{t('richEditor.frontMatter.editValue')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
setEditingProperty(property.key)
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: t('richEditor.frontMatter.changeType'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'string',
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<FileText size={14} />
|
||||||
|
{t('richEditor.frontMatter.changeToText')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: property.type === 'string',
|
||||||
|
onClick: () => {
|
||||||
|
handleChangePropertyType(property.key, 'string')
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'number',
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Hash size={14} />
|
||||||
|
{t('richEditor.frontMatter.changeToNumber')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: property.type === 'number',
|
||||||
|
onClick: () => {
|
||||||
|
handleChangePropertyType(property.key, 'number')
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'boolean',
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Check size={14} />
|
||||||
|
{t('richEditor.frontMatter.changeToBoolean')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: property.type === 'boolean',
|
||||||
|
onClick: () => {
|
||||||
|
handleChangePropertyType(property.key, 'boolean')
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'array',
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<TagIcon size={14} />
|
||||||
|
{t('richEditor.frontMatter.changeToTags')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: property.type === 'array',
|
||||||
|
onClick: () => {
|
||||||
|
handleChangePropertyType(property.key, 'array')
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'date',
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Calendar size={14} />
|
||||||
|
{t('richEditor.frontMatter.changeToDate', 'Date')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: property.type === 'date',
|
||||||
|
onClick: () => {
|
||||||
|
handleChangePropertyType(property.key, 'date')
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#ef4444' }}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
{t('richEditor.frontMatter.deleteProperty')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<TagContainer>
|
||||||
|
{property.value.map((item, index) => (
|
||||||
|
<Tag key={index}>
|
||||||
|
{String(item)}
|
||||||
|
<TagRemove onClick={() => handleRemoveArrayItem(property.key, index)}>
|
||||||
|
<X size={12} />
|
||||||
|
</TagRemove>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{isShowingInput ? (
|
||||||
|
<ArrayInput
|
||||||
|
placeholder={t('richEditor.frontMatter.addTag')}
|
||||||
|
value={arrayInputValues[property.key] || ''}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AddTagButton onClick={() => setShowArrayInput((prev) => ({ ...prev, [property.key]: true }))}>
|
||||||
|
<Plus size={12} />
|
||||||
|
</AddTagButton>
|
||||||
|
)}
|
||||||
|
</TagContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (property.type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
style={{ paddingLeft: 8 }}
|
||||||
|
checked={property.value}
|
||||||
|
onChange={(e) => handlePropertyChange(property.key, e.target.checked)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<StyledInput
|
||||||
|
defaultValue={String(property.value)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
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 (
|
||||||
|
<PropertyValue onClick={() => setEditingProperty(property.key)}>
|
||||||
|
{property.value ? (
|
||||||
|
String(property.value)
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#9ca3af', fontStyle: 'italic' }}>{t('richEditor.frontMatter.empty')}</span>
|
||||||
|
)}
|
||||||
|
</PropertyValue>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
className="yaml-front-matter-wrapper"
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
// Only prevent if the context menu is triggered on the wrapper itself
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<PropertiesContainer
|
||||||
|
onClick={(e) => {
|
||||||
|
// Prevent node selection when clicking inside properties
|
||||||
|
e.stopPropagation()
|
||||||
|
}}>
|
||||||
|
{parsedProperties.map((property) => (
|
||||||
|
<Dropdown
|
||||||
|
key={property.key}
|
||||||
|
menu={getPropertyMenu(property)}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
placement="bottomRight"
|
||||||
|
open={openDropdown === `context-${property.key}`}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setOpenDropdown(open ? `context-${property.key}` : null)
|
||||||
|
}}>
|
||||||
|
<PropertyRow
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}>
|
||||||
|
<PropertyIcon>{getPropertyIcon(property.type)}</PropertyIcon>
|
||||||
|
<PropertyName>{property.key}</PropertyName>
|
||||||
|
{renderPropertyValue(property)}
|
||||||
|
<PropertyActions>
|
||||||
|
<Dropdown
|
||||||
|
menu={getPropertyMenu(property)}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomRight"
|
||||||
|
open={openDropdown === `action-${property.key}`}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setOpenDropdown(open ? `action-${property.key}` : null)
|
||||||
|
}}>
|
||||||
|
<ActionButton onClick={(e) => e.stopPropagation()} title={t('richEditor.frontMatter.moreActions')}>
|
||||||
|
<MoreHorizontal size={14} />
|
||||||
|
</ActionButton>
|
||||||
|
</Dropdown>
|
||||||
|
</PropertyActions>
|
||||||
|
</PropertyRow>
|
||||||
|
</Dropdown>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showAddProperty ? (
|
||||||
|
<PropertyRow>
|
||||||
|
<PropertyIcon>
|
||||||
|
<Plus size={16} />
|
||||||
|
</PropertyIcon>
|
||||||
|
<Input
|
||||||
|
placeholder={t('richEditor.frontMatter.propertyName')}
|
||||||
|
value={newPropertyName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</PropertyRow>
|
||||||
|
) : (
|
||||||
|
<AddPropertyRow onClick={() => setShowAddProperty(true)}>
|
||||||
|
<PropertyIcon>
|
||||||
|
<Plus size={16} />
|
||||||
|
</PropertyIcon>
|
||||||
|
<AddPropertyText>{t('richEditor.frontMatter.addProperty')}</AddPropertyText>
|
||||||
|
</AddPropertyRow>
|
||||||
|
)}
|
||||||
|
</PropertiesContainer>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@ -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<ReturnType> {
|
||||||
|
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 []
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -31,6 +31,7 @@ import { EnhancedImage } from './extensions/enhanced-image'
|
|||||||
import { EnhancedLink } from './extensions/enhanced-link'
|
import { EnhancedLink } from './extensions/enhanced-link'
|
||||||
import { EnhancedMath } from './extensions/enhanced-math'
|
import { EnhancedMath } from './extensions/enhanced-math'
|
||||||
import { Placeholder } from './extensions/placeholder'
|
import { Placeholder } from './extensions/placeholder'
|
||||||
|
import { YamlFrontMatter } from './extensions/yaml-front-matter'
|
||||||
import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers/imageUtils'
|
import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers/imageUtils'
|
||||||
|
|
||||||
const logger = loggerService.withContext('useRichEditor')
|
const logger = loggerService.withContext('useRichEditor')
|
||||||
@ -320,6 +321,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
showOnlyCurrent: true,
|
showOnlyCurrent: true,
|
||||||
includeChildren: false
|
includeChildren: false
|
||||||
}),
|
}),
|
||||||
|
YamlFrontMatter,
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'mention'
|
class: 'mention'
|
||||||
|
|||||||
@ -2221,6 +2221,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dragHandle": "Drag to move",
|
"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": {
|
"image": {
|
||||||
"placeholder": "Add a picture"
|
"placeholder": "Add a picture"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2221,6 +2221,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dragHandle": "拖拽块",
|
"dragHandle": "拖拽块",
|
||||||
|
"frontMatter": {
|
||||||
|
"addProperty": "添加属性",
|
||||||
|
"addTag": "添加标签",
|
||||||
|
"changeToBoolean": "复选框",
|
||||||
|
"changeToDate": "日期",
|
||||||
|
"changeToNumber": "数字",
|
||||||
|
"changeToTags": "标签",
|
||||||
|
"changeToText": "文本",
|
||||||
|
"changeType": "更改类型",
|
||||||
|
"deleteProperty": "删除属性",
|
||||||
|
"editValue": "编辑值",
|
||||||
|
"empty": "空",
|
||||||
|
"moreActions": "更多操作",
|
||||||
|
"propertyName": "属性名称"
|
||||||
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"placeholder": "添加图片"
|
"placeholder": "添加图片"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2221,6 +2221,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dragHandle": "拖拽塊",
|
"dragHandle": "拖拽塊",
|
||||||
|
"frontMatter": {
|
||||||
|
"addProperty": "新增屬性",
|
||||||
|
"addTag": "新增標籤",
|
||||||
|
"changeToBoolean": "核取方塊",
|
||||||
|
"changeToDate": "日期",
|
||||||
|
"changeToNumber": "數字",
|
||||||
|
"changeToTags": "標籤",
|
||||||
|
"changeToText": "文字",
|
||||||
|
"changeType": "更改類型",
|
||||||
|
"deleteProperty": "刪除屬性",
|
||||||
|
"editValue": "編輯值",
|
||||||
|
"empty": "空",
|
||||||
|
"moreActions": "更多操作",
|
||||||
|
"propertyName": "屬性名稱"
|
||||||
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"placeholder": "添加圖片"
|
"placeholder": "添加圖片"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -484,4 +484,25 @@ describe('markdownConverter', () => {
|
|||||||
expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\n')
|
expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -229,6 +229,87 @@ interface InlineStateLike {
|
|||||||
push: (type: string, tag: string, nesting: number) => TokenLike & { content?: string }
|
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 `<div data-type="yaml-front-matter" data-content="${he.encode(content)}">${content}</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function tipTapKatexPlugin(md: MarkdownIt) {
|
function tipTapKatexPlugin(md: MarkdownIt) {
|
||||||
// 1) Parser: recognize $$ ... $$ as a block math token
|
// 1) Parser: recognize $$ ... $$ as a block math token
|
||||||
md.block.ruler.before(
|
md.block.ruler.before(
|
||||||
@ -371,6 +452,8 @@ function tipTapKatexPlugin(md: MarkdownIt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
md.use(yamlFrontMatterPlugin)
|
||||||
|
|
||||||
md.use(taskListPlugin, {
|
md.use(taskListPlugin, {
|
||||||
label: true
|
label: true
|
||||||
})
|
})
|
||||||
@ -437,6 +520,28 @@ turndownService.addRule('br', {
|
|||||||
replacement: () => '<br>'
|
replacement: () => '<br>'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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
|
// Helper function to safely get text content and clean it with LaTeX support
|
||||||
function cleanCellContent(content: string, cellElement?: Element): string {
|
function cleanCellContent(content: string, cellElement?: Element): string {
|
||||||
// First check for math elements in the cell
|
// First check for math elements in the cell
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@ -10127,6 +10127,7 @@ __metadata:
|
|||||||
winston-daily-rotate-file: "npm:^5.0.0"
|
winston-daily-rotate-file: "npm:^5.0.0"
|
||||||
word-extractor: "npm:^1.0.4"
|
word-extractor: "npm:^1.0.4"
|
||||||
y-protocols: "npm:^1.0.6"
|
y-protocols: "npm:^1.0.6"
|
||||||
|
yaml: "npm:^2.8.1"
|
||||||
yjs: "npm:^13.6.27"
|
yjs: "npm:^13.6.27"
|
||||||
youtubei.js: "npm:^15.0.1"
|
youtubei.js: "npm:^15.0.1"
|
||||||
zipread: "npm:^1.3.3"
|
zipread: "npm:^1.3.3"
|
||||||
@ -25505,6 +25506,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"yargs-parser@npm:^21.1.1":
|
||||||
version: 21.1.1
|
version: 21.1.1
|
||||||
resolution: "yargs-parser@npm:21.1.1"
|
resolution: "yargs-parser@npm:21.1.1"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user