Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ let package = Package(
.executableTarget(
name: "Decant",
dependencies: ["DecantCore"],
resources: [.copy("Resources/AppIcon.icns")], // regenerate with Assets/make-icon.sh
resources: [
.copy("Resources/AppIcon.icns"), // regenerate with Assets/make-icon.sh
.copy("Resources/scalar.js"), // @scalar/api-reference standalone build (MIT), for the OpenAPI preview
],
// SwiftPM records the deployment target as the SDK version, which makes AppKit fall back to the
// pre-macOS 26 look. Record the real SDK so the system Liquid Glass design is used.
linkerSettings: [.unsafeFlags(["-Xlinker", "-platform_version", "-Xlinker", "macos", "-Xlinker", "14.0", "-Xlinker", "26.0"])]
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ swift test # engine tests

| Category | Tools |
| --- | --- |
| Format | Auto Detect (New), Formatter (JSON tree/query/repair; JS, TS, CSS, SCSS, Less, HTML, XML, Markdown, YAML, GraphQL), Markdown preview |
| Format | Auto Detect (New), Formatter (JSON tree/query/repair; JS, TS, CSS, SCSS, Less, HTML, XML, Markdown, YAML, GraphQL), Markdown preview, OpenAPI editor with a [Scalar](https://github.com/scalar/scalar) API reference preview |
| Encoding | Base64 (text and files, URL-safe, padding, MIME wrap, data: URIs, magic-byte detection), URL encode with query table |
| Time | Unix time (live clock, results table, date picker, time zones, differences), Cron (plain words, next runs) |
| Tokens & Security | JWT decoder with HMAC check, UUID v4/v7 + ULID generator and inspector, password generator |
| Data | CSV ↔ JSON with table preview |
| Data | CSV ↔ JSON with table preview, JSON ↔ YAML (YAML 1.2, anchors and merges) |
| Text | Diff (side by side / unified, word highlights, folding), Git Diff cleanup for AI review (drops lockfiles, generated and binary files; path filters), Regex tester, character/word/line counter |
| Miscellaneous | Color converter (color panel, eyedropper, contrast), QR code generator and reader |

Expand All @@ -45,7 +45,7 @@ Decant's main feature is what it leaves out. Every tool has to earn its place, a

- **Daily use.** A tool belongs here if developers reach for it every week, not once a year.
- **Offline and local.** Nothing leaves your machine. No features that need a server, an API key, or a login.
- **Zero dependencies.** Only Apple's frameworks. Optional external tools (Biome, Prettier) are used when you already have them, never bundled.
- **Zero dependencies.** Only Apple's frameworks. Optional external tools (Biome, Prettier) are used when you already have them, never bundled. The one exception is the OpenAPI preview, which ships Scalar's prebuilt script so it works offline.
- **Fast to open, fast to use.** Paste, get the answer, move on. No setup, no onboarding, no settings you have to learn first.
- **Deleting beats adding.** A smaller, sharper app is better than a bigger one. We remove things that don't pull their weight.

Expand All @@ -69,6 +69,7 @@ Ground rules for PRs:
- `Sources/DecantCore` — deterministic engine and tool logic, no UI. `Sources/Decant` — the app.
- History lives in `~/Library/Application Support/Decant/history.json`; retention and size are set in Settings → History. Pinned entries are kept.
- The Formatter uses Biome or Prettier when either is on your login-shell `PATH` (looked up via `zsh -lic`), Biome first for the languages it supports. Without them it falls back to the built-in JSON and JS/TS formatters and Foundation's XML/HTML pretty-printer; other languages are shown unformatted.
- OpenAPI preview: `Sources/Decant/Resources/scalar.js` is the standalone build of [`@scalar/api-reference`](https://www.npmjs.com/package/@scalar/api-reference) (MIT), currently 1.73.1. To update, replace it with `https://cdn.jsdelivr.net/npm/@scalar/api-reference@<version>/dist/browser/standalone.js`. Telemetry, the AI agent, MCP and web fonts are turned off in `ScalarView`; "Test Request" still sends requests to the API's servers when you click it.
- App icon: source `Assets/AppIcon.svg`; after editing run `Assets/make-icon.sh` (needs `rsvg-convert`) to rebuild `Sources/Decant/Resources/AppIcon.icns`.
- Releasing: push a `v1.2.0`-style tag; `.github/workflows/release.yaml` runs the tests, builds a universal `.app` with `scripts/build-app.sh`, signs and notarizes it, and attaches the zip to a GitHub release. Secrets: `DEVELOPER_ID_P12` (base64 `.p12`), `DEVELOPER_ID_P12_PASSWORD`, `NOTARY_KEY` (base64 App Store Connect `.p8`), `NOTARY_KEY_ID`, `NOTARY_ISSUER_ID`.
- Debug builds: `[DECANT_TOOL=base64] DECANT_INPUT=… [DECANT_AUX=…] DECANT_SNAPSHOT=out.png [DECANT_DARK=1] .build/debug/Decant` writes a PNG of the window and quits without touching history. UserDefaults can be set per run with launch arguments, e.g. `-formatterTree YES`.
Expand Down
10 changes: 7 additions & 3 deletions Sources/Decant/DecantApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ enum Category: String, CaseIterable {
}

enum Tool: String, CaseIterable, Identifiable, Codable, Hashable {
case detect, formatter, base64, url, unixTime, cron, jwt, uuid, password, csv, diff, gitDiff, regex, counter, markdown, color, qr
case detect, formatter, base64, url, unixTime, cron, jwt, uuid, password, csv, yaml, diff, gitDiff, regex, counter, markdown, openapi, color, qr

var id: String { rawValue }

Expand All @@ -131,11 +131,13 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable {
case .uuid: "UUID / ULID"
case .password: "Passwords"
case .csv: "CSV ↔ JSON"
case .yaml: "JSON ↔ YAML"
case .diff: "Diff"
case .gitDiff: "Git Diff"
case .regex: "Regex"
case .counter: "Counter"
case .markdown: "Markdown"
case .openapi: "OpenAPI"
case .color: "Color"
case .qr: "QR Code"
}
Expand All @@ -153,23 +155,25 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable {
case .uuid: "number"
case .password: "lock"
case .csv: "tablecells"
case .yaml: "list.bullet.indent"
case .diff: "plusminus"
case .gitDiff: "arrow.triangle.branch"
case .regex: "asterisk"
case .counter: "textformat.123"
case .markdown: "doc.richtext"
case .openapi: "server.rack"
case .color: "paintpalette"
case .qr: "qrcode"
}
}

var category: Category {
switch self {
case .detect, .formatter, .markdown: .format
case .detect, .formatter, .markdown, .openapi: .format
case .base64, .url: .encoding
case .unixTime, .cron: .time
case .jwt, .uuid, .password: .security
case .csv: .data
case .csv, .yaml: .data
case .diff, .gitDiff, .regex, .counter: .text
case .color, .qr: .misc
}
Expand Down
2,519 changes: 2,519 additions & 0 deletions Sources/Decant/Resources/scalar.js

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions Sources/Decant/TextToolPages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,122 @@ struct MarkdownWebView: NSViewRepresentable {
}
}

// MARK: - OpenAPI

struct OpenAPIPage: View {
@Environment(Store.self) private var store
@Environment(\.colorScheme) private var scheme
@AppStorage("openapiPreviewOnly") private var previewOnly = false
@State private var summary: OpenAPISummary?
@State private var error: JSONError?
@State private var jump: Jump?

var body: some View {
@Bindable var store = store
let input = store[doc: .openapi].input
let isJSON = input.first { !$0.isWhitespace } == "{"
ToolPage(tool: .openapi,
status: input.isEmpty ? nil : error != nil ? .error("Invalid \(isJSON ? "JSON" : "YAML")") : summary.map { Status(text: $0.version) } ?? .warning("Not an OpenAPI document"),
meta: summary.map { [$0.title, "\($0.paths) paths", "\($0.operations) operations"].compactMap { $0 } } ?? ["OpenAPI 3 or Swagger 2, YAML or JSON", "Preview by Scalar"]) {
OptionSegments(selection: $previewOnly, options: [("Editor & Preview", false), ("Preview", true)])
} content: {
HStack(spacing: 14) {
if !previewOnly {
EditorPane(title: "Spec", text: $store[doc: .openapi].input,
placeholder: "Paste or drop an OpenAPI 3 or Swagger 2 document, YAML or JSON.",
syntax: isJSON ? .json : .text, primary: true, fileExtension: isJSON ? "json" : "yaml", errorLine: error?.line, jump: jump,
footer: error.map { e in AnyView(ErrorBar(message: e.description, line: e.line) { jump = Jump(line: e.line, column: e.column) }) })
}
Pane("Preview") {
if input.isEmpty {
Text("The API reference appears here.").foregroundStyle(Palette.text4).frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScalarView(spec: input, dark: scheme == .dark)
}
}
}
}
.task(id: input) {
let text = input
(summary, error) = await Task.detached { () -> (OpenAPISummary?, JSONError?) in
do { return (OpenAPISummary(try isJSON ? JSONValue.parse(text) : YAML.parse(text)), nil) } catch { return (nil, error as? JSONError) }
}.value
}
}
}

/// Scalar's API reference, from the bundled standalone build so it works offline. Its hosted extras (AI agent, MCP,
/// telemetry, web fonts) are off; "Test Request" still calls the API's servers, as the user asks it to.
struct ScalarView: NSViewRepresentable {
let spec: String
let dark: Bool

/// Scalar doesn't start on the opaque origin of a nil base URL. `.invalid` never resolves, so nothing is fetched from it.
static let base = URL(string: "https://decant.invalid/")!
static let script = Bundle.module.url(forResource: "scalar", withExtension: "js").flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? ""

static let page = """
<!doctype html><html><head><meta charset="utf-8"></head><body><div id="app"></div><script>
let ref, timer
// Debounced so typing in the editor doesn't re-render on every key.
function show(content, dark) {
const config = { content, darkMode: dark, forceDarkModeState: dark ? 'dark' : 'light', hideDarkModeToggle: true,
withDefaultFonts: false, telemetry: false, agent: { disabled: true }, mcp: { disabled: true }, showDeveloperTools: 'never', documentDownloadType: 'none' }
clearTimeout(timer)
timer = setTimeout(() => ref ? ref.updateConfiguration(config) : (ref = Scalar.createApiReference('#app', config)), ref ? 300 : 0)
}
</script></body></html>
"""

func makeCoordinator() -> Coordinator { Coordinator() }

func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.userContentController.addUserScript(WKUserScript(source: Self.script, injectionTime: .atDocumentEnd, forMainFrameOnly: true))
let web = WKWebView(frame: .zero, configuration: config)
web.setValue(false, forKey: "drawsBackground")
web.navigationDelegate = context.coordinator
web.uiDelegate = context.coordinator
web.loadHTMLString(Self.page, baseURL: Self.base)
return web
}

func updateNSView(_ web: WKWebView, context: Context) {
context.coordinator.run("show(\(JSONValue.quote(spec)), \(dark))", in: web)
}

final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
private var loaded = false, pending: String?, last: String?

func run(_ js: String, in web: WKWebView) {
guard loaded else { pending = js; return }
guard js != last else { return }
last = js
web.evaluateJavaScript(js)
}

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
loaded = true
if let pending { run(pending, in: webView) }
}

func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) {
// In-page anchors stay in the view, real links go to the browser.
if action.navigationType == .linkActivated, let url = action.request.url, url.host != ScalarView.base.host {
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
} else {
decisionHandler(.allow)
}
}

func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = action.request.url { NSWorkspace.shared.open(url) }
return nil
}
}
}

