diff --git a/HACKING.md b/HACKING.md index 7ba58488116..a5856f68490 100644 --- a/HACKING.md +++ b/HACKING.md @@ -235,6 +235,54 @@ that can then be committed and pushed to fix the builds. See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details. +## macOS Help Book + +The macOS app bundles an Apple Help Book (`Ghostty.help`) that backs the +Help menu and is generated at build time. The content comes from the same +sources as the man pages and website docs: the config option and keybind +action doc comments (via `help_strings`) and the markdown in +`src/build/mdgen/`. + +The generator is `src/extra/help_book.zig`. All markup lives as plain +HTML/CSS/JS files in `src/extra/help_book/`: pages with dynamic content are +`{{placeholder}}` templates filled in by `template.zig`, and markdown is +rendered to HTML by `markdown.zig`. The build (`src/build/GhosttyResources.zig`) +runs the generator, indexes the pages with `hiutil`, and installs the bundle +to `zig-out/help/Ghostty.help`, which the Xcode project copies into the app +bundle. + +The book's `CFBundleVersion` is set to the app version (the `ghostty ++version` string), which is how Help Viewer's cache daemon (`helpd`) knows +to pick up new content after an update. + +When iterating on the help book locally, `helpd` will happily keep serving +stale state: the content cache is only refreshed when the book version +changes, and the Core Spotlight search donations are only updated +incrementally, so search results for removed pages stick around (and open +as "content not available"). After building, run the reset script to purge +Help Viewer state (via `hiutil -P`, which also quits the Help Viewer app +"Tips" and `helpd`) so everything is rebuilt from the fresh content: + +```shell-session +zig build -Demit-macos-app=false +macos/build.nu help-book-reset +``` + +Afterwards rebuild and relaunch the app; the book is re-registered with +`helpd` at app launch. For content-only iteration you can skip Help Viewer +entirely and open the generated pages in a browser: + +```shell-session +open zig-out/help/Ghostty.help/Contents/Resources/en.lproj/index.html +``` + +The generator has tests covering the bundle structure, the markdown +renderer, and the templates: + +```shell-session +zig build test -Dtest-filter="help book" -Dtest-filter=markdown -Dtest-filter=template +``` + ## Checking for Memory Leaks While Zig does an amazing job of finding and preventing memory leaks, diff --git a/macos/AGENTS.md b/macos/AGENTS.md index 1a0c84c3280..c52edb74d7a 100644 --- a/macos/AGENTS.md +++ b/macos/AGENTS.md @@ -32,3 +32,20 @@ application. (5) When done, quit via: `osascript -e 'tell application "" to quit'` + +## Help Book + +- The Apple Help Book (`Ghostty.help`) behind the Help menu is generated + by `zig build` into `zig-out/help/` and copied into the app bundle by + the Xcode build. Its sources are `src/extra/help_book.zig` and the + HTML/CSS/JS assets in `src/extra/help_book/`; see "macOS Help Book" in + [HACKING.md](../HACKING.md). +- After changing help book sources, rebuild (`zig build + -Demit-macos-app=false`) and run `macos/build.nu help-book-reset` to + purge Help Viewer state (helpd's content cache and its Core Spotlight + search donations; stale donations surface as "content not available" + search results), then rebuild the app and relaunch it to re-register + the book (which might take roughly 10s to finish). +- Stale sibling copies of Ghostty.app (`macos/build/`, Xcode DerivedData) + can hijack help book resolution since all copies register the same book + id; make sure the copy you launch is freshly built. diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 7ffe12c3944..783e4714e82 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -46,6 +46,10 @@ + CFBundleHelpBookFolder + Ghostty.help + CFBundleHelpBookName + com.mitchellh.ghostty.help GhosttyBuild GhosttyCommit diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6d883ded84c..1f6e136d0be 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; + 5F0A11AB2E1B4A5F00C4D9E1 /* Ghostty.help in Resources */ = {isa = PBXBuildFile; fileRef = 5F0A11AA2E1B4A5F00C4D9E1 /* Ghostty.help */; }; 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */ = {isa = PBXBuildFile; fileRef = 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */; }; @@ -73,6 +74,7 @@ 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; + 5F0A11AA2E1B4A5F00C4D9E1 /* Ghostty.help */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Ghostty.help; path = "../zig-out/help/Ghostty.help"; sourceTree = ""; }; 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = Ghostty.sdef; sourceTree = ""; }; @@ -325,6 +327,7 @@ 29C15B1C2CDC3B2000520DD4 /* bat */, A586167B2B7703CC009BDB1D /* fish */, 55154BDF2B33911F001622DC /* ghostty */, + 5F0A11AA2E1B4A5F00C4D9E1 /* Ghostty.help */, A546F1132D7B68D7003B11A0 /* locale */, A5985CE52C33060F00C57AD3 /* man */, 9351BE8E2D22937F003B3499 /* nvim */, @@ -577,6 +580,7 @@ A586167C2B7703CC009BDB1D /* fish in Resources */, 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, + 5F0A11AB2E1B4A5F00C4D9E1 /* Ghostty.help in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, 9351BE8E3D22937F003B3499 /* nvim in Resources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 5003e3d3245..ee4227d96f4 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -229,6 +229,12 @@ class AppDelegate: NSObject, // This registers the Ghostty => Services menu to exist. NSApp.servicesMenu = menuServices + // Register our Help Book. `helpd` only auto-registers apps installed in /Applications. + // Explicit registration makes Help work when running from the build directory or Xcode as well. + if !NSHelpManager.shared.registerBooks(in: Bundle.main) { + Ghostty.logger.warning("help book registration failed; Help falls back to online docs") + } + // Setup a local event monitor for app-level keyboard shortcuts. See // localEventHandler for more info why. _ = NSEvent.addLocalMonitorForEvents( diff --git a/macos/build.nu b/macos/build.nu index 8c456d9b61f..55862e77721 100755 --- a/macos/build.nu +++ b/macos/build.nu @@ -3,6 +3,30 @@ # Build the macOS Ghostty app using xcodebuild with a clean environment # to avoid Nix shell interference (NIX_LDFLAGS, NIX_CFLAGS_COMPILE, etc.). +# Reset macOS Help Viewer state for Ghostty's help book (dev helper). +# +# helpd keeps two kinds of state: a per-book content cache (refreshed +# when the book's CFBundleVersion changes) and Core Spotlight search +# donations, which are only updated incrementally — search results for +# pages that no longer exist stick around and open as "content not +# available". `hiutil -P` quits Help Viewer (Tips) and helpd and purges +# the persisted state including the donations; the explicit removal +# below covers Ghostty's content cache in case parts of the purge fail. +# Relaunch the app afterwards so it re-registers the book. +def "main help-book-reset" [] { + try { ^/usr/bin/hiutil -P } + + let cache_globs = [ + $"($env.HOME)/Library/Group Containers/group.com.apple.helpviewer.content/Library/Caches/com.mitchellh.ghostty*.help" + $"($env.HOME)/Library/Caches/com.apple.helpd/Generated/com.mitchellh.ghostty*" + ] + for pattern in $cache_globs { + glob $pattern | each {|path| rm -r -f $path } | ignore + } + + print "Help Viewer state purged. Relaunch the app to re-register the book. The registration might take roughly 10s to finish." +} + def main [ --scheme: string = "Ghostty" # Xcode scheme (Ghostty, Ghostty-iOS, DockTilePlugin) --configuration: string = "Debug" # Build configuration (Debug, Release, ReleaseLocal) diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 673c7e56a22..1d77d30dc86 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -27,6 +27,13 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty deps.help_strings.addImport(build_data_exe); + // The help book embeds the same app version. + { + const options = b.addOptions(); + try cfg.addOptions(options); + build_data_exe.root_module.addOptions("build_options", options); + } + // Terminfo terminfo: { const os_tag = cfg.target.result.os.tag; @@ -125,6 +132,42 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty try steps.append(b.allocator, &install_step.step); } + // Apple Help Book: generated bundle plus the hiutil search indices. + // Installed outside share/ghostty so the app bundle doesn't pick it up + // twice (the Xcode project references both directories). The host + // check is because hiutil is a macOS host tool. + if (cfg.target.result.os.tag == .macos and + b.graph.host.result.os.tag == .macos) + { + const run = b.addRunArtifact(build_data_exe); + run.addArg("+help-book"); + const book_dir = run.addOutputDirectoryArg("Ghostty.help"); + const lproj = book_dir.path(b, "Contents/Resources/en.lproj"); + + const lsm = RunStep.create(b, "hiutil lsm index"); + lsm.addArgs(&.{ "/usr/bin/hiutil", "-I", "lsm", "-Caf" }); + const lsm_out = lsm.addOutputFileArg("Ghostty.helpindex"); + lsm.addDirectoryArg(lproj); + + const cs = RunStep.create(b, "hiutil corespotlight index"); + cs.addArgs(&.{ "/usr/bin/hiutil", "-I", "corespotlight", "-Caf" }); + const cs_out = cs.addOutputFileArg("Ghostty.cshelpindex"); + cs.addDirectoryArg(lproj); + + // Assemble the final bundle (pages + indices) into one tree. + const wf = b.addWriteFiles(); + _ = wf.addCopyDirectory(book_dir, "Ghostty.help", .{}); + _ = wf.addCopyFile(lsm_out, "Ghostty.help/Contents/Resources/en.lproj/Ghostty.helpindex"); + _ = wf.addCopyFile(cs_out, "Ghostty.help/Contents/Resources/en.lproj/Ghostty.cshelpindex"); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "help", + }); + try steps.append(b.allocator, &install_step.step); + } + // Themes if (cfg.emit_themes) { if (b.lazyDependency("iterm2_themes", .{})) |upstream| { diff --git a/src/cli/help.zig b/src/cli/help.zig index 755aa127b87..ee67483a1be 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -17,7 +17,7 @@ pub const Options = struct { }; /// The `help` command shows general help about Ghostty. Recognized as either -/// `-h, `--help`, or like other actions `+help`. +/// `-h`, `--help`, or like other actions `+help`. /// /// You can also specify `--help` or `-h` along with any action such as /// `+list-themes` to see help for a specific action. diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig index 3b34109d1cd..9ea4498e196 100644 --- a/src/cli/ssh.zig +++ b/src/cli/ssh.zig @@ -162,17 +162,17 @@ pub const Options = struct { /// /// Examples: /// -/// # Basic invocation using defaults: -/// ghostty +ssh user@example.com +/// # Basic invocation using defaults: +/// ghostty +ssh user@example.com /// -/// # Forward Ghostty env vars but skip the terminfo install: -/// ghostty +ssh --terminfo=false user@example.com +/// # Forward Ghostty env vars but skip the terminfo install: +/// ghostty +ssh --terminfo=false user@example.com /// -/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged: -/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com +/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged: +/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com /// -/// # Use `--` explicitly if your ssh args might collide with our flags: -/// ghostty +ssh -- --some-rare-ssh-arg user@example.com +/// # Use `--` explicitly if your ssh args might collide with our flags: +/// ghostty +ssh -- --some-rare-ssh-arg user@example.com /// /// Pass `--verbose` to see what `+ssh` is doing. For cache inspection /// and management, see `ghostty +ssh-cache`. diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index b1b53657bff..0172fc8fb84 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -49,13 +49,14 @@ pub const Options = struct { /// `--prune=6h`, `--prune=1y`. /// /// Examples: -/// ghostty +ssh-cache # List all cached destinations -/// ghostty +ssh-cache user@example.com # Show that destination -/// ghostty +ssh-cache example.com # Show all users on that host -/// ghostty +ssh-cache --add=user@example.com # Manually add a destination -/// ghostty +ssh-cache --remove=user@example.com # Remove a destination -/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days -/// ghostty +ssh-cache --clear # Clear entire cache +/// +/// ghostty +ssh-cache # List all cached destinations +/// ghostty +ssh-cache user@example.com # Show that destination +/// ghostty +ssh-cache example.com # Show all users on that host +/// ghostty +ssh-cache --add=user@example.com # Manually add a destination +/// ghostty +ssh-cache --remove=user@example.com # Remove a destination +/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days +/// ghostty +ssh-cache --clear # Clear entire cache pub fn run(alloc_gpa: Allocator) !u8 { var arena = std.heap.ArenaAllocator.init(alloc_gpa); defer arena.deinit(); diff --git a/src/extra/help_book.zig b/src/extra/help_book.zig new file mode 100644 index 00000000000..2991fdc3078 --- /dev/null +++ b/src/extra/help_book.zig @@ -0,0 +1,382 @@ +//! Apple Help Book bundle for the macOS app. +//! +//! This generates a complete `Ghostty.help` bundle: the bundle Info.plist +//! and PkgInfo, a title/index page, and one HTML page per config option and +//! bindable keybind action (options documented by a preceding option, like +//! the `font-family` style variants, share that option's page). The pages +//! are styled after the Ghostty website documentation. The build then indexes the bundle with `hiutil` and copies +//! it into the macOS app bundle, where Help Viewer picks it up via the +//! CFBundleHelpBook* keys in the app Info.plist. +//! +//! All markup lives as plain HTML/CSS/JS files in the `help_book/` +//! directory. Pages with dynamic content are `{{placeholder}}` templates +//! filled in by `help_book/template.zig`; the dynamic content itself is +//! rendered from the in-repo markdown docs and the config/keybind doc +//! comments by `help_book/markdown.zig`. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const build_config = @import("../build_config.zig"); +const Config = @import("../config/Config.zig"); +const CliAction = @import("../cli.zig").ghostty.Action; +const KeybindAction = @import("../input.zig").Binding.Action; +const help_strings = @import("help_strings"); +const markdown = @import("help_book/markdown.zig"); +const template = @import("help_book/template.zig"); + +/// The help book identifier. The app Info.plist's CFBundleHelpBookName +/// must match this value. +pub const book_identifier = "com.mitchellh.ghostty.help"; + +const book_title = "Ghostty Help"; + +/// Bundle files copied into en.lproj verbatim. +const static_files = [_]struct { + name: []const u8, + data: []const u8, +}{ + .{ .name = "style.css", .data = @embedFile("help_book/style.css") }, + .{ .name = "content.js", .data = @embedFile("help_book/content.js") }, + .{ .name = "shell.js", .data = @embedFile("help_book/shell.js") }, +}; + +/// Where the home page version links, mirroring the About window: dev +/// builds link to the commit (the version's `+` build metadata) on +/// GitHub, stable X.Y.Z releases link to their release notes. +const version_url = url: { + const v = build_config.version_string; + if (std.mem.lastIndexOfScalar(u8, v, '+')) |idx| { + const commit = v[idx + 1 ..]; + // The all-zero commit is the no-git fallback version. + if (commit.len > 0 and !std.mem.eql(u8, commit, "0000000")) + break :url "https://github.com/ghostty-org/ghostty/commits/" ++ commit; + break :url "https://github.com/ghostty-org/ghostty"; + } + const slug = slug: { + var out: [v.len]u8 = undefined; + for (v, 0..) |c, i| out[i] = if (c == '.') '-' else c; + const final = out; + break :slug final; + }; + break :url "https://ghostty.org/docs/install/release-notes/" ++ slug; +}; + +const Kind = enum { + option, + action, + + fn docsUrl(self: Kind) []const u8 { + return switch (self) { + .option => "https://ghostty.org/docs/config/reference#", + .action => "https://ghostty.org/docs/config/keybind/reference#", + }; + } + + fn keyword(self: Kind) []const u8 { + return switch (self) { + .option => "config", + .action => "keybind", + }; + } +}; + +const Topic = struct { + kind: Kind, + /// The names sharing this topic; the first is the primary name that + /// names the page file. + names: []const []const u8, + doc: []const u8, +}; + +/// Every config option and keybind action with its doc comment, in +/// declaration order. This is the single source for both the sidebar +/// table of contents and the per-topic pages. +/// +/// A field without a doc comment of its own is documented by the field +/// preceding it (e.g. `font-family-bold` by `font-family`); such fields +/// join the preceding topic and share its page. +const topics = topics: { + @setEvalBranchQuota(1_000_000); + const options = topicList( + .option, + @typeInfo(Config).@"struct".fields, + help_strings.Config, + ); + const actions = topicList( + .action, + @typeInfo(KeybindAction).@"union".fields, + help_strings.KeybindAction, + ); + break :topics options ++ actions; +}; + +fn topicList( + comptime kind: Kind, + comptime fields: anytype, + comptime Help: type, +) []const Topic { + @setEvalBranchQuota(1_000_000); + var list: []const Topic = &.{}; + for (fields) |field| { + if (field.name[0] == '_') continue; + if (!@hasDecl(Help, field.name) and list.len > 0) { + // Undocumented: share the preceding topic and its page. + const prev = list[list.len - 1]; + list = list[0 .. list.len - 1] ++ [_]Topic{.{ + .kind = kind, + .names = prev.names ++ [_][]const u8{field.name}, + .doc = prev.doc, + }}; + continue; + } + list = list ++ [_]Topic{.{ + .kind = kind, + .names = &.{field.name}, + .doc = docOf(Help, field.name), + }}; + } + return list; +} + +/// Every CLI action with its display name (--help/--version/+action) and +/// doc comment, for the CLI documentation page. +const cli_actions = cli: { + @setEvalBranchQuota(100_000); + const fields = @typeInfo(CliAction).@"enum".fields; + var list: [fields.len]struct { + name: []const u8, + display: []const u8, + doc: []const u8, + } = undefined; + for (fields, 0..) |field, i| { + list[i] = .{ + .name = field.name, + .display = if (std.mem.eql(u8, field.name, "help")) + "--help" + else if (std.mem.eql(u8, field.name, "version")) + "--version" + else + "+" ++ field.name, + .doc = docOf(help_strings.Action, field.name), + }; + } + const final = list; + break :cli final; +}; + +fn docOf(comptime T: type, comptime name: [:0]const u8) []const u8 { + return if (@hasDecl(T, name)) @field(T, name) else ""; +} + +/// CLI entrypoint for `+help-book `: write the bundle into the +/// directory given as the first non-action argument. +pub fn writeCli(alloc: Allocator, io: std.Io, args: std.process.Args) !void { + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const argv = try args.toSlice(arena_state.allocator()); + const path = for (argv[1..]) |arg| { + if (arg.len > 0 and arg[0] != '+') break arg; + } else return error.MissingOutputDirectory; + + var dir = try std.Io.Dir.cwd().createDirPathOpen(io, path, .{}); + defer dir.close(io); + try write(alloc, io, dir); +} + +/// Write the complete help book bundle contents into the given directory, +/// which becomes the `Ghostty.help` bundle root. +pub fn write(alloc: Allocator, io: std.Io, dir: std.Io.Dir) !void { + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + try dir.createDirPath(io, "Contents/Resources/en.lproj"); + // We need this for macOS to recognize this bundle as a help book + // and indexing it properly. + try dir.writeFile(io, .{ .sub_path = "Contents/PkgInfo", .data = "BNDLhbwr" }); + // The book version mirrors the app version (`ghostty +version`) so + // Help Viewer's helpd cache is invalidated on every app update. The + // short version is the plain semver triple as Apple requires for + // CFBundleShortVersionString. + try writeTemplateFile(io, dir, "Contents/Info.plist", @embedFile("help_book/Info.plist"), .{ + .identifier = book_identifier, + .title = book_title, + .version = build_config.version_string, + .short_version = std.fmt.comptimePrint("{d}.{d}.{d}", .{ + build_config.version.major, + build_config.version.minor, + build_config.version.patch, + }), + }); + + var lproj = try dir.openDir(io, "Contents/Resources/en.lproj", .{}); + defer lproj.close(io); + + for (static_files) |file| { + try lproj.writeFile(io, .{ .sub_path = file.name, .data = file.data }); + } + + // The landing page, showing the app version linked as in the About + // window. The wordmark is the website's, downloaded from + // https://ghostty.org/_next/static/media/ghostty-wordmark.815bf882.svg + // with the lettering fill changed to currentColor. + try writeTemplateFile(io, lproj, "home.html", @embedFile("help_book/home.html"), .{ + .version = build_config.version_string, + .version_url = version_url, + .wordmark = @embedFile("help_book/wordmark.svg"), + }); + + // The persistent shell: sidebar plus a content iframe. All other pages + // load inside the iframe so the sidebar never reloads while navigating. + { + var options_toc: std.Io.Writer.Allocating = .init(arena); + var actions_toc: std.Io.Writer.Allocating = .init(arena); + for (topics) |topic| { + const toc = switch (topic.kind) { + .option => &options_toc, + .action => &actions_toc, + }; + // One entry per topic, under its primary name. The other + // names of a group are findable through search (they are + // keywords of the shared page) and called out on the page. + const name = topic.names[0]; + try toc.writer.print( + "
  • {s}
  • \n", + .{ @tagName(topic.kind), name, @tagName(topic.kind), name, name }, + ); + } + try writeTemplateFile(io, lproj, "index.html", @embedFile("help_book/index.html"), .{ + .options_toc = options_toc.written(), + .actions_toc = actions_toc.written(), + }); + } + + // Documentation pages rendered from the in-repo markdown docs + // (the same sources as the ghostty(1)/ghostty(5) man pages). + try writeTemplateFile(io, lproj, "docs.config.html", @embedFile("help_book/docs_config.html"), .{ + .header = try renderMarkdown(arena, @embedFile("../build/mdgen/ghostty_5_header.md")), + }); + { + var actions_html: std.Io.Writer.Allocating = .init(arena); + for (cli_actions) |action| { + try actions_html.writer.print( + "

    {s}

    \n", + .{ action.name, action.display }, + ); + try markdown.render(arena, &actions_html.writer, action.doc); + } + try writeTemplateFile(io, lproj, "docs.cli.html", @embedFile("help_book/docs_cli.html"), .{ + .header = try renderMarkdown(arena, @embedFile("../build/mdgen/ghostty_1_header.md")), + .actions = actions_html.written(), + }); + } + + // One page per config option and keybind action. + for (topics) |topic| { + _ = arena_state.reset(.retain_capacity); + try writeTopicFile(arena, io, lproj, topic); + } +} + +fn writeTopicFile(arena: Allocator, io: std.Io, lproj: std.Io.Dir, topic: Topic) !void { + var body: std.Io.Writer.Allocating = .init(arena); + try markdown.render(arena, &body.writer, topic.doc); + + var description: std.Io.Writer.Allocating = .init(arena); + try writeMetaDescription(&description.writer, topic.doc); + + const kind = @tagName(topic.kind); + const primary = topic.names[0]; + + // Every name of the group is a search keyword (that is how Apple + // Help routes alternate terms to a page); the non-primary names are + // also called out below the title. + var names: std.Io.Writer.Allocating = .init(arena); + var variants: std.Io.Writer.Allocating = .init(arena); + for (topic.names, 0..) |name, i| { + if (i > 0) try names.writer.writeAll(", "); + try names.writer.writeAll(name); + } + if (topic.names.len > 1) { + try variants.writer.writeAll("

    Also applies to "); + for (topic.names[1..], 0..) |name, i| { + if (i > 0) try variants.writer.writeAll(", "); + try variants.writer.print("{s}", .{name}); + } + try variants.writer.writeAll(".

    \n"); + } + + const file_name = try std.fmt.allocPrint(arena, "{s}.{s}.html", .{ kind, primary }); + try writeTemplateFile(io, lproj, file_name, @embedFile("help_book/topic.html"), .{ + .name = primary, + .names = names.written(), + .description = description.written(), + .keyword = topic.kind.keyword(), + .anchor = try std.fmt.allocPrint(arena, "{s}.{s}", .{ kind, primary }), + .variants = variants.written(), + .body = body.written(), + .docs_url = try std.fmt.allocPrint(arena, "{s}{s}", .{ topic.kind.docsUrl(), primary }), + }); +} + +/// Create the file and write the template with placeholders substituted. +fn writeTemplateFile( + io: std.Io, + dir: std.Io.Dir, + sub_path: []const u8, + comptime source: []const u8, + args: anytype, +) !void { + var f = try dir.createFile(io, sub_path, .{}); + defer f.close(io); + var buf: [4096]u8 = undefined; + var fw = f.writerStreaming(io, &buf); + try template.write(&fw.interface, source, args); + try fw.end(); +} + +/// Render markdown to an HTML string for use as a template value. +fn renderMarkdown(arena: Allocator, source: []const u8) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + try markdown.render(arena, &out.writer, source); + return out.written(); +} + +/// Write the first line of the doc text, truncated to at most 160 bytes at +/// a UTF-8 boundary, as an HTML-escaped search-result abstract. Markdown +/// backticks are stripped since abstracts are plain text. +fn writeMetaDescription(w: *std.Io.Writer, doc: []const u8) !void { + var line = doc[0 .. std.mem.indexOfScalar(u8, doc, '\n') orelse doc.len]; + if (line.len > 160) { + var end: usize = 160; + while (end > 0 and (line[end] & 0xC0) == 0x80) end -= 1; + line = line[0..end]; + } + for (line) |c| { + if (c == '`') continue; + try markdown.writeEscapedByte(w, c); + } +} + +test "help book" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try write(alloc, io, tmp.dir); + + // Undocumented options share the page of the option documenting them, + // with no page of their own: searchable there through the keywords and + // called out below the title. Topic pages link back to the online docs. + { + const page = try tmp.dir.readFileAlloc(io, "Contents/Resources/en.lproj/option.font-family.html", alloc, .limited(1 << 20)); + defer alloc.free(page); + try testing.expect(std.mem.indexOf(u8, page, "content=\"ghostty, config, font-family, font-family-bold, font-family-italic, font-family-bold-italic\"") != null); + try testing.expect(std.mem.indexOf(u8, page, "

    ") != null); + try testing.expect(std.mem.indexOf(u8, page, "https://ghostty.org/docs/config/reference#font-family") != null); + try testing.expectError(error.FileNotFound, tmp.dir.access(io, "Contents/Resources/en.lproj/option.font-family-bold.html", .{})); + } +} diff --git a/src/extra/help_book/Info.plist b/src/extra/help_book/Info.plist new file mode 100644 index 00000000000..e60db6f008b --- /dev/null +++ b/src/extra/help_book/Info.plist @@ -0,0 +1,19 @@ + + + + +CFBundleDevelopmentRegionen +CFBundleIdentifier{{identifier}} +CFBundleInfoDictionaryVersion6.0 +CFBundleName{{title}} +CFBundlePackageTypeBNDL +CFBundleShortVersionString{{short_version}} +CFBundleSignaturehbwr +CFBundleVersion{{version}} +HPDBookAccessPathindex.html +HPDBookCSIndexPathGhostty.cshelpindex +HPDBookIndexPathGhostty.helpindex +HPDBookTitle{{title}} +HPDBookType3 + + diff --git a/src/extra/help_book/content.js b/src/extra/help_book/content.js new file mode 100644 index 00000000000..75fa6dabace --- /dev/null +++ b/src/extra/help_book/content.js @@ -0,0 +1,14 @@ +// Included by every content page. When a page is opened standalone (a +// search result or anchor deep link), it redirects itself into the shell +// so the persistent sidebar appears. Inside the shell's iframe it reports +// its identity to the shell (for sidebar highlighting). +(function () { + var page = location.pathname.split("/").pop(); + if (window.top === window.self) { + location.replace("index.html#" + page + location.hash); + return; + } + try { + parent.postMessage({ page: page }, "*"); + } catch (e) {} +})(); diff --git a/src/extra/help_book/docs_cli.html b/src/extra/help_book/docs_cli.html new file mode 100644 index 00000000000..a8cd4d6f80b --- /dev/null +++ b/src/extra/help_book/docs_cli.html @@ -0,0 +1,17 @@ + + + + + +Command Line Interface + + + + + + +

    Command Line Interface

    +{{header}}

    COMMAND LINE ACTIONS

    +{{actions}} + diff --git a/src/extra/help_book/docs_config.html b/src/extra/help_book/docs_config.html new file mode 100644 index 00000000000..63cacbca5c0 --- /dev/null +++ b/src/extra/help_book/docs_config.html @@ -0,0 +1,19 @@ + + + + + +Configuration File + + + + + + +

    Configuration File

    +{{header}}

    CONFIGURATION OPTIONS

    +

    All configuration options are documented as individual pages in +this help book; browse them in the sidebar table of contents.

    + + diff --git a/src/extra/help_book/home.html b/src/extra/help_book/home.html new file mode 100644 index 00000000000..50ddc8983af --- /dev/null +++ b/src/extra/help_book/home.html @@ -0,0 +1,40 @@ + + + + + +Ghostty Help + + + + + + +
    + +

    {{wordmark}}

    +

    Help for macOS

    +
    +
    +

    Ghostty is a fast, feature-rich, and cross-platform terminal +emulator. This help book is a reference for all configuration +options and keybind actions. The full documentation, including +installation and usage guides, is available at +ghostty.org/docs.

    +

    Documentation

    + +

    Reference

    +

    Every configuration option and keybind action is documented as +its own page. Browse them in the sidebar table of contents, or use +the Help menu search.

    +
    +

    This help book was generated for Ghostty +{{version}}.

    +
    + + diff --git a/src/extra/help_book/index.html b/src/extra/help_book/index.html new file mode 100644 index 00000000000..87c3bce6c88 --- /dev/null +++ b/src/extra/help_book/index.html @@ -0,0 +1,37 @@ + + + + + +Ghostty Help + + + + + + + + + + diff --git a/src/extra/help_book/markdown.zig b/src/extra/help_book/markdown.zig new file mode 100644 index 00000000000..f1f358cb16f --- /dev/null +++ b/src/extra/help_book/markdown.zig @@ -0,0 +1,742 @@ +//! Minimal markdown-to-HTML renderer for the Apple Help Book. +//! +//! This converts the pandoc-flavored markdown used by the in-repo docs +//! (src/build/mdgen/*.md) and the config/keybind doc comments (via +//! help_strings) into plain HTML: headings with website-compatible +//! anchor ids, fenced and indented code blocks, lists, GFM tables, +//! definition lists, blockquotes with GitHub `[!NOTE]`/`[!WARNING]` +//! callouts, "Note:"/"Warning:" callout paragraphs, and inline +//! code/strong/emphasis/links/autolinks. It is NOT a general markdown +//! renderer. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub fn render(alloc: Allocator, w: *std.Io.Writer, source: []const u8) !void { + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var lines: std.ArrayList([]const u8) = .empty; + var iter = std.mem.splitScalar(u8, source, '\n'); + while (iter.next()) |line| { + // Skip the pandoc title line (% GHOSTTY(5) ...). + if (lines.items.len == 0 and std.mem.startsWith(u8, line, "% GHOSTTY(")) continue; + try lines.append(arena, line); + } + + var r: Renderer = .{ + .arena = arena, + .w = w, + .slug_counts = .init(arena), + }; + var i: usize = 0; + while (i < lines.items.len) i = try r.renderBlock(lines.items, i); +} + +const Renderer = struct { + arena: Allocator, + w: *std.Io.Writer, + slug_counts: std.StringHashMap(usize), + + fn renderBlock(self: *Renderer, lines: []const []const u8, start: usize) anyerror!usize { + const w = self.w; + const line = lines[start]; + const trimmed = std.mem.trim(u8, line, " "); + + // Blank line + if (trimmed.len == 0) return start + 1; + + // Fenced code block + if (std.mem.startsWith(u8, trimmed, "```")) { + try w.writeAll("
    ");
    +            var i = start + 1;
    +            while (i < lines.len) : (i += 1) {
    +                if (std.mem.startsWith(u8, std.mem.trim(u8, lines[i], " "), "```")) break;
    +                try writeEscaped(w, lines[i]);
    +                try w.writeByte('\n');
    +            }
    +            try w.writeAll("
    \n"); + return @min(i + 1, lines.len); + } + + // Indented code block (4 spaces). Blank lines continue the block + // as long as more indented content follows. + if (std.mem.startsWith(u8, line, " ")) { + try w.writeAll("
    ");
    +            var i = start;
    +            while (i < lines.len) : (i += 1) {
    +                if (std.mem.startsWith(u8, lines[i], "    ")) {
    +                    try writeEscaped(w, lines[i][4..]);
    +                    try w.writeByte('\n');
    +                    continue;
    +                }
    +                if (std.mem.trim(u8, lines[i], " ").len > 0) break;
    +
    +                // Blank: only continue when more indented content follows.
    +                var j = i + 1;
    +                while (j < lines.len and std.mem.trim(u8, lines[j], " ").len == 0) j += 1;
    +                if (j >= lines.len or !std.mem.startsWith(u8, lines[j], "    ")) break;
    +                try w.writeByte('\n');
    +            }
    +            try w.writeAll("
    \n"); + return i; + } + + // ATX heading. The page provides its own

    title, so markdown + // heading levels are shifted down by one. + if (line[0] == '#') { + var level: usize = 0; + while (level < line.len and line[level] == '#') level += 1; + if (level <= 5 and level < line.len and line[level] == ' ') { + const text = std.mem.trim(u8, line[level + 1 ..], " "); + const id = try self.headingId(text); + try w.print("", .{ level + 1, id }); + try self.writeInline(text); + try w.print("\n", .{level + 1}); + return start + 1; + } + } + + // Horizontal rule + if (std.mem.eql(u8, trimmed, "---") or std.mem.eql(u8, trimmed, "***")) { + try w.writeAll("
    \n"); + return start + 1; + } + + // Blockquote. A GitHub-style `[!NOTE]`/`[!WARNING]` marker on the + // first line turns the quote into the same styled callout as a + // "Note:"/"Warning:" paragraph. + if (trimmed[0] == '>') { + var text: std.ArrayList(u8) = .empty; + var class: ?[]const u8 = null; + var i = start; + while (i < lines.len) : (i += 1) { + var t = std.mem.trim(u8, lines[i], " "); + if (t.len == 0 or t[0] != '>') break; + t = std.mem.trimStart(u8, t[1..], " "); + if (i == start) { + if (std.mem.eql(u8, t, "[!NOTE]")) { + class = "note"; + continue; + } + if (std.mem.eql(u8, t, "[!WARNING]")) { + class = "warning"; + continue; + } + } + if (text.items.len > 0) try text.append(self.arena, '\n'); + try text.appendSlice(self.arena, t); + } + if (class) |c| { + try w.print("

    ", .{c}); + try self.writeInline(text.items); + try w.writeAll("

    \n"); + } else { + try w.writeAll("

    "); + try self.writeInline(text.items); + try w.writeAll("

    \n"); + } + return i; + } + + // GFM table + if (trimmed[0] == '|') return try self.renderTable(lines, start); + + // Definition (pandoc definition list): a `: ` line following a term + // paragraph. Rendered as an indented paragraph. + if (std.mem.startsWith(u8, trimmed, ": ") or std.mem.eql(u8, trimmed, ":")) { + var text: std.ArrayList(u8) = .empty; + var i = start; + while (i < lines.len) : (i += 1) { + var t = std.mem.trim(u8, lines[i], " "); + if (t.len == 0) break; + if (std.mem.startsWith(u8, t, ": ")) t = std.mem.trimStart(u8, t[2..], " "); + if (i > start) try text.append(self.arena, '\n'); + try text.appendSlice(self.arena, t); + } + try w.writeAll("

    "); + try self.writeInline(text.items); + try w.writeAll("

    \n"); + return i; + } + + // List + if (listItem(line) != null) return try self.renderList(lines, start); + + // Paragraph: consume until a blank line or structural element. + // Lines are joined before inline rendering so that emphasis and + // links can span line breaks. A paragraph starting with "Note:" + // or "Warning:" (the doc comment callout convention) becomes a + // styled callout with the prefix stripped. + const callout: ?[]const u8 = if (std.ascii.startsWithIgnoreCase(trimmed, "note:")) + "note" + else if (std.ascii.startsWithIgnoreCase(trimmed, "warning:")) + "warning" + else + null; + + var text: std.ArrayList(u8) = .empty; + var i = start; + while (i < lines.len) : (i += 1) { + var t = std.mem.trim(u8, lines[i], " "); + if (t.len == 0) break; + if (i > start) { + // An indented line continues the paragraph (a hanging + // indent); indented code cannot interrupt a paragraph. + if (t[0] == '#' or t[0] == '|' or t[0] == ':' or t[0] == '>' or + std.mem.startsWith(u8, t, "```") or + listItem(lines[i]) != null) break; + try text.append(self.arena, '\n'); + } else if (callout) |class| { + // The class name plus ':' is the matched prefix length. + t = std.mem.trimStart(u8, t[class.len + 1 ..], " "); + } + try text.appendSlice(self.arena, t); + } + if (callout) |class| { + try w.print("

    ", .{class}); + try self.writeInline(text.items); + try w.writeAll("

    \n"); + } else { + try w.writeAll("

    "); + try self.writeInline(text.items); + try w.writeAll("

    \n"); + } + return i; + } + + const ListItem = struct { + indent: usize, + ordered: bool, + text: []const u8, + }; + + /// Parse a list item marker: `- `, `* `, or `1. ` after any indent. + fn listItem(line: []const u8) ?ListItem { + const indent = line.len - std.mem.trimStart(u8, line, " ").len; + const rest = line[indent..]; + if (rest.len >= 2 and (rest[0] == '-' or rest[0] == '*') and rest[1] == ' ') + return .{ .indent = indent, .ordered = false, .text = rest[2..] }; + var d: usize = 0; + while (d < rest.len and std.ascii.isDigit(rest[d])) d += 1; + if (d > 0 and d + 1 < rest.len and rest[d] == '.' and rest[d + 1] == ' ') + return .{ .indent = indent, .ordered = true, .text = rest[d + 2 ..] }; + return null; + } + + fn renderList(self: *Renderer, lines: []const []const u8, start: usize) !usize { + const w = self.w; + + const Level = struct { indent: usize, ordered: bool }; + var stack: std.ArrayList(Level) = .empty; + defer stack.deinit(self.arena); + + // Item text and its continuation lines are accumulated and + // rendered in a single writeInline call when the item (or an + // inner paragraph) ends, so inline spans can wrap across lines. + var text: std.ArrayList(u8) = .empty; + var text_para = false; + var pending_blank = false; + + var i = start; + while (i < lines.len) : (i += 1) { + const line = lines[i]; + const trimmed = std.mem.trim(u8, line, " "); + + if (trimmed.len == 0) { + pending_blank = true; + continue; + } + + if (listItem(line)) |item| { + try self.flushListText(&text, text_para); + text_para = false; + pending_blank = false; + + // Close levels deeper than this item, and a same-level list + // of the other kind (ordered vs. unordered). + while (stack.items.len > 0 and + (stack.items[stack.items.len - 1].indent > item.indent or + (stack.items[stack.items.len - 1].indent == item.indent and + stack.items[stack.items.len - 1].ordered != item.ordered))) + { + const lvl = stack.pop().?; + try w.writeAll(if (lvl.ordered) "\n\n" else "\n\n"); + } + + if (stack.items.len == 0 or + item.indent > stack.items[stack.items.len - 1].indent) + { + // Open a new (possibly nested) level. + try stack.append(self.arena, .{ + .indent = item.indent, + .ordered = item.ordered, + }); + try w.writeAll(if (item.ordered) "
      \n
    1. " else "
        \n
      • "); + } else { + // Sibling item at the current level. + try w.writeAll("
      • \n
      • "); + } + try text.appendSlice(self.arena, item.text); + continue; + } + + // Continuation line: must be indented past the item marker. + const indent = line.len - std.mem.trimStart(u8, line, " ").len; + if (stack.items.len > 0 and + indent >= stack.items[stack.items.len - 1].indent + 2) + { + if (pending_blank) { + try self.flushListText(&text, text_para); + text_para = true; + pending_blank = false; + } else if (text.items.len > 0) { + try text.append(self.arena, '\n'); + } + try text.appendSlice(self.arena, trimmed); + continue; + } + + break; + } + + try self.flushListText(&text, text_para); + while (stack.items.len > 0) { + const lvl = stack.pop().?; + try w.writeAll(if (lvl.ordered) "
      • \n
    \n" else "\n\n"); + } + return i; + } + + /// Render the pending item or continuation text of renderList, as a + /// nested paragraph when it followed a blank line within its item. + fn flushListText(self: *Renderer, text: *std.ArrayList(u8), para: bool) !void { + if (text.items.len == 0) return; + if (para) { + try self.w.writeAll("\n

    "); + try self.writeInline(text.items); + try self.w.writeAll("

    "); + } else { + try self.writeInline(text.items); + } + text.clearRetainingCapacity(); + } + + fn renderTable(self: *Renderer, lines: []const []const u8, start: usize) !usize { + const w = self.w; + try w.writeAll("\n"); + var i = start; + var row: usize = 0; + while (i < lines.len) : (i += 1) { + const trimmed = std.mem.trim(u8, lines[i], " "); + if (trimmed.len == 0 or trimmed[0] != '|') break; + + // The separator row (|---|---|) is implied. + if (row == 1) { + row += 1; + continue; + } + + const tag = if (row == 0) "th" else "td"; + try w.writeAll(""); + var cells = std.mem.splitScalar(u8, std.mem.trim(u8, trimmed, "|"), '|'); + while (cells.next()) |cell| { + try w.print("<{s}>", .{tag}); + try self.writeInline(std.mem.trim(u8, cell, " ")); + try w.print("", .{tag}); + } + try w.writeAll("\n"); + row += 1; + } + try w.writeAll("
    \n"); + return i; + } + + /// Generate a heading anchor id compatible with the website's + /// remark-heading-ids plugin: lowercased, slugified, de-duplicated + /// with a numeric suffix starting at 2. + fn headingId(self: *Renderer, text: []const u8) ![]const u8 { + var slug: std.ArrayList(u8) = .empty; + var last_dash = true; // avoid a leading dash + for (text) |c| { + const lower = std.ascii.toLower(c); + if (std.ascii.isAlphanumeric(lower) or lower == '_' or lower == '.') { + try slug.append(self.arena, lower); + last_dash = false; + } else if ((c == ' ' or c == '-') and !last_dash) { + try slug.append(self.arena, '-'); + last_dash = true; + } + } + while (slug.items.len > 0 and slug.items[slug.items.len - 1] == '-') + _ = slug.pop(); + + const base = try slug.toOwnedSlice(self.arena); + const gop = try self.slug_counts.getOrPut(base); + if (!gop.found_existing) { + gop.value_ptr.* = 1; + return base; + } + gop.value_ptr.* += 1; + return try std.fmt.allocPrint(self.arena, "{s}-{d}", .{ base, gop.value_ptr.* }); + } + + /// Render inline markdown: `code`, **strong**, _emphasis_/*emphasis*, + /// [text](href), autolinks, and backslash escapes. + fn writeInline(self: *Renderer, s: []const u8) anyerror!void { + const w = self.w; + var i: usize = 0; + while (i < s.len) { + const c = s[i]; + + if (c == '\\' and i + 1 < s.len) { + try writeEscapedByte(w, s[i + 1]); + i += 2; + continue; + } + + // Code span: opens with a run of N backticks and closes with a + // matching run (CommonMark), so literal backticks can appear + // inside, e.g. `` Cmd+` ``. + if (c == '`') code: { + var open: usize = 1; + while (i + open < s.len and s[i + open] == '`') open += 1; + var search = i + open; + const end = while (std.mem.indexOfScalarPos(u8, s, search, '`')) |cand| { + var run: usize = 1; + while (cand + run < s.len and s[cand + run] == '`') run += 1; + if (run == open) break cand; + search = cand + run; + } else break :code; + var content = s[i + open .. end]; + // One space of padding is stripped so the content can + // start or end with a backtick (CommonMark). + if (content.len >= 2 and content[0] == ' ' and + content[content.len - 1] == ' ' and + std.mem.trim(u8, content, " ").len > 0) + content = content[1 .. content.len - 1]; + try w.writeAll(""); + try writeEscaped(w, content); + try w.writeAll(""); + i = end + open; + continue; + } + + if (c == '*' and i + 1 < s.len and s[i + 1] == '*') { + if (std.mem.indexOfPos(u8, s, i + 2, "**")) |end| { + try w.writeAll(""); + try self.writeInline(s[i + 2 .. end]); + try w.writeAll(""); + i = end + 2; + continue; + } + } + + if ((c == '*' or c == '_') and i + 1 < s.len and s[i + 1] != ' ' and + // An underscore inside a word is literal, not emphasis. + !(c == '_' and i > 0 and std.ascii.isAlphanumeric(s[i - 1]))) + emph: { + var search = i + 1; + const end = while (std.mem.indexOfScalarPos(u8, s, search, c)) |cand| { + // Empty emphasis is not emphasis (e.g. a stray "**"). + if (cand == i + 1) { + search = cand + 1; + continue; + } + if (s[cand - 1] == ' ') break :emph; + // Skip intraword underscores when looking for the + // closing marker. + if (c == '_' and cand + 1 < s.len and + std.ascii.isAlphanumeric(s[cand + 1])) + { + search = cand + 1; + continue; + } + break cand; + } else break :emph; + try w.writeAll(""); + try self.writeInline(s[i + 1 .. end]); + try w.writeAll(""); + i = end + 1; + continue; + } + + if (c == '[') link: { + const text_end = std.mem.indexOfScalarPos(u8, s, i + 1, ']') orelse break :link; + if (text_end + 1 >= s.len or s[text_end + 1] != '(') break :link; + const href_end = std.mem.indexOfScalarPos(u8, s, text_end + 2, ')') orelse break :link; + try w.writeAll(""); + try self.writeInline(s[i + 1 .. text_end]); + try w.writeAll(""); + i = href_end + 1; + continue; + } + + // Autolink: + if (c == '<' and (std.mem.startsWith(u8, s[i..], "')) |end| { + const url = s[i + 1 .. end]; + try w.writeAll(""); + try writeEscaped(w, url); + try w.writeAll(""); + i = end + 1; + continue; + } + } + + try writeEscapedByte(w, c); + i += 1; + } + } + + /// Rewrite an internal website link to its help book equivalent: + /// generated reference anchors map to the option./action. pages and + /// other absolute website paths become ghostty.org URLs. + fn rewriteHref(self: *Renderer, href: []const u8) !void { + const w = self.w; + + if (std.mem.startsWith(u8, href, "/docs/config/reference#")) { + try w.print("option.{s}.html", .{href["/docs/config/reference#".len..]}); + return; + } + if (std.mem.startsWith(u8, href, "/docs/config/keybind/reference#")) { + try w.print("action.{s}.html", .{href["/docs/config/keybind/reference#".len..]}); + return; + } + if (href.len > 0 and href[0] == '/') { + try w.writeAll("https://ghostty.org"); + try writeEscaped(w, href); + return; + } + try writeEscaped(w, href); + } +}; + +/// Write the string with HTML special characters escaped, including double +/// quotes so this is safe in attribute values too. +pub fn writeEscaped(w: *std.Io.Writer, s: []const u8) !void { + for (s) |c| try writeEscapedByte(w, c); +} + +/// Write a single byte HTML-escaped. Control characters that are invalid +/// in HTML (all but tab, LF, CR) are skipped. +pub fn writeEscapedByte(w: *std.Io.Writer, c: u8) !void { + switch (c) { + '&' => try w.writeAll("&"), + '<' => try w.writeAll("<"), + '>' => try w.writeAll(">"), + '"' => try w.writeAll("""), + '\t', '\n', '\r' => try w.writeByte(c), + 0...8, 11, 12, 14...31 => {}, + else => try w.writeByte(c), + } +} + +test "markdown blocks" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try render(testing.allocator, &stream.writer, + \\% GHOSTTY(5) Version @@VERSION@@ | Title line + \\ + \\# NAME + \\ + \\A paragraph with `code` and a [link](/docs/config/reference#font-size). + \\ + \\ indented = code + \\ + \\ more + \\ + \\| A | B | + \\|---|---| + \\| 1 | 2 | + ); + + const out = stream.written(); + try testing.expect(std.mem.indexOf(u8, out, "GHOSTTY(5)") == null); + try testing.expect(std.mem.indexOf(u8, out, "

    NAME

    ") != null); + try testing.expect(std.mem.indexOf(u8, out, "link") != null); + try testing.expect(std.mem.indexOf(u8, out, "
    indented = code <block>\n\nmore\n
    ") != null); + try testing.expect(std.mem.indexOf(u8, out, "AB") != null); + try testing.expect(std.mem.indexOf(u8, out, "12") != null); +} + +test "markdown definitions and inlines" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try render(testing.allocator, &stream.writer, + \\_\$XDG_CONFIG_HOME/ghostty/config_ + \\ + \\: **On macOS**, see for details + \\that wrap onto more lines. + ); + + var stream2: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream2.deinit(); + try render(testing.allocator, &stream2.writer, + \\**A strong span that + \\wraps across lines.** Trailing text. + ); + try testing.expect(std.mem.indexOf( + u8, + stream2.written(), + "

    A strong span that\nwraps across lines. Trailing text.

    ", + ) != null); + + const out = stream.written(); + try testing.expect(std.mem.indexOf(u8, out, "

    $XDG_CONFIG_HOME/ghostty/config

    ") != null); + try testing.expect(std.mem.indexOf(u8, out, "

    On macOS, see https://example.com for details\nthat wrap onto more lines.

    ") != null); +} + +test "markdown doc comment with callout" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try render(testing.allocator, &stream.writer, + \\Some `text` that is **bold**. + \\ + \\ code = here + \\ + \\Warning: careful. + \\ + \\ - `a` + \\ + \\ One thing. + \\ + \\ - b two + \\ + \\Done. + ); + try testing.expectEqualStrings( + "

    Some text that is bold.

    \n" ++ + "
    code = here\n
    \n" ++ + "

    careful.

    \n" ++ + "
      \n
    • a\n

      One thing.

    • \n
    • b two
    • \n
    \n" ++ + "

    Done.

    \n", + stream.written(), + ); +} + +test "markdown writeEscaped" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try writeEscaped(&stream.writer, "a & \"q\"\x07"); + try testing.expectEqualStrings("a & <b> "q"", stream.written()); +} + +test "markdown blockquote callouts" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try render(testing.allocator, &stream.writer, + \\> [!NOTE] + \\> The exact behavior of each option may differ across + \\> compositors -- experiment with them! + \\ + \\> A plain quote. + ); + + const out = stream.written(); + try testing.expect(std.mem.indexOf( + u8, + out, + "

    The exact behavior of each option may differ across\ncompositors -- experiment with them!

    \n", + ) != null); + try testing.expect(std.mem.indexOf(u8, out, "

    A plain quote.

    \n") != null); + try testing.expect(std.mem.indexOf(u8, out, "[!NOTE]") == null); +} + +test "markdown code spans with literal backticks" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try render(testing.allocator, &stream.writer, + \\Toggle by pressing `` Cmd+` `` anywhere. + \\ + \\Default: ``\t '"│`|:;,()[]{}<>$`` + ); + + const out = stream.written(); + try testing.expect(std.mem.indexOf(u8, out, "

    Toggle by pressing Cmd+` anywhere.

    ") != null); + try testing.expect(std.mem.indexOf(u8, out, "

    Default: \\t '"│`|:;,()[]{}<>$

    ") != null); +} + +test "markdown code span across list continuation lines" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try render(testing.allocator, &stream.writer, + \\ * `--cache=`: to reinstall a single + \\ host, prefer `ghostty +ssh-cache + \\ --remove=` followed by a connection. + ); + + try testing.expect(std.mem.indexOf( + u8, + stream.written(), + "
  • --cache=<bool>: to reinstall a single\n" ++ + "host, prefer ghostty +ssh-cache\n--remove=<host> followed by a connection.
  • ", + ) != null); +} + +test "markdown hanging indent continues a paragraph" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try render(testing.allocator, &stream.writer, + \\macOS: To hide the titlebar without removing the native window borders + \\ or rounded corners, use `macos-titlebar-style = hidden` instead. + ); + + const out = stream.written(); + try testing.expect(std.mem.indexOf(u8, out, "
    ") == null);
    +    try testing.expect(std.mem.indexOf(
    +        u8,
    +        out,
    +        "borders\nor rounded corners, use macos-titlebar-style = hidden instead.",
    +    ) != null);
    +}
    +
    +test "markdown lists" {
    +    const testing = std.testing;
    +
    +    var stream: std.Io.Writer.Allocating = .init(testing.allocator);
    +    defer stream.deinit();
    +
    +    try render(testing.allocator, &stream.writer,
    +        \\- one
    +        \\  - nested
    +        \\- two
    +        \\
    +        \\1. first
    +        \\2. second
    +    );
    +
    +    const out = stream.written();
    +    try testing.expect(std.mem.indexOf(u8, out, "
      \n
    • one
        \n
      • nested
      • \n
      \n
    • \n
    • two
    • \n
    ") != null); + try testing.expect(std.mem.indexOf(u8, out, "
      \n
    1. first
    2. \n
    3. second
    4. \n
    ") != null); +} diff --git a/src/extra/help_book/shell.js b/src/extra/help_book/shell.js new file mode 100644 index 00000000000..a5c46f363ab --- /dev/null +++ b/src/extra/help_book/shell.js @@ -0,0 +1,39 @@ +// The shell page script: drives the content iframe from the location +// hash and highlights the current page in the sidebar. +(function () { + var frame = document.getElementById("content"); + + // The location hash addresses the page shown in the content frame, + // e.g. index.html#option.font-size.html. Only same-directory page + // names (with an optional anchor) are allowed as frame targets. + function navigate() { + var target = location.hash.slice(1) || "home.html"; + if (!/^[\w.-]+\.html(#[\w.-]*)?$/.test(target)) target = "home.html"; + if (frame.getAttribute("src") !== target) { + frame.setAttribute("src", target); + } + } + window.addEventListener("hashchange", navigate); + navigate(); + + // Content pages report their identity when they load in the frame + // (for sidebar highlighting). + window.addEventListener("message", function (ev) { + var d = ev.data || {}; + if (d.page) { + var cur = document.querySelector("nav.sidebar a.current"); + if (cur) cur.className = ""; + var a = document.getElementById(d.page.replace(/\.html$/, "")); + if (a) { + a.className = "current"; + var det = a.closest("details"); + if (det) det.open = true; + } + if (location.hash.slice(1).split("#")[0] !== d.page) { + try { + history.replaceState(null, "", "#" + d.page); + } catch (e) {} + } + } + }); +})(); diff --git a/src/extra/help_book/style.css b/src/extra/help_book/style.css new file mode 100644 index 00000000000..295d7f748c0 --- /dev/null +++ b/src/extra/help_book/style.css @@ -0,0 +1,253 @@ +/* Layout and typography modeled on the Ghostty website documentation + * (spacing, sizes, monospace headings, rounded blocks) with neutral, + * system-adaptive colors: no explicit backgrounds so the pages follow + * light and dark mode. The dark palette uses a media query rather than + * light-dark() because Help Viewer's WebKit only supports the latter + * from macOS 14.5 and the app targets macOS 13. + * Fully self-contained; nothing loads remotely. */ +:root { + color-scheme: light dark; + --accent: #3551f3; + --muted: #6e6e73; + --code-bg: rgba(0, 0, 0, 0.05); + --border: rgba(0, 0, 0, 0.12); + --warn: #9a6700; +} +@media (prefers-color-scheme: dark) { + :root { + --accent: #7c8ef8; + --muted: #959597; + --code-bg: rgba(255, 255, 255, 0.08); + --border: rgba(255, 255, 255, 0.14); + --warn: #e6c07b; + } +} +body { + margin: 0 auto; + padding: 1.25rem 1.75rem 2.5rem; + max-width: 44rem; + font: + 15px/1.6 -apple-system, + BlinkMacSystemFont, + "Helvetica Neue", + sans-serif; +} +h1, +h2, +h3 { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +h1 { + font-size: 1.6rem; + margin: 1rem 0; +} +h2 { + font-size: 1.2rem; + margin-top: 2rem; +} +h3 { + font-size: 1rem; + margin-top: 1.5rem; +} +hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.92em; + background: var(--code-bg); + border-radius: 4px; + padding: 0.1em 0.35em; +} +pre { + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + overflow-x: auto; +} +pre code { + background: none; + border-radius: 0; + padding: 0; +} +blockquote { + border-left: 3px solid var(--border); + color: var(--muted); + margin: 1rem 0; + padding: 0 1rem; +} +.note, +.warning { + border-left: 3px solid var(--accent); + border-radius: 3px; + background: var(--code-bg); + padding: 0.5rem 1rem; + margin: 1rem 0; +} +.warning { + border-left-color: var(--warn); +} +ul { + padding-left: 1.25rem; +} +li { + margin: 0.15rem 0; +} +table { + border-collapse: collapse; + margin: 1rem 0; +} +th, +td { + border: 1px solid var(--border); + padding: 0.4rem 0.6rem; + text-align: left; + vertical-align: top; +} +th { + background: var(--code-bg); +} +.def { + margin: 0.25rem 0 1rem 1.5rem; +} +.variants { + color: var(--muted); + margin: -0.5rem 0 1.25rem; +} +.footer { + margin-top: 2rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} +.hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; + margin: 1.5rem 0 0.5rem; +} +.hero h1.wordmark { + margin: 0; + line-height: 0; +} +.hero h1.wordmark svg { + width: 214px; + height: auto; +} +.hero .subtitle { + color: var(--muted); + font-size: 1.05rem; + margin: 0; +} +footer.footer { + color: var(--muted); + font-size: 0.85rem; +} +footer.footer p { + margin: 0.25rem 0; +} +footer.footer code { + color: var(--muted); + overflow-wrap: anywhere; +} +footer.footer a:hover { + text-decoration: none; +} +footer.footer a:hover code { + color: var(--accent); +} +body.shell { + display: flex; + height: 100vh; + margin: 0; + padding: 0; + max-width: none; + overflow: hidden; +} +iframe.content { + flex: 1; + height: 100%; + border: none; +} +nav.sidebar { + width: 240px; + flex-shrink: 0; + overflow-y: auto; + padding: 12px; + font-size: 13px; +} +nav.sidebar .sidebar-title { + margin: 4px 6px 12px; + font-size: 14px; + font-weight: 600; +} +nav.sidebar .sidebar-title a { + color: inherit; +} +nav.sidebar summary { + list-style: none; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 500; + color: var(--muted); + padding: 8px 6px; + border-radius: 5px; + cursor: pointer; +} +nav.sidebar summary::-webkit-details-marker { + display: none; +} +nav.sidebar summary:hover { + background: var(--code-bg); + color: inherit; +} +nav.sidebar summary::after { + content: ""; + width: 7px; + height: 7px; + margin-right: 4px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg); + transition: transform 0.2s; +} +nav.sidebar details[open] > summary::after { + transform: rotate(225deg); +} +nav.sidebar ul { + list-style: none; + margin: 2px 0 8px; + padding-left: 12px; +} +nav.sidebar li { + margin: 0 0 2px; +} +nav.sidebar li a { + display: block; + color: var(--muted); + padding: 7px 6px; + border-radius: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +nav.sidebar li a:hover { + background: var(--code-bg); + color: inherit; + text-decoration: none; +} +nav.sidebar li a.current { + background: var(--accent); + color: #fff; +} diff --git a/src/extra/help_book/template.zig b/src/extra/help_book/template.zig new file mode 100644 index 00000000000..4edd1a336c7 --- /dev/null +++ b/src/extra/help_book/template.zig @@ -0,0 +1,52 @@ +//! A minimal comptime-parsed template engine for the help book assets: +//! `{{name}}` placeholders in a template are replaced by the identically +//! named field of the `args` struct; everything else is written through +//! verbatim. Values are written as-is, so callers must HTML-escape any +//! dynamic value that needs it. A placeholder without a matching field is +//! a compile error. +const std = @import("std"); + +pub fn write( + w: *std.Io.Writer, + comptime source: []const u8, + args: anytype, +) !void { + const parts = comptime parse(source); + inline for (parts) |part| switch (part) { + .literal => |text| try w.writeAll(text), + .placeholder => |name| try w.writeAll(@field(args, name)), + }; +} + +const Part = union(enum) { + literal: []const u8, + placeholder: []const u8, +}; + +fn parse(comptime source: []const u8) []const Part { + @setEvalBranchQuota(1_000_000); + var parts: []const Part = &.{}; + var i: usize = 0; + while (std.mem.indexOfPos(u8, source, i, "{{")) |start| { + const end = std.mem.indexOfPos(u8, source, start + 2, "}}") orelse + @compileError("unterminated {{placeholder}} in template"); + if (start > i) parts = parts ++ [_]Part{.{ .literal = source[i..start] }}; + parts = parts ++ [_]Part{.{ .placeholder = source[start + 2 .. end] }}; + i = end + 2; + } + if (i < source.len) parts = parts ++ [_]Part{.{ .literal = source[i..] }}; + return parts; +} + +test "template substitution" { + const testing = std.testing; + + var stream: std.Io.Writer.Allocating = .init(testing.allocator); + defer stream.deinit(); + + try write(&stream.writer, "{{title}}{{body}} and {{title}}", .{ + .title = "T", + .body = "

    B

    ", + }); + try testing.expectEqualStrings("T

    B

    and T", stream.written()); +} diff --git a/src/extra/help_book/topic.html b/src/extra/help_book/topic.html new file mode 100644 index 00000000000..5ca2d04f8cb --- /dev/null +++ b/src/extra/help_book/topic.html @@ -0,0 +1,17 @@ + + + + + +{{name}} + + + + + + + +

    {{name}}

    +{{variants}}{{body}} + + diff --git a/src/extra/help_book/wordmark.svg b/src/extra/help_book/wordmark.svg new file mode 100644 index 00000000000..dce64b9b775 --- /dev/null +++ b/src/extra/help_book/wordmark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/main_build_data.zig b/src/main_build_data.zig index b441e377930..f166bc62906 100644 --- a/src/main_build_data.zig +++ b/src/main_build_data.zig @@ -24,6 +24,7 @@ pub const Action = enum { @"vim-compiler", // Other + @"help-book", terminfo, }; @@ -32,7 +33,8 @@ pub fn main(init: std.process.Init) !void { const action_ = try cli.action.detectArgs(Action, alloc, init.minimal.args); const action = action_ orelse return error.NoAction; - // Our output always goes to stdout. + // Our output goes to stdout, except for the help book, which is a + // directory tree written to the path given as an argument. var buffer: [1024]u8 = undefined; var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buffer); const writer = &stdout_writer.interface; @@ -45,6 +47,7 @@ pub fn main(init: std.process.Init) !void { .@"vim-ftdetect" => try writer.writeAll(@import("extra/vim.zig").ftdetect), .@"vim-ftplugin" => try writer.writeAll(@import("extra/vim.zig").ftplugin), .@"vim-compiler" => try writer.writeAll(@import("extra/vim.zig").compiler), + .@"help-book" => try @import("extra/help_book.zig").writeCli(alloc, init.io, init.minimal.args), .terminfo => try @import("terminfo/ghostty.zig").ghostty.encode(writer), } try stdout_writer.end(); diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index cd2ef0c1797..7857c92994b 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -228,6 +228,9 @@ test { // Extra _ = @import("extra/bash.zig"); _ = @import("extra/fish.zig"); + _ = @import("extra/help_book.zig"); + _ = @import("extra/help_book/markdown.zig"); + _ = @import("extra/help_book/template.zig"); _ = @import("extra/sublime.zig"); _ = @import("extra/vim.zig"); _ = @import("extra/zsh.zig"); diff --git a/typos.toml b/typos.toml index 42303dd0076..8ebdaa20a2a 100644 --- a/typos.toml +++ b/typos.toml @@ -78,6 +78,8 @@ rin = "rin" ower = "ower" # OpenType table names loca = "loca" +# hiutil flags (-Caf: create index with anchors into file) +Caf = "Caf" [type.po] extend-glob = ["*.po"]