feat(viewer): mode pretty print avec coloration syntaxique et numeros de ligne pour fichiers texte (#89)
This commit is contained in:
@@ -20,6 +20,9 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
overlay plein écran à double cible (dépôt vault vs analyse IA), surbrillance des cibles dans
|
overlay plein écran à double cible (dépôt vault vs analyse IA), surbrillance des cibles dans
|
||||||
l'arborescence, et dropzone BooksLM pour injection de contexte ou pièces jointes.
|
l'arborescence, et dropzone BooksLM pour injection de contexte ou pièces jointes.
|
||||||
Fiche : [docs/features/drag-and-drop-ai.md](./docs/features/drag-and-drop-ai.md).
|
Fiche : [docs/features/drag-and-drop-ai.md](./docs/features/drag-and-drop-ai.md).
|
||||||
|
- **#89 Pretty print fichiers texte** — bouton « Pretty » dans le mode lecture des fichiers
|
||||||
|
non-markdown (code, JSON, configs, logs...) : formatage JSON, numéros de ligne avec gouttière
|
||||||
|
et coloration syntaxique highlight.js alignée sur le thème clair/sombre.
|
||||||
- **#88 Assistant IA : contexte applicatif** — l'assistant en mode **Général**
|
- **#88 Assistant IA : contexte applicatif** — l'assistant en mode **Général**
|
||||||
reçoit l'état vivant de l'interface (`app_context`) et connaît désormais les
|
reçoit l'état vivant de l'interface (`app_context`) et connaît désormais les
|
||||||
documents ouverts, le répertoire et le vault courants, la recherche en cours
|
documents ouverts, le répertoire et le vault courants, la recherche en cours
|
||||||
|
|||||||
@@ -446,6 +446,27 @@ async function renderBacklinksPanel(vault, path, container) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pretty print (mode lecture enrichi pour fichiers texte non-markdown) ──
|
||||||
|
// Detecte le langage depuis le HTML deja rendu par le backend (classe
|
||||||
|
// language-xxx), formate le JSON, puis genere un bloc avec gouttiere de
|
||||||
|
// numeros de ligne. La coloration est appliquee ensuite via highlight.js.
|
||||||
|
function detectPrettyLang(data) {
|
||||||
|
if (data.is_json || data.extension === ".json") return "json";
|
||||||
|
const m = /class="language-([\w-]+)/.exec(data.html || "");
|
||||||
|
return m ? m[1] : "plaintext";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrettyHtml(data, raw) {
|
||||||
|
let text = raw;
|
||||||
|
const lang = detectPrettyLang(data);
|
||||||
|
if (lang === "json") {
|
||||||
|
try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { /* JSON invalide: on affiche brut */ }
|
||||||
|
}
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const gutter = lines.map((_, i) => `<span>${i + 1}</span>`).join("");
|
||||||
|
return `<div class="pretty-view"><div class="pp-gutter" aria-hidden="true">${gutter}</div><pre class="pp-pre"><code class="language-${lang}">${escapeHtml(text)}</code></pre></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderFile(data) {
|
export function renderFile(data) {
|
||||||
const area = getContentArea();
|
const area = getContentArea();
|
||||||
|
|
||||||
@@ -774,10 +795,40 @@ export function renderFile(data) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pretty print — mode lecture enrichi (numeros de ligne + coloration)
|
||||||
|
// pour les fichiers texte non-markdown (code, JSON, configs, logs...)
|
||||||
|
let prettyBtn = null;
|
||||||
|
const isTextViewerFile = !data.is_markdown && !data.is_csv && !data.unsupported && !data.is_pdf && !data.is_image && !data.is_excalidraw;
|
||||||
|
if (isTextViewerFile) {
|
||||||
|
prettyBtn = el("button", { class: "btn-action", title: t("viewer.pretty_title") || "Mode formaté avec couleurs et numéros de ligne" }, [icon("sparkles", 14), document.createTextNode(t("viewer.pretty") || "Pretty")]);
|
||||||
|
prettyBtn.addEventListener("click", async () => {
|
||||||
|
const on = !prettyBtn.classList.contains("active");
|
||||||
|
if (on) {
|
||||||
|
try {
|
||||||
|
if (!state.cachedRawSource) {
|
||||||
|
const rawUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||||||
|
const rawData = await api(rawUrl);
|
||||||
|
state.cachedRawSource = rawData.raw;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
state.cachedRawSource = mdDiv.textContent || "";
|
||||||
|
}
|
||||||
|
mdDiv.innerHTML = buildPrettyHtml(data, state.cachedRawSource);
|
||||||
|
safeHighlight(mdDiv.querySelector("code"));
|
||||||
|
prettyBtn.classList.add("active");
|
||||||
|
} else {
|
||||||
|
mdDiv.innerHTML = data.html;
|
||||||
|
mdDiv.querySelectorAll("pre code").forEach((block) => safeHighlight(block));
|
||||||
|
prettyBtn.classList.remove("active");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const fileActions = [copyBtn, sourceBtn];
|
const fileActions = [copyBtn, sourceBtn];
|
||||||
if (!data.is_image) fileActions.push(mdBtn);
|
if (!data.is_image) fileActions.push(mdBtn);
|
||||||
if (data.is_markdown) fileActions.push(pdfBtn, exportWrap, forgeBtn, tocBtn);
|
if (data.is_markdown) fileActions.push(pdfBtn, exportWrap, forgeBtn, tocBtn);
|
||||||
if (!data.unsupported) fileActions.push(editBtn);
|
if (!data.unsupported) fileActions.push(editBtn);
|
||||||
|
if (prettyBtn) fileActions.push(prettyBtn);
|
||||||
fileActions.push(openNewWindowBtn, shareBtn, bookmarkBtn);
|
fileActions.push(openNewWindowBtn, shareBtn, bookmarkBtn);
|
||||||
|
|
||||||
// Assemble
|
// Assemble
|
||||||
|
|||||||
@@ -1687,6 +1687,8 @@
|
|||||||
"viewer.download": "Download",
|
"viewer.download": "Download",
|
||||||
"viewer.download_md": "Download as .md",
|
"viewer.download_md": "Download as .md",
|
||||||
"viewer.download_file": "Download file",
|
"viewer.download_file": "Download file",
|
||||||
|
"viewer.pretty": "Pretty",
|
||||||
|
"viewer.pretty_title": "Formatted mode: colors and line numbers",
|
||||||
"viewer.download_pdf": "Download as PDF",
|
"viewer.download_pdf": "Download as PDF",
|
||||||
"viewer.edit": "Edit",
|
"viewer.edit": "Edit",
|
||||||
"viewer.error": "Loading error",
|
"viewer.error": "Loading error",
|
||||||
|
|||||||
@@ -1687,6 +1687,8 @@
|
|||||||
"viewer.download": "Télécharger",
|
"viewer.download": "Télécharger",
|
||||||
"viewer.download_md": "Télécharger en .md",
|
"viewer.download_md": "Télécharger en .md",
|
||||||
"viewer.download_file": "Télécharger le fichier",
|
"viewer.download_file": "Télécharger le fichier",
|
||||||
|
"viewer.pretty": "Pretty",
|
||||||
|
"viewer.pretty_title": "Mode formaté : couleurs et numéros de ligne",
|
||||||
"viewer.download_pdf": "Télécharger en PDF",
|
"viewer.download_pdf": "Télécharger en PDF",
|
||||||
"viewer.edit": "Éditer",
|
"viewer.edit": "Éditer",
|
||||||
"viewer.error": "Erreur de chargement",
|
"viewer.error": "Erreur de chargement",
|
||||||
|
|||||||
@@ -2052,6 +2052,47 @@ select {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Pretty print (mode lecture fichiers texte non-markdown) --- */
|
||||||
|
.pretty-view {
|
||||||
|
display: flex;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 78vh;
|
||||||
|
}
|
||||||
|
.pretty-view .pp-gutter {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px 10px 16px 14px;
|
||||||
|
text-align: right;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.65;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pretty-view .pp-pre {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.pretty-view .pp-pre code {
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Markdown Rendered Content --- */
|
/* --- Markdown Rendered Content --- */
|
||||||
.md-content h1,
|
.md-content h1,
|
||||||
.md-content h2,
|
.md-content h2,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Frontend tests for Pretty print helpers (#89).
|
||||||
|
* detectPrettyLang / buildPrettyHtml are pure functions extracted from
|
||||||
|
* frontend/js/viewer.js; they are evaluated in a sandbox to avoid importing
|
||||||
|
* the full viewer module chain (browser-only dependencies).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const src = readFileSync(path.join(__dirname, "..", "..", "frontend", "js", "viewer.js"), "utf8");
|
||||||
|
|
||||||
|
function extractFn(name) {
|
||||||
|
const start = src.indexOf(`function ${name}(`);
|
||||||
|
assert.ok(start !== -1, `function ${name} not found in viewer.js`);
|
||||||
|
let depth = 0;
|
||||||
|
let i = src.indexOf("{", start);
|
||||||
|
const begin = i;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === "{") depth++;
|
||||||
|
else if (src[i] === "}") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandbox = { escapeHtml };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(`${extractFn("detectPrettyLang")}\n${extractFn("buildPrettyHtml")}\nglobalThis.detectPrettyLang = detectPrettyLang; globalThis.buildPrettyHtml = buildPrettyHtml;`, sandbox);
|
||||||
|
const { detectPrettyLang, buildPrettyHtml } = sandbox;
|
||||||
|
|
||||||
|
console.log("Testing detectPrettyLang...");
|
||||||
|
assert.equal(detectPrettyLang({ is_json: true }), "json");
|
||||||
|
assert.equal(detectPrettyLang({ extension: ".json" }), "json");
|
||||||
|
assert.equal(detectPrettyLang({ extension: ".py", html: '<pre><code class="language-python">x</code></pre>' }), "python");
|
||||||
|
assert.equal(detectPrettyLang({ extension: ".sh", html: '<pre><code class="language-bash">x</code></pre>' }), "bash");
|
||||||
|
assert.equal(detectPrettyLang({ extension: ".txt", html: "<pre><code>x</code></pre>" }), "plaintext");
|
||||||
|
|
||||||
|
console.log("Testing buildPrettyHtml (json reformat)...");
|
||||||
|
const jsonOut = buildPrettyHtml({ is_json: true }, '{"a":1,"b":[2,3]}');
|
||||||
|
assert.ok(jsonOut.includes("pretty-view"), "wrapper class present");
|
||||||
|
assert.ok(jsonOut.includes("pp-gutter"), "gutter present");
|
||||||
|
assert.ok(jsonOut.includes('class="language-json"'), "language class present");
|
||||||
|
// 4 formatted lines: {, "a": 1, "b": [...], } -> gutter must show 4 numbers at least
|
||||||
|
assert.ok(/<span>4<\/span>/.test(jsonOut), "line numbers computed on formatted text");
|
||||||
|
assert.ok(jsonOut.includes(""a""), "HTML-escaped quotes");
|
||||||
|
|
||||||
|
console.log("Testing buildPrettyHtml (invalid json falls back to raw)...");
|
||||||
|
const bad = buildPrettyHtml({ is_json: true }, "{not valid json}");
|
||||||
|
assert.ok(bad.includes("{not valid json}"), "raw content displayed when JSON invalid");
|
||||||
|
|
||||||
|
console.log("Testing buildPrettyHtml (xss escaped)...");
|
||||||
|
const xss = buildPrettyHtml({ extension: ".py", html: "" }, 'print("<script>alert(1)</script>")');
|
||||||
|
assert.ok(!xss.includes("<script>"), "no raw script tag injected");
|
||||||
|
assert.ok(xss.includes("<script>"), "script tag escaped");
|
||||||
|
|
||||||
|
console.log("✅ All pretty-print tests passed successfully!");
|
||||||
Reference in New Issue
Block a user