diff --git a/PeekMark.xcodeproj/project.pbxproj b/PeekMark.xcodeproj/project.pbxproj index 2464feb..931e968 100644 --- a/PeekMark.xcodeproj/project.pbxproj +++ b/PeekMark.xcodeproj/project.pbxproj @@ -58,7 +58,7 @@ B99E3B7C4790DE780684B589 /* PreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewViewController.swift; sourceTree = ""; }; D1032F275C06D493280925DD /* MarkdownPreviewWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewWindow.swift; sourceTree = ""; }; D5930CEE95459D0E5F893402 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; - F11C727FD46CC04F24B2D70C /* QuickMarkCore */ = {isa = PBXFileReference; lastKnownFileType = folder; name = QuickMarkCore; path = QuickMarkCore; sourceTree = SOURCE_ROOT; }; + F11C727FD46CC04F24B2D70C /* QuickMarkCore */ = {isa = PBXFileReference; lastKnownFileType = folder; path = QuickMarkCore; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -201,14 +201,6 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1600; - TargetAttributes = { - 183D479E6DE0A841E5AFCFFB = { - DevelopmentTeam = ""; - }; - 8FE1094769F2A06EF7E1D49C = { - DevelopmentTeam = ""; - }; - }; }; buildConfigurationList = 04D27A61D5C48930CE548C62 /* Build configuration list for PBXProject "PeekMark" */; developmentRegion = en; @@ -365,6 +357,7 @@ CODE_SIGN_ENTITLEMENTS = QuickMarkQL/QuickMarkQL.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = M924X3MR87; INFOPLIST_FILE = QuickMarkQL/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -472,6 +465,7 @@ CODE_SIGN_ENTITLEMENTS = QuickMarkApp/QuickMarkApp.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = M924X3MR87; INFOPLIST_FILE = QuickMarkApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/QuickMarkApp/Info.plist b/QuickMarkApp/Info.plist index 687d35b..27dc695 100644 --- a/QuickMarkApp/Info.plist +++ b/QuickMarkApp/Info.plist @@ -2,6 +2,14 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL CFBundleName PeekMark CFBundleDisplayName diff --git a/QuickMarkApp/Sources/ContentView.swift b/QuickMarkApp/Sources/ContentView.swift index c562a89..7a2466d 100644 --- a/QuickMarkApp/Sources/ContentView.swift +++ b/QuickMarkApp/Sources/ContentView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ContentView: View { @@ -5,9 +6,11 @@ struct ContentView: View { var body: some View { VStack(spacing: 20) { - Image(systemName: "doc.text.magnifyingglass") - .font(.system(size: 56)) - .foregroundStyle(.secondary) + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 88, height: 88) + .accessibilityHidden(true) Text("PeekMark") .font(.title.bold()) Text("Quick Look preview is ready.\nSelect a Markdown file in Finder, then press Space to preview.") diff --git a/QuickMarkApp/Sources/MarkdownPreviewWindow.swift b/QuickMarkApp/Sources/MarkdownPreviewWindow.swift index ed089ca..5c4dd56 100644 --- a/QuickMarkApp/Sources/MarkdownPreviewWindow.swift +++ b/QuickMarkApp/Sources/MarkdownPreviewWindow.swift @@ -1,6 +1,7 @@ import AppKit import WebKit import QuickMarkCore +import UniformTypeIdentifiers /// A floating split-view window: /// Left → editable NSTextView with raw Markdown source @@ -213,6 +214,7 @@ final class SplitPreviewViewController: NSSplitViewController, NSToolbarDelegate func load(markdown: String, url: URL) { editorVC.setText(markdown) + previewVC.sourceURL = url previewVC.baseURL = url.deletingLastPathComponent() previewVC.render(markdown: markdown, baseURL: url.deletingLastPathComponent()) editorVC.setCurrentURL(url) @@ -221,6 +223,7 @@ final class SplitPreviewViewController: NSSplitViewController, NSToolbarDelegate func loadNew() { editorVC.setText("") editorVC.setCurrentURL(nil) + previewVC.sourceURL = nil previewVC.baseURL = nil previewVC.render(markdown: "") viewMode = .both @@ -247,6 +250,65 @@ final class SplitPreviewViewController: NSSplitViewController, NSToolbarDelegate flashTitle("✓ HTML Copied") } + func exportDocument(using exporter: any DocumentExporting) { + let panel = NSSavePanel() + panel.title = exporter.format.displayName + panel.nameFieldStringValue = "\(window?.title ?? "Untitled").\(exporter.format.filenameExtension)" + if let contentType = UTType(filenameExtension: exporter.format.filenameExtension) { + panel.allowedContentTypes = [contentType] + } + + let completion: (NSApplication.ModalResponse) -> Void = { [weak self] response in + guard response == .OK, let destinationURL = panel.url, let self else { return } + let sourceURL = editorVC.documentURL + let purpose: RenderPurpose + switch exporter.format.filenameExtension.lowercased() { + case "pdf": purpose = .pdfExport + case "docx": purpose = .docxExport + default: purpose = .htmlExport + } + let context = RenderContext( + sourceURL: sourceURL, + title: window?.title ?? "PeekMark", + purpose: purpose + ) + let provider = QuickMarkExtensionRegistry.customizationProviders.first + let markdown = editorVC.markdownText + + Task { @MainActor [weak self] in + guard let self else { return } + do { + let customization = try await provider?.customization(for: context) ?? .none + let html = MarkdownRenderer.render( + markdown: markdown, + title: context.title, + customization: customization + ) + try await exporter.export(ExportRequest( + html: html, + context: context, + destinationURL: destinationURL + )) + flashTitle("✓ Exported") + NSWorkspace.shared.activateFileViewerSelecting([destinationURL]) + } catch { + let alert = NSAlert(error: error) + alert.messageText = "Unable to Export Document" + if let hostWindow = window ?? NSApp.keyWindow { + alert.beginSheetModal(for: hostWindow) { _ in } + } else { + alert.runModal() + } + } + } + } + if let hostWindow = window ?? NSApp.keyWindow { + panel.beginSheetModal(for: hostWindow, completionHandler: completion) + } else { + panel.begin(completionHandler: completion) + } + } + @objc func findInSource(_ sender: Any?) { editorVC.showFindPanel() } @@ -388,6 +450,7 @@ final class SplitPreviewViewController: NSSplitViewController, NSToolbarDelegate final class EditorViewController: NSViewController { var onTextChange: ((String) -> Void)? var markdownText: String { textView.string } + var documentURL: URL? { currentURL } private var currentURL: URL? private var securityScopedURL: URL? @@ -513,7 +576,9 @@ extension EditorViewController: NSTextViewDelegate { final class PreviewWebViewController: NSViewController, WKNavigationDelegate { var baseURL: URL? + var sourceURL: URL? private var webView: WKWebView! + private var renderTask: Task? override func loadView() { let config = WKWebViewConfiguration() @@ -525,8 +590,37 @@ final class PreviewWebViewController: NSViewController, WKNavigationDelegate { func render(markdown: String, baseURL: URL? = nil) { let resolvedBase = baseURL ?? self.baseURL - let html = MarkdownRenderer.render(markdown: markdown, title: "Preview") - webView.loadHTMLString(html, baseURL: resolvedBase) + let context = RenderContext(sourceURL: sourceURL, title: "Preview", purpose: .appPreview) + let provider = QuickMarkExtensionRegistry.customizationProviders.first + + renderTask?.cancel() + renderTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .milliseconds(150)) + } catch { + return + } + guard !Task.isCancelled else { return } + + let customization: RenderCustomization + if let provider { + do { + customization = try await provider.customization(for: context) + } catch { + customization = .none + } + } else { + customization = .none + } + guard !Task.isCancelled, let self else { return } + + let html = MarkdownRenderer.render( + markdown: markdown, + title: context.title, + customization: customization + ) + webView.loadHTMLString(html, baseURL: resolvedBase) + } } func webView(_ webView: WKWebView, diff --git a/QuickMarkApp/Sources/QuickMarkApp.swift b/QuickMarkApp/Sources/QuickMarkApp.swift index 687b630..c0bfd36 100644 --- a/QuickMarkApp/Sources/QuickMarkApp.swift +++ b/QuickMarkApp/Sources/QuickMarkApp.swift @@ -2,6 +2,7 @@ import SwiftUI import Foundation import AppKit import Carbon +import QuickMarkCore @main struct QuickMarkApp: App { @@ -10,6 +11,7 @@ struct QuickMarkApp: App { init() { QuickMarkSettings.registerDefaults() + QuickMarkExtensionLoader.loadConfiguredModule() ScratchpadHotKeyManager.shared.start() } @@ -20,15 +22,14 @@ struct QuickMarkApp: App { } .windowStyle(.titleBar) .windowToolbarStyle(.unified) + .windowResizability(.contentSize) .defaultSize(width: 480, height: 300) .commands { QuickMarkFileCommands() - CommandGroup(replacing: .saveItem) { - Button("Save") { - activeSplitPreviewViewController()?.saveCurrentFile(nil) - } - .keyboardShortcut("s") - } +#if OFFICIAL_EXTENSIONS + OfficialOptionalCommands() +#endif + QuickMarkSaveCommands() CommandGroup(after: .pasteboard) { Button("Copy Rendered HTML") { activeSplitPreviewViewController()?.copyHTML(nil) @@ -56,6 +57,36 @@ struct QuickMarkApp: App { } } +struct QuickMarkSaveCommands: Commands { + @ObservedObject private var runtimeState = QuickMarkExtensionRegistry.runtimeState + + private var exporters: [any DocumentExporting] { + _ = runtimeState.revision + return QuickMarkExtensionRegistry.exporters + } + + var body: some Commands { + CommandGroup(replacing: .saveItem) { + Button("Save") { + activeSplitPreviewViewController()?.saveCurrentFile(nil) + } + .keyboardShortcut("s") + + if !exporters.isEmpty { + Divider() + Menu("Export") { + ForEach(exporters.indices, id: \.self) { index in + let exporter = exporters[index] + Button("Export as \(exporter.format.displayName)…") { + activeSplitPreviewViewController()?.exportDocument(using: exporter) + } + } + } + } + } + } +} + final class QuickMarkAppDelegate: NSObject, NSApplicationDelegate { func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { false diff --git a/QuickMarkApp/Sources/SettingsView.swift b/QuickMarkApp/Sources/SettingsView.swift index 422b7e3..1835617 100644 --- a/QuickMarkApp/Sources/SettingsView.swift +++ b/QuickMarkApp/Sources/SettingsView.swift @@ -26,14 +26,18 @@ private struct GeneralSettingsView: View { .foregroundStyle(.secondary) .font(.subheadline) } - Section("Pro Features") { - Text("Custom themes, workspace search, and PDF export are planned for PeekMark Pro.") +#if OFFICIAL_EXTENSIONS + OfficialOptionalFeaturesSettingsView() +#else + Section("Optional Features") { + Text("Custom themes, workspace search, and additional export formats are available in official editions.") .foregroundStyle(.secondary) .font(.subheadline) Button("Learn More…") { NSWorkspace.shared.open(URL(string: "https://peekmark.app")!) } } +#endif } .formStyle(.grouped) } @@ -92,9 +96,11 @@ private struct PreviewSettingsView: View { private struct AboutView: View { var body: some View { VStack(spacing: 12) { - Image(systemName: "doc.text.magnifyingglass") - .font(.system(size: 48)) - .foregroundStyle(.secondary) + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 72, height: 72) + .accessibilityHidden(true) Text("PeekMark") .font(.title2.bold()) Text("Version 0.1.0 (Community)") diff --git a/QuickMarkCore/Sources/QuickMarkCore/ExtensionContracts.swift b/QuickMarkCore/Sources/QuickMarkCore/ExtensionContracts.swift new file mode 100644 index 0000000..48e907c --- /dev/null +++ b/QuickMarkCore/Sources/QuickMarkCore/ExtensionContracts.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Describes where rendered Markdown will be consumed. +/// +/// This is intentionally edition-neutral so optional modules can extend the +/// renderer without the Community target depending on a private package. +public enum RenderPurpose: String, Sendable, Codable { + case appPreview + case quickLook + case htmlExport + case pdfExport + case docxExport +} + +public struct RenderContext: Sendable, Equatable { + public let sourceURL: URL? + public let title: String + public let purpose: RenderPurpose + + public init(sourceURL: URL?, title: String, purpose: RenderPurpose) { + self.sourceURL = sourceURL + self.title = title + self.purpose = purpose + } +} + +/// Trusted, local presentation changes applied while the HTML shell is built. +public struct RenderCustomization: Sendable, Equatable { + public let identifier: String? + public let additionalCSS: String + public let articleClassNames: [String] + + public init( + identifier: String? = nil, + additionalCSS: String = "", + articleClassNames: [String] = [] + ) { + self.identifier = identifier + self.additionalCSS = additionalCSS + self.articleClassNames = articleClassNames + } + + public static let none = RenderCustomization() +} + +public protocol RenderCustomizationProviding: Sendable { + var identifier: String { get } + var displayName: String { get } + func customization(for context: RenderContext) async throws -> RenderCustomization +} + +public struct ExportFormat: Sendable, Equatable { + public let identifier: String + public let displayName: String + public let filenameExtension: String + public let mimeType: String + + public init( + identifier: String, + displayName: String, + filenameExtension: String, + mimeType: String + ) { + self.identifier = identifier + self.displayName = displayName + self.filenameExtension = filenameExtension + self.mimeType = mimeType + } +} + +public struct ExportRequest: Sendable { + public let html: String + public let context: RenderContext + public let destinationURL: URL + + public init(html: String, context: RenderContext, destinationURL: URL) { + self.html = html + self.context = context + self.destinationURL = destinationURL + } +} + +public protocol DocumentExporting: Sendable { + var format: ExportFormat { get } + func export(_ request: ExportRequest) async throws +} diff --git a/QuickMarkCore/Sources/QuickMarkCore/ExtensionRuntime.swift b/QuickMarkCore/Sources/QuickMarkCore/ExtensionRuntime.swift new file mode 100644 index 0000000..3064149 --- /dev/null +++ b/QuickMarkCore/Sources/QuickMarkCore/ExtensionRuntime.swift @@ -0,0 +1,80 @@ +import Combine +import Foundation + +/// Observable, edition-agnostic change signal for optional runtime services. +/// +/// Host UI can observe this object without knowing which optional module +/// supplied the services or why they changed. +@MainActor +public final class QuickMarkExtensionRuntimeState: ObservableObject { + @Published public private(set) var revision: UInt64 = 0 + + fileprivate func notifyChange() { + revision &+= 1 + } +} + +/// Process-local services supplied by an optional module. +/// +/// Community builds leave this registry empty. The host never needs to import +/// or name a private package. +@MainActor +public enum QuickMarkExtensionRegistry { + public static let runtimeState = QuickMarkExtensionRuntimeState() + public private(set) static var customizationProviders: [any RenderCustomizationProviding] = [] + public private(set) static var exporters: [any DocumentExporting] = [] + + public static func install( + customizationProviders: [any RenderCustomizationProviding], + exporters: [any DocumentExporting] + ) { + self.customizationProviders = customizationProviders + self.exporters = exporters + runtimeState.notifyChange() + } + + public static func resetServices() { + customizationProviders = [] + exporters = [] + runtimeState.notifyChange() + } + + public static func reset() { + customizationProviders = [] + exporters = [] + runtimeState.notifyChange() + } +} + +@MainActor +@objc public protocol QuickMarkExtensionBootstrapping { + static func install() +} + +/// Loads an optional bootstrap class named only by the official build's +/// private configuration. The Community Info.plist does not contain this key. +@MainActor +public enum QuickMarkExtensionLoader { + public static let bootstrapInfoKey = "QuickMarkExtensionBootstrapClass" + public static let bootstrapResourceName = "QuickMarkExtensionBootstrap" + + public static func loadConfiguredModule(from bundle: Bundle = .main) { + let className = (bundle.object(forInfoDictionaryKey: bootstrapInfoKey) as? String) + ?? resourceConfiguration(in: bundle)?["BootstrapClass"] as? String + guard let className, + !className.isEmpty, + let bootstrap = NSClassFromString(className) as? QuickMarkExtensionBootstrapping.Type else { + return + } + bootstrap.install() + } + + private static func resourceConfiguration(in bundle: Bundle) -> [String: Any]? { + guard let url = bundle.url(forResource: bootstrapResourceName, withExtension: "plist"), + let data = try? Data(contentsOf: url), + let propertyList = try? PropertyListSerialization.propertyList(from: data, format: nil) else { + return nil + } + return propertyList as? [String: Any] + } +} diff --git a/QuickMarkCore/Sources/QuickMarkCore/HTMLTemplate.swift b/QuickMarkCore/Sources/QuickMarkCore/HTMLTemplate.swift index 9557b51..c4bf7a8 100644 --- a/QuickMarkCore/Sources/QuickMarkCore/HTMLTemplate.swift +++ b/QuickMarkCore/Sources/QuickMarkCore/HTMLTemplate.swift @@ -20,6 +20,8 @@ public enum HTMLTemplate { static let highlightLightCSS = "{{HIGHLIGHT_LIGHT_CSS}}" static let highlightDarkCSS = "{{HIGHLIGHT_DARK_CSS}}" static let scriptNonce = "{{SCRIPT_NONCE}}" + static let customCSS = "{{CUSTOM_CSS}}" + static let articleClasses = "{{ARTICLE_CLASSES}}" } // MARK: - Resource names @@ -40,11 +42,17 @@ public enum HTMLTemplate { /// - bodyHTML: HTML already produced from the Markdown AST. /// - title: Document ``. /// - Returns: A fully self-contained HTML string. - public static func build(bodyHTML: String, title: String) -> String { + public static func build( + bodyHTML: String, + title: String, + customization: RenderCustomization = .none + ) -> String { var html = templateString() let scriptNonce = UUID().uuidString.replacingOccurrences(of: "-", with: "") html = html.replacingOccurrences(of: Placeholder.title, with: htmlEscape(title)) html = html.replacingOccurrences(of: Placeholder.scriptNonce, with: scriptNonce) + html = html.replacingOccurrences(of: Placeholder.customCSS, with: safeInlineCSS(customization.additionalCSS)) + html = html.replacingOccurrences(of: Placeholder.articleClasses, with: safeArticleClasses(customization.articleClassNames)) html = html.replacingOccurrences(of: Placeholder.highlightLightCSS, with: loadResource(Resource.highlightLight)) html = html.replacingOccurrences(of: Placeholder.highlightDarkCSS, with: loadResource(Resource.highlightDark)) html = html.replacingOccurrences(of: Placeholder.highlightJS, with: loadResource(Resource.highlightJS)) @@ -71,15 +79,16 @@ public enum HTMLTemplate { <meta name="color-scheme" content="light dark"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src file: data:; media-src file: data:; font-src file: data:; style-src 'unsafe-inline'; script-src 'nonce-\(Placeholder.scriptNonce)'; connect-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none'; form-action 'none'"> <title>\(Placeholder.title) - + -
\(Placeholder.content)
+
\(Placeholder.content)
+ """ } @@ -106,4 +115,20 @@ public enum HTMLTemplate { .replacingOccurrences(of: "\"", with: """) .replacingOccurrences(of: "'", with: "'") } + + private static func safeInlineCSS(_ css: String) -> String { + css.replacingOccurrences(of: " String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + return classes.compactMap { name in + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.unicodeScalars.allSatisfy(allowed.contains) else { + return nil + } + return trimmed + }.joined(separator: " ") + } } diff --git a/QuickMarkCore/Sources/QuickMarkCore/MarkdownRenderer.swift b/QuickMarkCore/Sources/QuickMarkCore/MarkdownRenderer.swift index b74d463..54d2780 100644 --- a/QuickMarkCore/Sources/QuickMarkCore/MarkdownRenderer.swift +++ b/QuickMarkCore/Sources/QuickMarkCore/MarkdownRenderer.swift @@ -20,16 +20,24 @@ public struct MarkdownRenderer { /// - markdown: Markdown source text. /// - title: Value used for the document ``. Defaults to `"Preview"`. /// - Returns: A self-contained HTML string. - public static func render(markdown: String, title: String = "Preview") -> String { + public static func render( + markdown: String, + title: String = "Preview", + customization: RenderCustomization = .none + ) -> String { let intelligence = PreviewIntelligence(markdown: markdown) let document = Document(parsing: intelligence.markdownBody) let bodyHTML = intelligence.enhance(bodyHTML: HTMLFormatter.format(document)) - return HTMLTemplate.build(bodyHTML: bodyHTML, title: title) + return HTMLTemplate.build(bodyHTML: bodyHTML, title: title, customization: customization) } /// Instance variant of ``render(markdown:title:)`` for callers that prefer /// to hold a renderer value. - public func render(_ markdown: String, title: String = "Preview") -> String { - Self.render(markdown: markdown, title: title) + public func render( + _ markdown: String, + title: String = "Preview", + customization: RenderCustomization = .none + ) -> String { + Self.render(markdown: markdown, title: title, customization: customization) } } diff --git a/QuickMarkCore/Sources/QuickMarkCore/Resources/template.html b/QuickMarkCore/Sources/QuickMarkCore/Resources/template.html index e28cc48..7eee964 100644 --- a/QuickMarkCore/Sources/QuickMarkCore/Resources/template.html +++ b/QuickMarkCore/Sources/QuickMarkCore/Resources/template.html @@ -343,10 +343,13 @@ @media (prefers-color-scheme: dark) { {{HIGHLIGHT_DARK_CSS}} } + +/* ---------- Optional local customization ---------- */ +{{CUSTOM_CSS}} </style> </head> <body> -<article class="markdown-body"> +<article class="markdown-body {{ARTICLE_CLASSES}}"> {{CONTENT}} </article> <script nonce="{{SCRIPT_NONCE}}"> @@ -373,6 +376,8 @@ </script> <script nonce="{{SCRIPT_NONCE}}"> (function() { + window.__quickMarkRenderState = { status: 'pending', errors: [] }; + function prepareMermaidBlocks() { document.querySelectorAll('pre > code.language-mermaid, pre > code.language-mmd').forEach(function(code, index) { var pre = code.parentElement; @@ -385,32 +390,66 @@ }); } - function runMermaid() { + async function runMermaid() { prepareMermaidBlocks(); if (typeof mermaid === 'undefined' || typeof mermaid.initialize !== 'function') { return; } var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; try { mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' }); if (typeof mermaid.run === 'function') { - mermaid.run({ querySelector: '.peekmark-mermaid' }); + await mermaid.run({ querySelector: '.peekmark-mermaid' }); } else if (typeof mermaid.init === 'function') { - mermaid.init(undefined, document.querySelectorAll('.peekmark-mermaid')); + await Promise.resolve(mermaid.init(undefined, document.querySelectorAll('.peekmark-mermaid'))); } } catch (e) { document.documentElement.classList.add('peekmark-mermaid-error'); + window.__quickMarkRenderState.errors.push('mermaid'); + } + } + + async function waitForMath() { + if (window.MathJax && window.MathJax.startup && window.MathJax.startup.promise) { + try { + await window.MathJax.startup.promise; + } catch (e) { + window.__quickMarkRenderState.errors.push('math'); + } } } - function run() { + async function run() { if (typeof hljs !== 'undefined' && typeof hljs.highlightAll === 'function') { try { hljs.highlightAll(); } catch (e) { /* swallow */ } } - runMermaid(); + try { + await Promise.all([runMermaid(), waitForMath()]); + if (document.fonts && document.fonts.ready) { + await document.fonts.ready; + } + await new Promise(function(resolve) { + var settled = false; + function finish() { + if (settled) { return; } + settled = true; + resolve(); + } + setTimeout(finish, 80); + requestAnimationFrame(function() { requestAnimationFrame(finish); }); + }); + window.__quickMarkRenderState.status = 'ready'; + } catch (e) { + window.__quickMarkRenderState.status = 'failed'; + window.__quickMarkRenderState.errors.push('unexpected'); + } + document.documentElement.setAttribute('data-quickmark-render-status', window.__quickMarkRenderState.status); + window.dispatchEvent(new CustomEvent('quickmark-render-ready', { + detail: window.__quickMarkRenderState + })); } if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', run); + document.addEventListener('DOMContentLoaded', function() { void run(); }); } else { - run(); + void run(); } })(); </script> diff --git a/QuickMarkCore/Tests/QuickMarkCoreTests/ExtensionRuntimeTests.swift b/QuickMarkCore/Tests/QuickMarkCoreTests/ExtensionRuntimeTests.swift new file mode 100644 index 0000000..65d954f --- /dev/null +++ b/QuickMarkCore/Tests/QuickMarkCoreTests/ExtensionRuntimeTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import QuickMarkCore + +final class ExtensionRuntimeTests: XCTestCase { + @MainActor + func testRegistryPublishesServiceChanges() { + let initialRevision = QuickMarkExtensionRegistry.runtimeState.revision + + QuickMarkExtensionRegistry.resetServices() + XCTAssertEqual( + QuickMarkExtensionRegistry.runtimeState.revision, + initialRevision + 1 + ) + + QuickMarkExtensionRegistry.reset() + XCTAssertEqual( + QuickMarkExtensionRegistry.runtimeState.revision, + initialRevision + 2 + ) + } +} diff --git a/QuickMarkCore/Tests/QuickMarkCoreTests/PreviewIntelligenceTests.swift b/QuickMarkCore/Tests/QuickMarkCoreTests/PreviewIntelligenceTests.swift index 365229e..f0599cb 100644 --- a/QuickMarkCore/Tests/QuickMarkCoreTests/PreviewIntelligenceTests.swift +++ b/QuickMarkCore/Tests/QuickMarkCoreTests/PreviewIntelligenceTests.swift @@ -95,4 +95,47 @@ final class PreviewIntelligenceTests: XCTestCase { XCTAssertFalse(html.contains("{{MATHJAX_JS}}")) XCTAssertFalse(html.contains("<script src=")) } + + func testPublishesRenderReadinessAfterAsyncContentSettles() { + let html = MarkdownRenderer.render(markdown: """ + ```mermaid + flowchart LR + A --> B + ``` + + Math $x^2$. + """) + + XCTAssertTrue(html.contains("window.__quickMarkRenderState")) + XCTAssertTrue(html.contains("await Promise.all([runMermaid(), waitForMath()])")) + XCTAssertTrue(html.contains("quickmark-render-ready")) + XCTAssertTrue(html.contains("data-quickmark-render-status")) + } + + func testAppliesLocalRenderCustomizationWithoutLeavingTemplatePlaceholders() { + let html = MarkdownRenderer.render( + markdown: "# Custom", + customization: RenderCustomization( + identifier: "test.theme", + additionalCSS: ".markdown-body { --fgColor-accent: #ff0000; }", + articleClassNames: ["theme-test", "invalid class"] + ) + ) + + XCTAssertTrue(html.contains("--fgColor-accent: #ff0000")) + XCTAssertTrue(html.contains("class=\"markdown-body theme-test\"")) + XCTAssertFalse(html.contains("invalid class")) + XCTAssertFalse(html.contains("{{CUSTOM_CSS}}")) + XCTAssertFalse(html.contains("{{ARTICLE_CLASSES}}")) + } + + func testCustomCSSCannotCloseTheTemplateStyleElement() { + let html = MarkdownRenderer.render( + markdown: "Text", + customization: RenderCustomization(additionalCSS: "</STYLE><script>alert(1)</script>") + ) + + XCTAssertFalse(html.localizedCaseInsensitiveContains("</style><script>alert(1)</script>")) + XCTAssertTrue(html.contains("<\\/style>")) + } }