- SlugGuard.IsValid() rejects slugs with path traversal (../, //, uppercase) - SlugGuard.ToSafePath() ensures sync writes stay within target directory - PushCommand: skip files with invalid slugs before upload - SyncCommand: validate server-returned slugs before writing to disk - EditCommand/NewCommand: try/finally guarantees temp file deletion on crash - NotesCache: set 600 permissions on cache file (metadata only, no token) - NewCommand: remove dead Sha256 method (HashHelper already covers this)
195 lines
7.7 KiB
C#
195 lines
7.7 KiB
C#
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Spectre.Console;
|
|
|
|
namespace NozCli.Rendering;
|
|
|
|
/// Terminal markdown renderer — converts common markdown to Spectre.Console output.
|
|
/// Handles: headings, bold/italic, inline code, fenced code blocks,
|
|
/// bullet/numbered lists, blockquotes, horizontal rules.
|
|
public static partial class MarkdownRenderer
|
|
{
|
|
public static void Render(string markdown)
|
|
{
|
|
var lines = markdown.ReplaceLineEndings("\n").Split('\n');
|
|
var inCode = false;
|
|
var codeLang = "";
|
|
var codeBlock = new StringBuilder();
|
|
|
|
AnsiConsole.WriteLine();
|
|
|
|
for (var i = 0; i < lines.Length; i++)
|
|
{
|
|
var line = lines[i];
|
|
|
|
// ── Fenced code block ────────────────────────────────────────────
|
|
if (line.TrimStart().StartsWith("```"))
|
|
{
|
|
if (!inCode)
|
|
{
|
|
inCode = true;
|
|
codeLang = line.TrimStart()[3..].Trim();
|
|
codeBlock.Clear();
|
|
}
|
|
else
|
|
{
|
|
inCode = false;
|
|
RenderCodeBlock(codeBlock.ToString().TrimEnd(), codeLang);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inCode)
|
|
{
|
|
codeBlock.AppendLine(line);
|
|
continue;
|
|
}
|
|
|
|
// ── Horizontal rule ──────────────────────────────────────────────
|
|
if (HrRegex().IsMatch(line))
|
|
{
|
|
AnsiConsole.Write(new Rule() { Style = new Style(new Color(60, 60, 80)) });
|
|
continue;
|
|
}
|
|
|
|
// ── Headings ─────────────────────────────────────────────────────
|
|
if (line.StartsWith("### "))
|
|
{
|
|
AnsiConsole.MarkupLine($"\n [bold white]{Markup.Escape(line[4..])}[/]");
|
|
continue;
|
|
}
|
|
if (line.StartsWith("## "))
|
|
{
|
|
AnsiConsole.MarkupLine($"\n[bold cyan]{Markup.Escape(line[3..])}[/]");
|
|
continue;
|
|
}
|
|
if (line.StartsWith("# "))
|
|
{
|
|
AnsiConsole.MarkupLine($"\n[bold underline cyan]{Markup.Escape(line[2..])}[/]\n");
|
|
continue;
|
|
}
|
|
|
|
// ── Blockquote ───────────────────────────────────────────────────
|
|
if (line.StartsWith("> "))
|
|
{
|
|
var content = ApplyInline(line[2..]);
|
|
AnsiConsole.MarkupLine($"[grey46]┃[/] [dim italic]{content}[/]");
|
|
continue;
|
|
}
|
|
|
|
// ── Bullet list ──────────────────────────────────────────────────
|
|
var bulletMatch = BulletRegex().Match(line);
|
|
if (bulletMatch.Success)
|
|
{
|
|
var indent = bulletMatch.Groups[1].Value.Length / 2;
|
|
var content = ApplyInline(bulletMatch.Groups[2].Value);
|
|
var pad = new string(' ', indent * 2);
|
|
AnsiConsole.MarkupLine($"{pad} [dim cyan]•[/] {content}");
|
|
continue;
|
|
}
|
|
|
|
// ── Numbered list ────────────────────────────────────────────────
|
|
var numMatch = NumberedRegex().Match(line);
|
|
if (numMatch.Success)
|
|
{
|
|
var num = numMatch.Groups[1].Value;
|
|
var content = ApplyInline(numMatch.Groups[2].Value);
|
|
AnsiConsole.MarkupLine($" [dim cyan]{num}.[/] {content}");
|
|
continue;
|
|
}
|
|
|
|
// ── Empty line ───────────────────────────────────────────────────
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
{
|
|
AnsiConsole.WriteLine();
|
|
continue;
|
|
}
|
|
|
|
// ── Paragraph ────────────────────────────────────────────────────
|
|
AnsiConsole.MarkupLine($" {ApplyInline(line)}");
|
|
}
|
|
|
|
AnsiConsole.WriteLine();
|
|
}
|
|
|
|
// ── Code block rendering ─────────────────────────────────────────────────
|
|
|
|
private static void RenderCodeBlock(string code, string lang)
|
|
{
|
|
var header = lang.Length > 0 ? $"[dim]{Markup.Escape(lang)}[/]" : "[dim]code[/]";
|
|
var panel = new Panel(new Text(code, new Style(new Color(200, 200, 220))))
|
|
{
|
|
Header = new PanelHeader($" {header} "),
|
|
Border = BoxBorder.Rounded,
|
|
BorderStyle = new Style(new Color(60, 60, 80)),
|
|
Padding = new Padding(1, 0),
|
|
};
|
|
AnsiConsole.WriteLine();
|
|
AnsiConsole.Write(panel);
|
|
}
|
|
|
|
// ── Inline formatting ────────────────────────────────────────────────────
|
|
|
|
private static string ApplyInline(string raw)
|
|
{
|
|
// Collect all pattern matches, escape text between them.
|
|
// This ensures brackets/special chars in regular text don't break Spectre markup.
|
|
(Regex regex, Func<Match, string> format)[] patterns =
|
|
[
|
|
(InlineCodeRegex(), m => $"[bold cyan on grey7] {Markup.Escape(m.Groups[1].Value)} [/]"),
|
|
(BoldRegex(), m => $"[bold]{Markup.Escape(m.Groups[1].Value)}[/]"),
|
|
(ItalicRegex(), m => $"[italic]{Markup.Escape(m.Groups[1].Value)}[/]"),
|
|
];
|
|
|
|
var spans = new List<(int start, int end, string replacement)>();
|
|
|
|
foreach (var (regex, format) in patterns)
|
|
{
|
|
foreach (Match m in regex.Matches(raw))
|
|
{
|
|
// Skip if overlapping with an already-claimed span
|
|
if (spans.Exists(s => m.Index < s.end && m.Index + m.Length > s.start))
|
|
continue;
|
|
spans.Add((m.Index, m.Index + m.Length, format(m)));
|
|
}
|
|
}
|
|
|
|
spans.Sort((a, b) => a.start.CompareTo(b.start));
|
|
|
|
var sb = new StringBuilder();
|
|
var pos = 0;
|
|
|
|
foreach (var (start, end, replacement) in spans)
|
|
{
|
|
if (pos < start)
|
|
sb.Append(Markup.Escape(raw[pos..start]));
|
|
sb.Append(replacement);
|
|
pos = end;
|
|
}
|
|
|
|
if (pos < raw.Length)
|
|
sb.Append(Markup.Escape(raw[pos..]));
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ── Compiled regexes (AOT-safe via [GeneratedRegex]) ────────────────────
|
|
|
|
[GeneratedRegex(@"^[-*_]{3,}\s*$")]
|
|
private static partial Regex HrRegex();
|
|
|
|
[GeneratedRegex(@"^( *)[-*+] (.+)$")]
|
|
private static partial Regex BulletRegex();
|
|
|
|
[GeneratedRegex(@"^(\d+)\. (.+)$")]
|
|
private static partial Regex NumberedRegex();
|
|
|
|
[GeneratedRegex(@"`([^`]+)`")]
|
|
private static partial Regex InlineCodeRegex();
|
|
|
|
[GeneratedRegex(@"\*\*([^*]+)\*\*")]
|
|
private static partial Regex BoldRegex();
|
|
|
|
[GeneratedRegex(@"(?<!\*)\*(?!\*)([^*]+)\*(?!\*)")]
|
|
private static partial Regex ItalicRegex();
|
|
}
|