diff --git a/Info.plist b/Info.plist index 5130264..78d22c3 100644 --- a/Info.plist +++ b/Info.plist @@ -46,6 +46,39 @@ LSHandlerRank Owner + + CFBundleTypeExtensions + + json + + CFBundleTypeName + JSON Document + CFBundleTypeRole + Viewer + LSItemContentTypes + + public.json + + LSHandlerRank + Alternate + + + CFBundleTypeExtensions + + yaml + yml + + CFBundleTypeName + YAML Document + CFBundleTypeRole + Viewer + LSItemContentTypes + + public.yaml + + LSHandlerRank + Alternate + UTImportedTypeDeclarations diff --git a/Sources/MarkdownViewerLib/ContentView.swift b/Sources/MarkdownViewerLib/ContentView.swift index 39057d1..9edd8b5 100644 --- a/Sources/MarkdownViewerLib/ContentView.swift +++ b/Sources/MarkdownViewerLib/ContentView.swift @@ -237,6 +237,10 @@ public struct ContentView: View { return "\(total)/\(resolved)" } + private var markdownForRendering: String { + HTMLRenderer.wrapForRendering(currentText, fileExtension: fileURL?.pathExtension) + } + private var effectiveOverrideHTML: String? { if showDiff { return diffHTML } switch viewMode { @@ -264,7 +268,7 @@ public struct ContentView: View { Divider() } MarkdownWebView( - markdown: currentText, + markdown: markdownForRendering, fileURL: fileURL, overrideHTML: effectiveOverrideHTML, searchText: showSearch ? searchText : "", diff --git a/Sources/MarkdownViewerLib/HTMLRenderer.swift b/Sources/MarkdownViewerLib/HTMLRenderer.swift index 69d8915..84be3aa 100644 --- a/Sources/MarkdownViewerLib/HTMLRenderer.swift +++ b/Sources/MarkdownViewerLib/HTMLRenderer.swift @@ -69,6 +69,18 @@ public enum HTMLRenderer { .replacingOccurrences(of: "{{MARKDOWN_CONTENT}}", with: escaped) } + public static func wrapForRendering(_ text: String, fileExtension: String?) -> String { + guard let ext = fileExtension?.lowercased() else { return text } + switch ext { + case "json": + return "```json\n\(text)\n```" + case "yaml", "yml": + return "```yaml\n\(text)\n```" + default: + return text + } + } + static func escapeForJSTemplateLiteral(_ string: String) -> String { string .replacingOccurrences(of: "\\", with: "\\\\") diff --git a/Sources/MarkdownViewerLib/MarkdownDocument.swift b/Sources/MarkdownViewerLib/MarkdownDocument.swift index a8c44e8..ce3ed06 100644 --- a/Sources/MarkdownViewerLib/MarkdownDocument.swift +++ b/Sources/MarkdownViewerLib/MarkdownDocument.swift @@ -6,7 +6,7 @@ public extension UTType { } public struct MarkdownDocument: FileDocument { - public static var readableContentTypes: [UTType] = [.markdown, .plainText] + public static var readableContentTypes: [UTType] = [.markdown, .plainText, .json, .yaml] public var text: String diff --git a/Tests/MarkdownViewerTests/DataFileRenderingTests.swift b/Tests/MarkdownViewerTests/DataFileRenderingTests.swift new file mode 100644 index 0000000..ce3492c --- /dev/null +++ b/Tests/MarkdownViewerTests/DataFileRenderingTests.swift @@ -0,0 +1,455 @@ +import Testing +import UniformTypeIdentifiers +@testable import MarkdownViewerLib + +// MARK: - MarkdownDocument: File Type Registration + +@Suite("MarkdownDocument - Data File Content Types") +struct DataFileContentTypeTests { + + @Test func readableContentTypesIncludesJSON() { + #expect(MarkdownDocument.readableContentTypes.contains(.json)) + } + + @Test func readableContentTypesIncludesYAML() { + #expect(MarkdownDocument.readableContentTypes.contains(.yaml)) + } + + @Test func readableContentTypesStillIncludesMarkdown() { + #expect(MarkdownDocument.readableContentTypes.contains(.markdown)) + } + + @Test func readableContentTypesStillIncludesPlainText() { + #expect(MarkdownDocument.readableContentTypes.contains(.plainText)) + } + + @Test func initWithJSONContent() { + let json = """ + {"key": "value", "number": 42} + """ + let doc = MarkdownDocument(text: json) + #expect(doc.text == json) + } + + @Test func initWithYAMLContent() { + let yaml = """ + name: test + items: + - one + - two + """ + let doc = MarkdownDocument(text: yaml) + #expect(doc.text == yaml) + } + + @Test func initWithComplexJSON() { + let json = """ + { + "nested": { + "array": [1, 2, 3], + "null_value": null, + "boolean": true, + "string": "hello \\\"world\\\"" + } + } + """ + let doc = MarkdownDocument(text: json) + #expect(doc.text.contains("nested")) + #expect(doc.text.contains("null_value")) + } + + @Test func initWithComplexYAML() { + let yaml = """ + apiVersion: v1 + kind: ConfigMap + metadata: + name: my-config + labels: + app: test + data: + key1: value1 + key2: | + multi + line + value + """ + let doc = MarkdownDocument(text: yaml) + #expect(doc.text.contains("apiVersion")) + #expect(doc.text.contains("multi")) + } +} + +// MARK: - HTMLRenderer.wrapForRendering + +@Suite("HTMLRenderer - wrapForRendering: JSON") +struct WrapJSONTests { + + @Test func wrapsJSONInCodeFence() { + let json = """ + {"key": "value"} + """ + let result = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + #expect(result == "```json\n{\"key\": \"value\"}\n```") + } + + @Test func jsonExtensionCaseInsensitive() { + let text = "{}" + let upper = HTMLRenderer.wrapForRendering(text, fileExtension: "JSON") + let mixed = HTMLRenderer.wrapForRendering(text, fileExtension: "Json") + #expect(upper.hasPrefix("```json")) + #expect(mixed.hasPrefix("```json")) + } + + @Test func preservesJSONContentExactly() { + let json = """ + { + "string": "hello", + "number": 3.14, + "boolean": true, + "null": null, + "array": [1, "two", false], + "nested": {"a": {"b": "c"}} + } + """ + let result = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + #expect(result.contains(json)) + } + + @Test func emptyJSONFile() { + let result = HTMLRenderer.wrapForRendering("", fileExtension: "json") + #expect(result == "```json\n\n```") + } + + @Test func jsonWithSpecialCharacters() { + let json = """ + {"url": "https://example.com?a=1&b=2", "html": "
test
"} + """ + let result = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + #expect(result.contains("https://example.com?a=1&b=2")) + #expect(result.contains("
test
")) + } + + @Test func jsonWithUnicode() { + let json = """ + {"emoji": "🎉", "cjk": "日本語", "accents": "café"} + """ + let result = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + #expect(result.contains("🎉")) + #expect(result.contains("日本語")) + #expect(result.contains("café")) + } + + @Test func jsonWithBackticks() { + let json = """ + {"code": "`inline code`"} + """ + let result = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + #expect(result.hasPrefix("```json\n")) + #expect(result.hasSuffix("\n```")) + #expect(result.contains("`inline code`")) + } + + @Test func jsonWithNewlines() { + let json = "{\n \"a\": 1,\n \"b\": 2\n}" + let result = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + let lines = result.components(separatedBy: "\n") + #expect(lines.first == "```json") + #expect(lines.last == "```") + } +} + +@Suite("HTMLRenderer - wrapForRendering: YAML") +struct WrapYAMLTests { + + @Test func wrapsYAMLInCodeFence() { + let yaml = "name: test\nvalue: 42" + let result = HTMLRenderer.wrapForRendering(yaml, fileExtension: "yaml") + #expect(result == "```yaml\nname: test\nvalue: 42\n```") + } + + @Test func wrapsYMLInCodeFence() { + let yaml = "key: value" + let result = HTMLRenderer.wrapForRendering(yaml, fileExtension: "yml") + #expect(result == "```yaml\nkey: value\n```") + } + + @Test func yamlExtensionCaseInsensitive() { + let text = "key: value" + let upper = HTMLRenderer.wrapForRendering(text, fileExtension: "YAML") + let mixed = HTMLRenderer.wrapForRendering(text, fileExtension: "Yml") + #expect(upper.hasPrefix("```yaml")) + #expect(mixed.hasPrefix("```yaml")) + } + + @Test func ymlAndYamlProduceSameWrapper() { + let text = "key: value" + let fromYaml = HTMLRenderer.wrapForRendering(text, fileExtension: "yaml") + let fromYml = HTMLRenderer.wrapForRendering(text, fileExtension: "yml") + #expect(fromYaml == fromYml) + } + + @Test func preservesYAMLContentExactly() { + let yaml = """ + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx + labels: + app: nginx + spec: + replicas: 3 + selector: + matchLabels: + app: nginx + """ + let result = HTMLRenderer.wrapForRendering(yaml, fileExtension: "yaml") + #expect(result.contains(yaml)) + } + + @Test func emptyYAMLFile() { + let result = HTMLRenderer.wrapForRendering("", fileExtension: "yaml") + #expect(result == "```yaml\n\n```") + } + + @Test func yamlWithComments() { + let yaml = """ + # This is a comment + name: test # inline comment + """ + let result = HTMLRenderer.wrapForRendering(yaml, fileExtension: "yaml") + #expect(result.contains("# This is a comment")) + #expect(result.contains("# inline comment")) + } + + @Test func yamlWithMultilineStrings() { + let yaml = """ + description: | + This is a + multi-line string + folded: > + This will be + folded into one line + """ + let result = HTMLRenderer.wrapForRendering(yaml, fileExtension: "yaml") + #expect(result.contains("description: |")) + #expect(result.contains("folded: >")) + } + + @Test func yamlWithAnchorsAndAliases() { + let yaml = """ + defaults: &defaults + timeout: 30 + retries: 3 + production: + <<: *defaults + timeout: 60 + """ + let result = HTMLRenderer.wrapForRendering(yaml, fileExtension: "yaml") + #expect(result.contains("&defaults")) + #expect(result.contains("*defaults")) + } +} + +@Suite("HTMLRenderer - wrapForRendering: Passthrough") +struct WrapPassthroughTests { + + @Test func markdownFilesNotWrapped() { + let md = "# Hello\n\nSome text" + let result = HTMLRenderer.wrapForRendering(md, fileExtension: "md") + #expect(result == md) + } + + @Test func markdownExtensionNotWrapped() { + let md = "# Hello" + let result = HTMLRenderer.wrapForRendering(md, fileExtension: "markdown") + #expect(result == md) + } + + @Test func txtFilesNotWrapped() { + let text = "plain text content" + let result = HTMLRenderer.wrapForRendering(text, fileExtension: "txt") + #expect(result == text) + } + + @Test func nilExtensionNotWrapped() { + let text = "no extension" + let result = HTMLRenderer.wrapForRendering(text, fileExtension: nil) + #expect(result == text) + } + + @Test func emptyExtensionNotWrapped() { + let text = "empty ext" + let result = HTMLRenderer.wrapForRendering(text, fileExtension: "") + #expect(result == text) + } + + @Test func unknownExtensionNotWrapped() { + let text = "some content" + #expect(HTMLRenderer.wrapForRendering(text, fileExtension: "xml") == text) + #expect(HTMLRenderer.wrapForRendering(text, fileExtension: "csv") == text) + #expect(HTMLRenderer.wrapForRendering(text, fileExtension: "toml") == text) + #expect(HTMLRenderer.wrapForRendering(text, fileExtension: "ini") == text) + } +} + +@Suite("HTMLRenderer - wrapForRendering: Code Fence Structure") +struct WrapCodeFenceStructureTests { + + @Test func jsonFenceStartsWithCorrectMarker() { + let result = HTMLRenderer.wrapForRendering("{}", fileExtension: "json") + #expect(result.hasPrefix("```json\n")) + } + + @Test func jsonFenceEndsWithClosingMarker() { + let result = HTMLRenderer.wrapForRendering("{}", fileExtension: "json") + #expect(result.hasSuffix("\n```")) + } + + @Test func yamlFenceStartsWithCorrectMarker() { + let result = HTMLRenderer.wrapForRendering("key: val", fileExtension: "yaml") + #expect(result.hasPrefix("```yaml\n")) + } + + @Test func yamlFenceEndsWithClosingMarker() { + let result = HTMLRenderer.wrapForRendering("key: val", fileExtension: "yaml") + #expect(result.hasSuffix("\n```")) + } + + @Test func noDoubleNewlinesAroundContent() { + let result = HTMLRenderer.wrapForRendering("content", fileExtension: "json") + #expect(!result.contains("```json\n\ncontent")) + #expect(!result.contains("content\n\n```")) + } + + @Test func contentBetweenFenceMarkers() { + let content = "test content here" + let result = HTMLRenderer.wrapForRendering(content, fileExtension: "json") + let inner = result + .replacingOccurrences(of: "```json\n", with: "") + .replacingOccurrences(of: "\n```", with: "") + #expect(inner == content) + } +} + +// MARK: - Integration: wrapForRendering + escapeForJSTemplateLiteral + +@Suite("Data File Rendering - JS Escaping Integration") +struct DataFileEscapingTests { + + @Test func jsonWithBackticksEscapedForJS() { + let json = """ + {"code": "`value`"} + """ + let wrapped = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + let escaped = HTMLRenderer.escapeForJSTemplateLiteral(wrapped) + #expect(escaped.contains("\\`\\`\\`json")) + #expect(escaped.contains("\\`value\\`")) + } + + @Test func jsonWithDollarSignsEscapedForJS() { + let json = """ + {"price": "$99.99"} + """ + let wrapped = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + let escaped = HTMLRenderer.escapeForJSTemplateLiteral(wrapped) + #expect(escaped.contains("\\$99.99")) + } + + @Test func yamlWithBackslashesEscapedForJS() { + let yaml = "path: C:\\Users\\test" + let wrapped = HTMLRenderer.wrapForRendering(yaml, fileExtension: "yaml") + let escaped = HTMLRenderer.escapeForJSTemplateLiteral(wrapped) + #expect(escaped.contains("C:\\\\Users\\\\test")) + } + + @Test func jsonWithScriptTagEscapedForJS() { + let json = """ + {"html": ""} + """ + let wrapped = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + let escaped = HTMLRenderer.escapeForJSTemplateLiteral(wrapped) + #expect(escaped.contains("<\\/script>")) + } +} + +// MARK: - Integration: TOC with Data Files + +@Suite("TOC - Data File Content") +struct TOCDataFileTests { + + @Test func jsonContentProducesNoTOCEntries() { + let json = """ + { + "# not a heading": "value", + "## also not": "value" + } + """ + let entries = TOCEntry.parse(from: json) + #expect(entries.isEmpty) + } + + @Test func yamlContentProducesNoTOCEntries() { + let yaml = """ + config: + setting1: value1 + setting2: value2 + """ + let entries = TOCEntry.parse(from: yaml) + #expect(entries.isEmpty) + } + + @Test func yamlCommentLooksLikeHeadingButIsNot() { + // YAML comments start with # but TOC parser requires space after # + // and these are typically indented or part of values, not standalone headings + let yaml = """ + # This actually matches the heading regex + name: test + """ + let entries = TOCEntry.parse(from: yaml) + // This is expected: YAML comments with "# Text" format will match + // as headings — acceptable since it's a code file, not markdown + #expect(entries.count == 1) + } +} + +// MARK: - Integration: Full Render Pipeline + +@Suite("Data File Rendering - Full Pipeline") +struct DataFileFullPipelineTests { + + @Test func jsonRenderedAsHTML() { + let json = """ + {"key": "value"} + """ + let wrapped = HTMLRenderer.wrapForRendering(json, fileExtension: "json") + let html = HTMLRenderer.render(markdown: wrapped) + #expect(html.contains("