mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Improve markdown stability for 0.5.1 and finalize release checks
This commit is contained in:
parent
f7fe16dfa5
commit
b265701637
8 changed files with 192 additions and 6 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -4,6 +4,16 @@ All notable changes to **Neon Vision Editor** are documented in this file.
|
|||
|
||||
The format follows *Keep a Changelog*. Versions use semantic versioning with prerelease tags.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Improved
|
||||
- Improved Markdown preview stability by preserving relative scroll position during preview refreshes.
|
||||
- Improved Markdown preview behavior for very large files by using a safe plain-text fallback with explicit status messaging instead of full HTML conversion.
|
||||
|
||||
### Fixed
|
||||
- Fixed diagnostics export safety by redacting token-like updater status fragments before copying.
|
||||
- Fixed Markdown regression coverage with new tests for Claude-style mixed-content Markdown and code-fence matching behavior.
|
||||
|
||||
## [v0.5.0] - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 431;
|
||||
CURRENT_PROJECT_VERSION = 432;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
@ -444,7 +444,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 431;
|
||||
CURRENT_PROJECT_VERSION = 432;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
|
|||
|
|
@ -1150,6 +1150,31 @@ final class AppUpdateManager: ObservableObject {
|
|||
return "\(normalized)+\(buildValue)"
|
||||
}
|
||||
|
||||
nonisolated static func sanitizedDiagnosticSummary(_ text: String) -> String {
|
||||
var sanitized = text
|
||||
if let bearerRegex = try? NSRegularExpression(pattern: #"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+"#) {
|
||||
let range = NSRange(sanitized.startIndex..., in: sanitized)
|
||||
sanitized = bearerRegex.stringByReplacingMatches(
|
||||
in: sanitized,
|
||||
options: [],
|
||||
range: range,
|
||||
withTemplate: "Bearer [redacted]"
|
||||
)
|
||||
}
|
||||
if let keyValueRegex = try? NSRegularExpression(
|
||||
pattern: #"(?i)\b(authorization|token|api[_-]?key|password)\b\s*[:=]\s*([^\s,;]+)"#
|
||||
) {
|
||||
let range = NSRange(sanitized.startIndex..., in: sanitized)
|
||||
sanitized = keyValueRegex.stringByReplacingMatches(
|
||||
in: sanitized,
|
||||
options: [],
|
||||
range: range,
|
||||
withTemplate: "$1=[redacted]"
|
||||
)
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
nonisolated static func isVersionSkipped(_ version: String, skippedValue: String?) -> Bool {
|
||||
skippedValue == version
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3547,8 +3547,11 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var markdownPreviewRenderByteLimit: Int { 180_000 }
|
||||
private var markdownPreviewFallbackCharacterLimit: Int { 120_000 }
|
||||
|
||||
private func markdownPreviewHTML(from markdownText: String) -> String {
|
||||
let bodyHTML = renderedMarkdownBodyHTML(from: markdownText) ?? "<pre>\(escapedHTML(markdownText))</pre>"
|
||||
let bodyHTML = markdownPreviewBodyHTML(from: markdownText)
|
||||
return """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
|
@ -3568,6 +3571,27 @@ struct ContentView: View {
|
|||
"""
|
||||
}
|
||||
|
||||
private func markdownPreviewBodyHTML(from markdownText: String) -> String {
|
||||
let byteCount = markdownText.lengthOfBytes(using: .utf8)
|
||||
if byteCount > markdownPreviewRenderByteLimit {
|
||||
return largeMarkdownFallbackHTML(from: markdownText, byteCount: byteCount)
|
||||
}
|
||||
return renderedMarkdownBodyHTML(from: markdownText) ?? "<pre>\(escapedHTML(markdownText))</pre>"
|
||||
}
|
||||
|
||||
private func largeMarkdownFallbackHTML(from markdownText: String, byteCount: Int) -> String {
|
||||
let previewText = String(markdownText.prefix(markdownPreviewFallbackCharacterLimit))
|
||||
let truncated = previewText.count < markdownText.count
|
||||
let statusSuffix = truncated ? " (truncated preview)" : ""
|
||||
return """
|
||||
<section class="preview-warning">
|
||||
<p><strong>Large Markdown file</strong></p>
|
||||
<p class="preview-warning-meta">Rendering full Markdown is skipped for stability (\(byteCount) bytes)\(statusSuffix).</p>
|
||||
</section>
|
||||
<pre>\(escapedHTML(previewText))</pre>
|
||||
"""
|
||||
}
|
||||
|
||||
private func renderedMarkdownBodyHTML(from markdownText: String) -> String? {
|
||||
let html = simpleMarkdownToHTML(markdownText).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return html.isEmpty ? nil : html
|
||||
|
|
@ -3861,6 +3885,21 @@ struct ContentView: View {
|
|||
padding: \(basePadding);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.preview-warning {
|
||||
margin: 0.5em 0 0.8em;
|
||||
padding: 0.75em 0.9em;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
}
|
||||
.preview-warning p {
|
||||
margin: 0;
|
||||
}
|
||||
.preview-warning-meta {
|
||||
margin-top: 0.4em !important;
|
||||
font-size: 0.92em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.25;
|
||||
margin: 1.1em 0 0.55em;
|
||||
|
|
|
|||
|
|
@ -18,12 +18,25 @@ struct MarkdownPreviewWebView: NSViewRepresentable {
|
|||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
guard context.coordinator.lastHTML != html else { return }
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
context.coordinator.reloadPreservingScroll(webView: webView, html: html)
|
||||
context.coordinator.lastHTML = html
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var lastHTML: String = ""
|
||||
|
||||
func reloadPreservingScroll(webView: WKWebView, html: String) {
|
||||
let capture = "(() => { const max = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); return window.scrollY / max; })();"
|
||||
webView.evaluateJavaScript(capture) { value, _ in
|
||||
let ratio = value as? Double ?? 0
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
let clamped = min(1.0, max(0.0, ratio))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
|
||||
let restore = "(() => { const max = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); window.scrollTo(0, max * \(clamped)); })();"
|
||||
webView.evaluateJavaScript(restore, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
|
|
@ -43,12 +56,25 @@ struct MarkdownPreviewWebView: UIViewRepresentable {
|
|||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
guard context.coordinator.lastHTML != html else { return }
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
context.coordinator.reloadPreservingScroll(webView: webView, html: html)
|
||||
context.coordinator.lastHTML = html
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var lastHTML: String = ""
|
||||
|
||||
func reloadPreservingScroll(webView: WKWebView, html: String) {
|
||||
let capture = "(() => { const max = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); return window.scrollY / max; })();"
|
||||
webView.evaluateJavaScript(capture) { value, _ in
|
||||
let ratio = value as? Double ?? 0
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
let clamped = min(1.0, max(0.0, ratio))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
|
||||
let restore = "(() => { const max = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); window.scrollTo(0, max * \(clamped)); })();"
|
||||
webView.evaluateJavaScript(restore, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1752,7 +1752,7 @@ struct NeonSettingsView: View {
|
|||
var lines: [String] = []
|
||||
lines.append("Neon Vision Editor Diagnostics")
|
||||
lines.append("Timestamp: \(Date().formatted(date: .abbreviated, time: .shortened))")
|
||||
lines.append("Updater.lastCheckResult: \(appUpdateManager.lastCheckResultSummary)")
|
||||
lines.append("Updater.lastCheckResult: \(AppUpdateManager.sanitizedDiagnosticSummary(appUpdateManager.lastCheckResultSummary))")
|
||||
lines.append("Updater.lastCheckedAt: \(appUpdateManager.lastCheckedAt?.formatted(date: .abbreviated, time: .shortened) ?? "never")")
|
||||
if let pausedUntil = appUpdateManager.pausedUntil, pausedUntil > Date() {
|
||||
lines.append("Updater.pauseUntil: \(pausedUntil.formatted(date: .abbreviated, time: .shortened))")
|
||||
|
|
|
|||
|
|
@ -146,4 +146,14 @@ final class AppUpdateManagerTests: XCTestCase {
|
|||
XCTAssertEqual(AppUpdateManager.releaseTrackingIdentifier(version: "v1.2.3", build: "45"), "1.2.3+45")
|
||||
XCTAssertEqual(AppUpdateManager.releaseTrackingIdentifier(version: "1.2.3", build: nil), "1.2.3")
|
||||
}
|
||||
|
||||
func testSanitizedDiagnosticSummaryRedactsSensitiveValues() {
|
||||
let summary = "token=abc123 authorization:Bearer TOPSECRET api_key=my-key password=swordfish"
|
||||
let redacted = AppUpdateManager.sanitizedDiagnosticSummary(summary)
|
||||
XCTAssertFalse(redacted.localizedCaseInsensitiveContains("abc123"))
|
||||
XCTAssertFalse(redacted.localizedCaseInsensitiveContains("TOPSECRET"))
|
||||
XCTAssertFalse(redacted.localizedCaseInsensitiveContains("my-key"))
|
||||
XCTAssertFalse(redacted.localizedCaseInsensitiveContains("swordfish"))
|
||||
XCTAssertTrue(redacted.contains("token=[redacted]"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import XCTest
|
||||
import SwiftUI
|
||||
@testable import Neon_Vision_Editor
|
||||
|
||||
final class MarkdownSyntaxHighlightingTests: XCTestCase {
|
||||
private func markdownPatterns() -> [String: Color] {
|
||||
getSyntaxPatterns(
|
||||
for: "markdown",
|
||||
colors: SyntaxColors.fromVibrantLightTheme(colorScheme: .dark)
|
||||
)
|
||||
}
|
||||
|
||||
func testMarkdownPatternsMatchClaudeStyleDocumentSections() {
|
||||
let sample = """
|
||||
# Claude Export
|
||||
|
||||
Here is prose with an [inline link](https://example.com).
|
||||
|
||||
- First bullet
|
||||
- Second bullet with *emphasis*
|
||||
|
||||
```swift
|
||||
struct Demo { let id: Int }
|
||||
```
|
||||
|
||||
> This is a quoted block.
|
||||
"""
|
||||
|
||||
let patterns = markdownPatterns()
|
||||
let headingPattern = patterns.keys.first { $0.contains("#{1,6}") }
|
||||
let listPattern = patterns.keys.first { $0.contains("[-*+]") }
|
||||
let quotePattern = patterns.keys.first { $0.contains("^>\\s+") }
|
||||
|
||||
XCTAssertNotNil(headingPattern)
|
||||
XCTAssertNotNil(listPattern)
|
||||
XCTAssertNotNil(quotePattern)
|
||||
|
||||
for pattern in [headingPattern, listPattern, quotePattern].compactMap({ $0 }) {
|
||||
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.dotMatchesLineSeparators]) else {
|
||||
XCTFail("Failed to compile regex: \(pattern)")
|
||||
continue
|
||||
}
|
||||
let matches = regex.matches(in: sample, options: [], range: NSRange(sample.startIndex..., in: sample))
|
||||
XCTAssertFalse(matches.isEmpty, "Expected markdown regex to match sample sections: \(pattern)")
|
||||
}
|
||||
}
|
||||
|
||||
func testMarkdownCodeFenceRegexKeepsSeparateFences() {
|
||||
let sample = """
|
||||
Intro paragraph.
|
||||
|
||||
```swift
|
||||
let x = 1
|
||||
```
|
||||
|
||||
middle text with `inline` code
|
||||
|
||||
```json
|
||||
{"a": 1}
|
||||
```
|
||||
"""
|
||||
|
||||
let patterns = markdownPatterns()
|
||||
guard let fencePattern = patterns.keys.first(where: { $0.contains("```.*?```") }) else {
|
||||
XCTFail("Fence regex pattern missing")
|
||||
return
|
||||
}
|
||||
guard let regex = cachedSyntaxRegex(pattern: fencePattern, options: [.dotMatchesLineSeparators]) else {
|
||||
XCTFail("Fence regex failed to compile")
|
||||
return
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: sample, options: [], range: NSRange(sample.startIndex..., in: sample))
|
||||
XCTAssertEqual(matches.count, 3, "Expected 2 fenced blocks plus 1 inline code span")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue