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:
SuYao 2025-09-06 17:10:16 +08:00 committed by GitHub
parent 1640ba2e9e
commit cb4b26c8a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1045 additions and 0 deletions

View File

@ -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",

View File

@ -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

View File

@ -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 []
}
})

View File

@ -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'

View File

@ -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"
},

View File

@ -2221,6 +2221,21 @@
}
},
"dragHandle": "拖拽块",
"frontMatter": {
"addProperty": "添加属性",
"addTag": "添加标签",
"changeToBoolean": "复选框",
"changeToDate": "日期",
"changeToNumber": "数字",
"changeToTags": "标签",
"changeToText": "文本",
"changeType": "更改类型",
"deleteProperty": "删除属性",
"editValue": "编辑值",
"empty": "空",
"moreActions": "更多操作",
"propertyName": "属性名称"
},
"image": {
"placeholder": "添加图片"
},

View File

@ -2221,6 +2221,21 @@
}
},
"dragHandle": "拖拽塊",
"frontMatter": {
"addProperty": "新增屬性",
"addTag": "新增標籤",
"changeToBoolean": "核取方塊",
"changeToDate": "日期",
"changeToNumber": "數字",
"changeToTags": "標籤",
"changeToText": "文字",
"changeType": "更改類型",
"deleteProperty": "刪除屬性",
"editValue": "編輯值",
"empty": "空",
"moreActions": "更多操作",
"propertyName": "屬性名稱"
},
"image": {
"placeholder": "添加圖片"
},

View File

@ -484,4 +484,25 @@ describe('markdownConverter', () => {
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)
})
})
})

View File

@ -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 `<div data-type="yaml-front-matter" data-content="${he.encode(content)}">${content}</div>`
}
}
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: () => '<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
function cleanCellContent(content: string, cellElement?: Element): string {
// First check for math elements in the cell

View File

@ -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"