feat(notes): renderer markdown étendu v0.5.9

- Tableaux (pipe syntax) avec header distinct
- Task lists - [ ] / - [x] avec checkbox colorée
- Listes imbriquées avec indentation (• / ◦)
- Texte barré ~~strikethrough~~
- Liens [texte](url) cliquables (target _blank)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 20:13:14 +02:00
parent ec87bc091d
commit f81be12a38
2 changed files with 78 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.5.8",
"version": "0.5.9",
"type": "module",
"scripts": {
"dev": "vite",
+77 -6
View File
@@ -36,15 +36,18 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
}
// Formate le texte inline : **gras**, *italique*, `code`
// Formate le texte inline : **gras**, *italique*, ~~barré~~, `code`, [lien](url)
function inlineFmt(text: string): React.ReactNode {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/)
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|`[^`]+`|\[[^\]]+\]\([^)]+\))/)
return (
<>
{parts.map((p, i) => {
if (p.startsWith('**') && p.endsWith('**')) return <strong key={i}>{p.slice(2, -2)}</strong>
if (p.startsWith('~~') && p.endsWith('~~')) return <del key={i} style={{ color: 'var(--ink-4)' }}>{p.slice(2, -2)}</del>
if (p.startsWith('*') && p.endsWith('*')) return <em key={i} style={{ color: 'var(--ink-3)' }}>{p.slice(1, -1)}</em>
if (p.startsWith('`') && p.endsWith('`')) return <code key={i} style={{ background: 'var(--bg-4)', borderRadius: 3, padding: '0 4px', fontFamily: 'var(--font-mono)', fontSize: '0.88em' }}>{p.slice(1, -1)}</code>
const linkMatch = p.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (linkMatch) return <a key={i} href={linkMatch[2]} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)', textDecoration: 'underline' }}>{linkMatch[1]}</a>
return p || null
})}
</>
@@ -58,6 +61,8 @@ function renderMarkdown(text: string): React.ReactNode {
let i = 0
while (i < lines.length) {
const line = lines[i]
// Blocs de code
if (line.startsWith('```')) {
const lang = line.slice(3).trim()
const code: string[] = []
@@ -69,19 +74,77 @@ function renderMarkdown(text: string): React.ReactNode {
{code.join('\n')}
</pre>
)
// Tableaux : ligne commençant et finissant par |
} else if (line.startsWith('|') && line.trim().endsWith('|')) {
const tableLines: string[] = [line]
i++
while (i < lines.length && lines[i].startsWith('|') && lines[i].trim().endsWith('|')) {
tableLines.push(lines[i])
i++
}
const parseRow = (r: string) => r.split('|').slice(1, -1).map(c => c.trim())
const isSep = (r: string) => /^\|[\s|:-]+\|$/.test(r.trim())
const headers = parseRow(tableLines[0])
const bodyRows = tableLines.filter((_, idx) => idx > 0 && !isSep(tableLines[idx]))
nodes.push(
<div key={i} style={{ overflowX: 'auto', margin: '6px 0' }}>
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 12, fontFamily: 'var(--font-ui)' }}>
<thead>
<tr>
{headers.map((h, hi) => (
<th key={hi} style={{ border: '1px solid var(--bg-5)', padding: '4px 8px', background: 'var(--bg-4)', color: 'var(--ink-2)', fontWeight: 600, textAlign: 'left' }}>{inlineFmt(h)}</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, ri) => (
<tr key={ri}>
{parseRow(row).map((cell, ci) => (
<td key={ci} style={{ border: '1px solid var(--bg-5)', padding: '4px 8px', color: 'var(--ink-2)' }}>{inlineFmt(cell)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
continue
// Titres
} else if (line.startsWith('# ')) {
nodes.push(<div key={i} style={{ fontWeight: 700, fontSize: 15, color: 'var(--accent)', marginTop: 10, marginBottom: 2, fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(2))}</div>)
} else if (line.startsWith('## ')) {
nodes.push(<div key={i} style={{ fontWeight: 700, fontSize: 14, color: 'var(--ink-1)', marginTop: 8, marginBottom: 2, paddingBottom: 3, borderBottom: '1px solid var(--bg-5)', fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(3))}</div>)
} else if (line.startsWith('### ')) {
nodes.push(<div key={i} style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink-1)', marginTop: 6, marginBottom: 1, fontFamily: 'var(--font-ui)' }}>{inlineFmt(line.slice(4))}</div>)
} else if (line.startsWith('- ') || line.startsWith('* ')) {
// Task lists : - [ ] et - [x]
} else if (/^(\s*)-\s\[([ xX])\]\s/.test(line)) {
const m = line.match(/^(\s*)-\s\[([ xX])\]\s(.*)/)
const indent = (m?.[1] ?? '').length
const done = (m?.[2] ?? ' ').toLowerCase() === 'x'
nodes.push(
<div key={i} style={{ display: 'flex', gap: 6, color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<span style={{ color: 'var(--accent)', flexShrink: 0 }}></span>
<span>{inlineFmt(line.slice(2))}</span>
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'flex-start', marginLeft: indent * 8, color: done ? 'var(--ink-4)' : 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<span style={{ flexShrink: 0, width: 14, height: 14, marginTop: 3, border: `1.5px solid ${done ? 'var(--ok)' : 'var(--ink-4)'}`, borderRadius: 3, display: 'flex', alignItems: 'center', justifyContent: 'center', background: done ? 'var(--ok)' : 'transparent' }}>
{done && <i className="fa-solid fa-check" style={{ fontSize: 8, color: 'var(--bg-1)' }} />}
</span>
<span style={{ textDecoration: done ? 'line-through' : 'none' }}>{inlineFmt(m?.[3] ?? '')}</span>
</div>
)
// Listes non ordonnées (avec indentation possible)
} else if (/^(\s*)[-*]\s/.test(line)) {
const m = line.match(/^(\s*)[-*]\s(.*)/)
const indent = (m?.[1] ?? '').length
nodes.push(
<div key={i} style={{ display: 'flex', gap: 6, marginLeft: indent * 8, color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<span style={{ color: 'var(--accent)', flexShrink: 0 }}>{indent > 0 ? '◦' : '•'}</span>
<span>{inlineFmt(m?.[2] ?? '')}</span>
</div>
)
// Listes ordonnées
} else if (/^\d+\.\s/.test(line)) {
const m = line.match(/^(\d+)\.\s(.*)/)
nodes.push(
@@ -90,16 +153,24 @@ function renderMarkdown(text: string): React.ReactNode {
<span>{inlineFmt(m?.[2] ?? '')}</span>
</div>
)
// Citation
} else if (line.startsWith('> ')) {
nodes.push(
<div key={i} style={{ borderLeft: '3px solid var(--accent)', paddingLeft: 10, color: 'var(--ink-3)', fontStyle: 'italic', margin: '4px 0', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
{inlineFmt(line.slice(2))}
</div>
)
// Séparateur
} else if (line === '---' || line === '***') {
nodes.push(<div key={i} style={{ borderBottom: '1px solid var(--bg-5)', margin: '8px 0' }} />)
// Ligne vide
} else if (line.trim() === '') {
nodes.push(<div key={i} style={{ height: 5 }} />)
// Paragraphe normal
} else {
nodes.push(
<div key={i} style={{ color: 'var(--ink-2)', lineHeight: 1.6, fontFamily: 'var(--font-ui)', fontSize: 13 }}>