feat(extension): rendu Markdown dans la prévisualisation + zone agrandie v0.4.3
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m24s
CI / Deploy production (on server) (push) Successful in 23s

Avant : renderConfirm() échappait le résumé et l'extrait avec escapeHtml(),
donc les **gras**, *italique*, [liens](), listes, titres du Markdown
produit par l'IA s'affichaient en texte brut (littéral « **gras** »).

Maintenant : renderMarkdownSafe() parse le Markdown en HTML limité
et sûr (escapeHtml d'abord, puis regex sur le texte échappé — pas de
HTML brut autorisé).

+ Refonte du panneau de prévisualisation :
  - .preview-block avec header sticky (label Résumé / Extrait)
  - .excerpt-block : min-height 180px, max-height 360px, scroll si long
  - .summary-block : fond bleuté léger, bordure gauche sky
  - markdown-h3/h4/h5 stylés (serif pour titres), strong/em/code/ul/a
  - Polices 14px, line-height 1.7, word-break pour texte long

+ Nouvelle clé i18n summaryLabel dans les 15 locales.
+ Version 0.4.2 → 0.4.3.
This commit is contained in:
Antigravity
2026-07-18 10:35:00 +00:00
parent 1987b084c3
commit bff060ad20
58 changed files with 886 additions and 341 deletions

View File

@@ -71,6 +71,42 @@ function escapeHtml(s) {
.replace(/"/g, '"')
}
/**
* Mini-renderer Markdown sûr pour le side panel.
* - Échappe d'abord le texte (anti-XSS)
* - Applique ensuite les transformations HTML par regex sur le texte échappé
* - Supporte : titres, gras, italique, liens, code, listes à puces, sauts de ligne
*
* Volontairement limité — pas de tableaux imbriqués, pas de HTML brut.
*/
function renderMarkdownSafe(md) {
if (!md) return ''
let s = escapeHtml(md)
// Liens : [texte](https://url) → <a>
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Titres : ### (avant ## avant #)
s = s.replace(/^### (.+)$/gm, '<h5 class="md-h5">$1</h5>')
s = s.replace(/^## (.+)$/gm, '<h4 class="md-h4">$1</h4>')
s = s.replace(/^# (.+)$/gm, '<h3 class="md-h3">$1</h3>')
// Gras (**texte**) puis italique (*texte* ou _texte_)
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
s = s.replace(/(^|\b)_([^_\n]+)_/g, '$1<em>$2</em>')
// Code inline `code`
s = s.replace(/`([^`\n]+)`/g, '<code class="md-code">$1</code>')
// Listes à puces : lignes commençant par - ou *
s = s.replace(/^(\s*)[-*]\s+(.+)$/gm, '$1<li>$2</li>')
s = s.replace(/(<li>.*<\/li>\n?)+/g, (m) => `<ul class="md-ul">${m}</ul>`)
// Sauts de ligne (double = paragraphe)
s = s.replace(/\n{2,}/g, '</p><p>')
s = s.replace(/\n/g, '<br/>')
// Envelopper dans un paragraphe si pas déjà fait
if (!s.startsWith('<')) s = `<p>${s}</p>`
else if (!/^<(p|h[3-5]|ul|li)/.test(s)) s = `<p>${s}</p>`
return s
}
const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
const LTR_CHAR = /[A-Za-z0-9]/
@@ -491,10 +527,16 @@ function renderLoading(label) {
function renderConfirm() {
const excerpt = analyzeResult?.excerpt || ''
const summary = analyzeResult?.summary || ''
const tags = analyzeResult?.tags || []
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
// Le résumé et l'extrait peuvent contenir du Markdown produit par l'IA.
// On rend le Markdown en HTML (limité et sûr) au lieu de l'afficher brut.
const summaryHtml = summary ? renderMarkdownSafe(summary) : ''
const excerptHtml = (excerpt && pendingClipType !== 'link') ? renderMarkdownSafe(excerpt) : ''
els.screen.innerHTML = `
<div class="confirm-panel">
<span class="label">${escapeHtml(t('previewBeforeSave'))}</span>
@@ -502,25 +544,16 @@ function renderConfirm() {
<span>${escapeHtml(t('noteTitleLabel'))}</span>
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
</label>
${
reading
? `<div class="meta-row"><span class="reading-time">${escapeHtml(reading)}</span></div>`
: ''
}
${
analyzeResult?.summary
? `<p class="summary-preview"${rtlAttrs(analyzeResult.summary)}>${escapeHtml(analyzeResult.summary)}</p>`
: ''
}
${
excerpt && pendingClipType !== 'link'
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
<span class="excerpt-label">${escapeHtml(t('excerptLabel'))}</span>
${escapeHtml(excerpt)}
</div>`
: ''
}
${reading ? `<div class="meta-row"><span class="reading-time">${escapeHtml(reading)}</span></div>` : ''}
${tagsHtml ? `<div class="tags preview-tags">${tagsHtml}</div>` : ''}
${summaryHtml ? `<section class="preview-block summary-block"${rtlAttrs(summary)}>
<header class="preview-block-head"><span class="dot dot-summary"></span>${escapeHtml(t('summaryLabel') || 'Résumé')}</header>
<div class="preview-block-body md">${summaryHtml}</div>
</section>` : ''}
${excerptHtml ? `<section class="preview-block excerpt-block"${rtlAttrs(excerpt)}>
<header class="preview-block-head"><span class="dot dot-excerpt"></span>${escapeHtml(t('excerptLabel'))}</header>
<div class="preview-block-body md md-excerpt">${excerptHtml}</div>
</section>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="saveBtn">${escapeHtml(t('saveToMomento'))}</button>