// MARK: - Color

struct ColorPage: View {
Expand Down
69 changes: 69 additions & 0 deletions Sources/Decant/ToolPages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,75 @@ struct CSVPage: View {
}
}

// MARK: - JSON ↔ YAML

struct YAMLPage: View {
@Environment(Store.self) private var store
@AppStorage("indent") private var indent: Indent = .two
@State private var error: JSONError?
/// Which side was typed in last; the other one is the output.
@State private var editedYAML = false
@State private var jump: Jump?

private var doc: Doc { store[doc: .yaml] }

var body: some View {
ToolPage(tool: .yaml, status: error.map { _ in .error("Invalid \(editedYAML ? "YAML" : "JSON")") } ?? (doc.input.isEmpty && doc.aux.isEmpty ? nil : Status(text: "Converted")),
meta: ["YAML 1.2", "Anchors and merges resolved", "Comments dropped"]) {
OptionSegments(title: "JSON indent", selection: $indent, options: [("2", .two), ("4", .four), ("Tab", .tab)])
} content: {
HStack(spacing: 14) {
EditorPane(title: "JSON", text: Binding(get: { doc.input }, set: fromJSON),
placeholder: "Paste JSON to turn it into YAML.", syntax: .json, output: editedYAML, primary: editedYAML, fileExtension: "json",
errorLine: editedYAML ? nil : error?.line, jump: editedYAML ? nil : jump, footer: editedYAML ? nil : errorBar)
EditorPane(title: "YAML", text: Binding(get: { doc.aux }, set: fromYAML),
placeholder: "Or paste YAML to turn it into JSON.", output: !editedYAML, primary: !editedYAML, fileExtension: "yaml",
errorLine: editedYAML ? error?.line : nil, jump: editedYAML ? jump : nil, footer: editedYAML ? errorBar : nil)
}
}
.onChange(of: indent) { if editedYAML { fromYAML(doc.aux) } }
.onAppear { if doc.aux.isEmpty, !doc.input.isEmpty { fromJSON(doc.input) } }
}

private var errorBar: AnyView? {
error.map { e in AnyView(ErrorBar(message: e.description, line: e.line) { jump = Jump(line: e.line, column: e.column) }) }
}

private func fromJSON(_ json: String) {
editedYAML = false
guard !json.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
error = nil
store[doc: .yaml] = Doc()
return
}
do {
let v = try JSONValue.parseLoose(json).value
error = nil
store[doc: .yaml] = Doc(input: json, aux: YAML.write(v))
} catch {
self.error = error as? JSONError
store[doc: .yaml].input = json
}
}

private func fromYAML(_ yaml: String) {
editedYAML = true
guard !yaml.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
error = nil
store[doc: .yaml] = Doc()
return
}
do {
let v = try YAML.parse(yaml)
error = nil
store[doc: .yaml] = Doc(input: v.formatted(indent: indent.unit), aux: yaml)
} catch {
self.error = error as? JSONError
store[doc: .yaml].aux = yaml
}
}
}

struct TablePreview: View {
let rows: [[String]]
let header: Bool
Expand Down
2 changes: 2 additions & 0 deletions Sources/Decant/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -439,11 +439,13 @@ struct DetailView: View {
case .uuid: UUIDPage()
case .password: PasswordPage()
case .csv: CSVPage()
case .yaml: YAMLPage()
case .diff: DiffPage()
case .gitDiff: GitDiffPage()
case .regex: RegexPage()
case .counter: CounterPage()
case .markdown: MarkdownPage()
case .openapi: OpenAPIPage()
case .color: ColorPage()
case .qr: QRPage()
}
Expand Down
Loading