CLAUDE.mdis a symlink to this file. Claude Code and Codex (and any other agent readingAGENTS.md) share this single source so the guide stays in sync.Claude-specific assets:
- 全局规则:
~/.claude/CLAUDE.md- Swift 通用规则:
~/.claude/rules/swift.md(项目级补充.claude/rules/swift.md)- 本仓库没有项目 skill;release、review、lint 的项目事实都在本文件
MiaoYan is a lightweight Markdown editor built with Swift. This open-source repository is the only Mac codebase and is actively developed (maintainer, 2026-10-11, reversing the 2026-09-28 maintenance-only split): every Mac feature and fix lands here first. The Mac app is moving from an AppKit shell to a SwiftUI one while keeping its AppKit editor and preview. The iPhone and iPad app and the App Store packaging live in the private tw93/MiaoYan-Pro, which merges this repository's main to build the App Store Mac app; nothing is pushed from that repository to this one. The App Store purchase is the recommended way to get MiaoYan, one purchase covering iPhone, iPad and Mac; the Mac app stays free here.
- Markdown rendering:
swift-cmark-gfmparses GitHub Flavored Markdown. - Syntax highlight:
Highlightr. - Math / diagrams: LaTeX formulas, Mermaid, PlantUML supported via the preview renderer.
- Slide mode: based on Reveal.js.
---separators delimit slides. - Note storage: filesystem-backed with folder nesting, file-system watch, auto-save, and version history.
- Editor: live preview, syntax highlight, keyboard shortcuts, Prettier-integrated auto-format.
- App shell: the storyboard window today; a SwiftUI main window under
Shell/opens instead with-MiaoYanShell YESuntil it reaches parity.
Controllers/- view controllers and window controllers.Views/- UI components.Business/- models and business logic.Helpers/- utilities and services.Extensions/- Swift extensions.Resources/- bundled resources.Shell/- the SwiftUI main window in progress (-MiaoYanShell YES), see the SwiftUI shell rules below.MiaoYan.xcodeproj/- Xcode project and version settings.Package.swift- a reference list of the Swift package dependencies; no Xcode project reads it, the real package references live inproject.pbxproj.Packages/MiaoYanCore/- local Swift package with the logic the Mac app and the iPhone app in MiaoYan-Pro must agree on (frontmatter, search matching, note text decoding, list-row and card previews), with its own tests (swift test --package-path Packages/MiaoYanCore). It imports Foundation only; a change here reaches the iPhone app when Pro mergesmain, so keep its API stable.scripts/- local build, release, and project maintenance scripts.scripts/release-ci/- release note rendering, appcast, notarization, and package helpers.skills/miaoyan/- published Agent Skill (tracked) describing MiaoYan's Markdown, PPT, andmiaoCLI surfaces to outside agents; it restates product syntax, so it drifts when those surfaces change..github/RELEASE_NOTES.md- public release note source for GitHub release and appcast body generation..github/workflows/- holdsci.ymlonly; release builds are not driven by a tracked release workflow.vercel.json-mainis also deployed as static files tocdn.miaoyan.app(Vercel projectmiao-yan, team Faberon), because exported HTML sets<base href>tohttps://cdn.miaoyan.app/Resources/DownView.bundle/(HtmlManager.cdnBaseURL); keep those paths stable. The file only addsX-Robots-Tag: noindex, since search engines were indexing that host's listing of this repository.
xcodebuild -project MiaoYan.xcodeproj -scheme MiaoYan -configuration Debug build
xcodebuild clean
xcodebuild test -project MiaoYan.xcodeproj -scheme MiaoYan -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
swiftlint lint --strict
swift-format lint --recursive . --strict # --strict is what CI runs; without it a local pass can still fail CI
swift test --package-path Packages/MiaoYanCore
bash scripts/build.sh
ruby scripts/add_tests_target.rb # only when re-wiring MiaoYanTests after pbxproj resetUse the narrowest relevant command first. Full app builds are the default verification for Swift or project changes.
The Xcode project uses classic pbxproj groups (no filesystem-synchronized
groups). Adding a new source file requires manual registration at 4 sites in
MiaoYan.xcodeproj/project.pbxproj: a PBXBuildFile entry, a
PBXFileReference entry, the owning group's children list, and the target's
PBXSourcesBuildPhase files list. Mimic an existing sibling entry and use a
fresh unique 24-hex ID for each new object.
Unit tests live under MiaoYanTests/. Coverage targets pure-logic surfaces
(ImageLinkParser, WikilinkIndex.updateNote, String+, etc.). UI flows are
verified by manual smoke after build, not by XCUITest.
Add a new test:
- Create
MiaoYanTests/<Subject>Tests.swift(XCTest,@MainActoron the test methods if they touch@MainActor-isolated types;setUp()overrides cannot be@MainActor, construct isolated objects inside the tests). - Register the file manually at the same 4 pbxproj sites as an app source,
but into the
MiaoYanTestsgroup and theMiaoYanTeststarget'sPBXSourcesBuildPhase. Mimic theNoteFrontmatterTests.swiftsibling entries.scripts/add_tests_target.rbis a no-op once the target exists (it only bootstraps the target after a pbxproj reset), and thexcodeprojgem it needs is not installed on this machine anyway. - Run
xcodebuild test ...locally, then push.
A green exit code does not prove a new test ran. Check that Executed N tests
grew and that the new test's Test Case ... passed line is in the log: a
method-level -only-testing: that matches nothing exits 0 having run zero
tests, and a test appended to the end of a file holding two XCTestCase
classes lands in the second one. For a regression test, red-run it once on
the old code and see it fail.
CODE_SIGNING_ALLOWED=NO is required on the local test command because
the dev signing identity used for MiaoYan.app and the per-developer
identity used for MiaoYanTests.xctest end up with different Team IDs,
which makes dyld refuse to load the test bundle into the host app. ci.yml
passes the same flag on every xcodebuild invocation, so it is not a
local-only workaround.
.github/workflows/ci.yml runs on every PR and push to main:
- macOS Debug build, then
xcodebuild testfor the unit suite (no signing required), thenswift testforPackages/MiaoYanCore - SwiftLint and swift-format, both
--strict, so any warning is a merge gate - Release-notes rendering smoke (
scripts/release-ci/notes_to_html.shandrender_release_body.sh) so a broken.github/RELEASE_NOTES.mdis caught before release time, not during it - The same job checks that the Chinese and English release-note lists have
the same number of items, and runs
bash -nplusshellcheck -S erroronscripts/*.sh,scripts/release-ci/*.shandscripts/miaoyan. ShellCheck does not flag a helper called before its definition (the install.shwarnbug), so keep script helpers at the top. - On tag pushes (
V*): version-triplet consistency check (MARKETING_VERSION == CURRENT_PROJECT_VERSION == tag) to prevent the V3.5.1 / #524 incident recurrence
CI builds with Xcode 16.3 (Swift 6.1), whose concurrency checks are stricter
than the local Xcode 27: reading a main-actor static from a nonisolated
override, sending self into Task.detached, or a test calling a helper of a
type inferred @MainActor (anything conforming to WKScriptMessageHandler)
builds locally and fails there. After every push, read the run's result with
gh run view; CI failed unnoticed for a whole batch in 2026-10. A test of the
1.5s save debounce must let the timer fire on its own: flushPendingSave
writes current content and hides a queued stale write.
CI does NOT run the notarization or Sparkle signing scripts; those need maintainer-managed signing keys and run only on the maintainer's machine.
AppDelegate.trackError is the single funnel for runtime errors:
- DEBUG: still prints to stdout (kept for Xcode console workflow).
- RELEASE: routes through
Helpers/Diagnostics.swift, which writes a.faultos_log entry plus a JSON-line ring buffer at~/Library/Logs/MiaoYan/diagnostics.log(50 entries max). - Users can attach the diagnostics log to bug reports without us running an analytics SDK (local-first stance).
When wiring a new failure path, call AppDelegate.trackError(error, context:)
rather than print(...) or silently swallowing with try?. The context
string is the only breadcrumb the maintainer has when triaging.
- 付费用户视角: 默认按 App Store 付费版的精致度做。每次视觉 / 交互改动思考"对得起付费用户吗"。能精致一分就精致一分。
- Preview images, videos and iframes must stay within
max-width: 100%. Wide tables may scroll inside.table-scroll, but must not widen the page. PDF and PNG exports must fit tables to the output width because exported content cannot scroll. Recheck these bounds when adding raw HTML rendering; verify initial rendering, incremental updates and repeated exports when changing table layout. - 设计参考: UI / CSS 抄不出来时去看
~/www/weekly和~/www/tw93.github.io, 那里有维护者已经满意的样式。不要凭空发挥。 - 目标视觉风格: macOS 26 风格的 sidebar (玻璃态、透明、SF Symbols 最新一代) 是长期方向, 不是经典 Big Sur 风格。
- 不要再提议整套 macOS 26 / Liquid Glass 重设计。一次实机改造 (侧栏换原生
.sidebar半透明材质 + 选中态改强调色玻璃 pill + 图标整体迁 SF Symbols + 自绘 pillChromeToolbarButton) 已被维护者否决, 原话"还不如之前好看, 不强求这个"。要打磨侧栏 / 按钮就在现有不透明设计上做小步增量: 间距、对齐、hover、focus、字重。不要整体换材质或换图标体系, 除非维护者在当前回合明确要求。 - App 图标保持原插画 (维护者 2026-10-14):
Resources/app.icon用Assets/43.png的完整红雀插画 (太阳、红雀、树枝、叶子)。同日试过两次简化都被否: 去掉太阳和叶子后尖冠孤零零戳在底色上「太尖」, 只放回太阳后像一顶「太阳帽」。原图里右上的太阳和右下的叶子互相配平, 拆掉一半就失衡。要再动图标, 在完整插画上一次只改一个属性 (底色、比例、玻璃), 并排渲染给维护者挑, 改前先读~/www/Kata/rules/icon.md。 - 设置窗口按 Mole 的排布 (维护者 2026-10-15): 一个固定尺寸的普通窗口 (568pt 宽, 所有标签同一高度, 内容超出就在窗口内滚动), 标题栏居中三个标签 通用 · 编辑器 · 字体, 选中用沙色底; 不要侧栏, 不要半透明材质。分区只用细线隔开, 不加标题和卡片; 每一行左边是标题加一行说明, 右边是控件, 控件只有小开关、胶囊下拉框、小按钮三种。需要重启的设置在行内出「重启」按钮, 不弹窗。新增设置照这个行结构加, 标题和说明在五种语言下都要一行放得下。实现在
Controllers/*PrefsViewController.swift和Views/PrefsControls.swift, 颜色在Theme.Settings。 - README 首屏 GIF 按语言分开是有意的:
README_CN.md和README_TW.md用中文界面的newmiaoyan.gif, 其余语言用英文界面的miaoyan.gif, 审查和改稿时不要统一成一张 (维护者 2026-10-10)。 - cmd-数字快捷键已占满 0-5, 不要冲突: 1 侧栏, 2 笔记列表, 3 Toggle Preview, 4 Toggle Presentation, 5 TOC, 0 Actual Size。
⌘\只给 Toggle Split View, 用户已经记住了, 不要改; Window 菜单的 Main Window 原先也绑⌘\, 被分栏抢先从来触发不了, 2026-10-11 改为⌥⌘0。新增前先grep 'keyEquivalent="N"' Resources/Localization/Base.lproj/Main.storyboard核对, 绑定只存在于 storyboard, 代码里没有 keyBindings 表。打字机滚动 / 链接相关另开模式或子开关即可。
- Keep UI updates on the main thread.
- Avoid force unwraps unless the invariant is obvious and local.
- Prefer
AppEnvironment.current.<service>over direct singleton access in new code. The SwiftLintno_direct_singleton_in_new_coderule isseverity: warningin.swiftlint.yml, but CI runsswiftlint lint --strict, which promotes it to a merge gate. Existing call sites are grandfathered. - Keep file writes scoped to user documents or app-controlled locations.
- Do not add network calls, shell execution, or broad file access without clear user need.
- The Mac app shell (window, sidebar, note list, settings, toolbar, menu commands) moves to SwiftUI. The editor (
EditTextView, anNSTextViewwith regex highlighting) and the preview (MPreviewView, aWKWebView) stay AppKit behindNSViewRepresentable: SwiftUI's text editing on macOS cannot carry the highlighting, CJK input handling or large-note performance, and a TextKit 2 rewrite is out of scope. The SwiftUI shell lives inShell/inside the same MiaoYan target and opens instead of the storyboard window only when the app is launched with-MiaoYanShell YES(add it to the Xcode scheme's arguments), so the AppKit app stays shippable until the new one reaches parity. Same target rather than a second one keeps the bundle id, preferences, sandbox bookmarks, entitlements, resources and the localized storyboard main menu shared. The shell needs macOS 14 (@Observable); the deployment target stays 12.0 until the switch-over release decides otherwise. - The SwiftUI sidebar must reproduce today's opaque sidebar, not
NavigationSplitView's default translucent material; a glass sidebar was tried on the AppKit app and rejected by the maintainer. - Colour (maintainer's pick, 2026-09-28): one warm paper tone through every column,
#FDFCF9light and#1E1D1Bdark, columns split by a 7% hairline only (Theme.columnHairline, warm ink light and white dark, which both windows use for every dividing line: columns, the split editor and panels such as version history; the system separator grey it replaced read cool on paper and too bright in dark); selection is Kami's warm sand (#E8E6DCsidebar,#EFEDE4list, white 8% / 7% in dark); MiaoYan green stays for the brand row and active buttons. A grey sidebar against a white body and grey selection were both rejected. A dark graphite sidebar (Bear's) was tried and rejected as too loud and too derivative. The editor's highlight colours are Kaku's palette so MiaoYan, Kaku and Mole read as one family:titlepurple#5E3DB3,html(bold, inline code) gold#9A7400,linkandhighlightteal#24837B,listand markup muted#7A7872, body text#403E3C(Theme.editorTextColor/editorMarkupColor), dark values from Kaku's dark theme. Shell text uses Kami's solid warm greys, titles at weight 500, selection by sand fill alone, and note list rows parted by a 0.5ptTheme.columnHairlineinset 20pt on both ends to the title, hidden under the selected row and the row above it, in both windows (maintainer's pick, 2026-10-19, after rows parted by space alone read odd). The storyboard list'sgridStyleMaskstays off: AppKit draws grid lines apart from the row view, so they flickered beside the selection plate as it moved;NoteRowViewdraws the line itself and hides it throughisNextRowSelected, which AppKit updates in the same selection change. The sidebar's MiaoYan lockup (bird and Charter wordmark, always green) is a brand mark that cannot be selected; since 2026-10-19 All Notes is its own row under it, with theallNotesicon and the sand plate like any folder, because selecting the logo itself read wrong (2026-10-15 had given the brand row the plate, and before that it had none). In the storyboard sidebar the lockup is the.Branditem at row 0, andSidebarProjectView.selectRowIndexesturns a selection landing on it into All Notes; in the shell it isShellSidebarBrand, drawn abovesidebarRows. The note list's header reads All Notes. The tone lives inShell/ShellPalette.swift, themainBackgroundandselectionBackgroundassets,Theme.previewDarkBackgroundColor, the--bg-color/--code-bg/--diagram-bg/--border-colorvalues inDownView.bundle(theme-light.css,theme-dark.css,base.css,index.html) andHtmlManager.darkModeExportCSS; change them together or split mode shows two whites. Light export stays white on purpose. - The shell's columns are an AppKit
NSSplitViewController(ShellSplitViewController) with plainNSSplitViewItems hosting SwiftUI panes.NavigationSplitViewbrings the translucent sidebar material plus a window toolbar that.toolbar(removing:)and.toolbar(.hidden, for: .windowToolbar)did not remove, andHSplitViewignores ideal widths and cannot autosave dividers. Each pane'sNSHostingControllerneedssizingOptions = [], and the window's content size must be set again after assigning the controller, or the window collapses to its minimum size. - Any editor or preview code that reaches
AppContext.shared.viewControllerorViewControllerdoes nothing in the shell, where there is none.MPreviewView.loadfalls back to the note's own title. The shell's editor is anEditTextViewcreated in code byShellEditorControllerwithisShellHosted = true:fill()then runs without aViewController, the editor always behaves as the plain edit view (preview and split live in the shell's own panes), andShellEditorController.textDidChangerepeats the storyboard window's owner guard before saving (#543). Keep newEditTextViewchanges working with the flag both ways. - Menu commands: the storyboard menu targets stand-in
ViewControllerandEditTextViewobjects in the Application scene that forward toViewController.shared(), which is nil in the shell.ShellWindowController.retargetMainMenu()points those items at the first responder when the shell opens (the folder items that targetSidebarProjectVieworSidebarCellViewgo straight toShellWindowController's own folder actions); the real editor then takes formatting commands,EditTextView.responds(to:)declines the mode commands (togglePreview:,togglePresentation:,toggleTOC:,formatText:,cleanTypography:) while shell-hosted, andShellWindowControllerimplements them plus new note, delete, rename, pin, reveal, sort and search. Every main-menu command now has a shell handler, and the storyboard window'shandleKeyDownshortcuts are mirrored inShellWindowController.handleKeyDown/handleKeyUp, so a disabled item no longer marks missing parity. The shell goes beyond the storyboard window in two places on purpose: notes drag out to Finder (one note per drag, since SwiftUI.draggablehas no multi-item drag on macOS 14) and Finder files dropped on the list are imported; the storyboard list does neither. The focus moves driven throughShellModel.focusRequeststill need a check in the running app. Delete in the shell does nothing while text has focus, a find bar is open or a presentation runs, as in the storyboard window, so Command-Delete in a text field edits text instead of trashing notes. Disk changes reach either window throughFileSystemEventHost(Helpers/FileSystemEventManager.swift), including the edit-time pause and conflict backups; renaming by title goes throughNote.renameFile(to:)in both. - Parity paths that differ from the storyboard window:
miaoyan://andnv://routes (wikilink clicks, goto, find, new/make) go toShellModelwhen the shell is open, and ones that arrive before the window are replayed after it opens. Single-file mode is left in place withStorage.reconfigureForLibrary()rather than a relaunch, which would drop-MiaoYanShell; the storyboard window's own Command-Shift-W handler still relaunches, so tests that exercise single mode in the test host must hide the default from it. Import adds each copy to storage itself, because an APFS clone reaches the file watcher as a change event, never a creation. PPT PDF export setsUserDefaultsManagement.isOnExportPPTbefore the deck renders;ppt.htmlonly switches Reveal to print layout at render time, and without it the PDF is one blank page. Backlinks in both windows come from every note file under the library roots at any depth (the sidebar's folder rules, Trash excluded), read from disk off the main thread, not only from the notes storage has listed. Subfolders come fromStorage.subfolders(of:)in both sidebars. - The shell's title bar is an empty unified
NSToolbar, which centres the traffic lights in a 52pt band (ShellStyle.headerHeight); each column draws its own controls on that line, and clicks there reach the SwiftUI panes (checked by hit-testing), not the toolbar. Mode buttons persist toUserDefaultsManagement.preview/splitViewMode, the same keys the storyboard window restores from. Shell code refers toSwiftUI.Color,SwiftUI.FontandSwiftUI.Imageby full name because the app target declares module-wideColor,FontandImagetypealiases for the AppKit types. - Preserve recoverability for delete flows. Notes and attachments should move through the app Trash or system Trash path that matches the current context, not disappear through direct deletion.
- The app Trash may resolve to the same directory as the volume's system Trash. Calling
FileManager.trashItemon an item already there renames it in place and makes it reappear. Mark it withAppIdentifier.removedFromTrashKeyand exclude that marker only inside Trash projects, so Finder recovery into a normal project remains visible. - Treat iCloud sync and symlinked directories as file-system-sensitive surfaces; resolve paths deliberately and avoid loops or duplicate indexing.
When scope is incomplete, start with:
ARCHITECTURE.mdfor the real top-level dependency mapControllers/AppDelegate.swiftControllers/MainWindowController.swiftControllers/ViewController.swift- Narrow related files under
Helpers/,Views/,Business/, orExtensions/ - Relevant Xcode project settings only when build, signing, target membership, or version behavior is involved
Avoid broad scans of build/, .build/, dist/, and bundled web assets unless the task targets them.
- Editor buffer ownership (#543): in preview/presentation/PPT modes
EditTextView.notefollows the list selection whiletextStoragekeeps the last edited note, so the two legitimately diverge.EditTextView.storageNoterecords which note the buffer belongs to; every wholesale storage assignment must go throughpublishStorage(_:owner:), and every whole-buffer persist throughsaveTextStorageContent(to:), which refuses cross-note targets. Never persist the buffer based onEditTextView.noteor the table selection alone, and never compareEditTextView.noteagainst itself as a guard (that tautology is how the V4.0.0 content-swap shipped). - Sidebar horizontal layout is owned by
SidebarProjectView.tile(): after reloads and resizes, the outline frame and first column must match the clip-view width and the clip-view horizontal origin must stay at zero. Do not replace this invariant with event-specific width resets. - Wikilinks and backlinks depend on
Business/WikilinkIndex.swift, note loading, search, and sidebar refresh behavior. Keep[[note]]parsing, recursive search, and Trash exclusions consistent. - iCloud sync lives in macOS storage and
Business/CloudSyncManager.swift. Verify fallback behavior when iCloud is unavailable. - Trash handling spans
Business/Storage.swift,Business/Note.swift, sidebar drag/drop, attachment cleanup, and system Trash fallback. Note.loadFailedmarks a note whose file exists but could not be read (an iCloud placeholder offline, a permission error). Its buffer holds none of the real text, soexecuteSaverefuses every write until a read succeeds and the editor stays read-only for it. Never clear the flag without a successful read: saving that buffer is how an unreadable note used to be replaced by an empty file.- ViewController's local key monitor runs
handleKeyDownfor every key and passes every key on (#491: swallowing them broke Command-C on macOS Tahoe), except Return pressed in the list or sidebar, which it consumes after moving focus so the newline never reaches the editor. Views must not callhandleKeyDownagain from their ownkeyDown, or list shortcuts fire twice. - A successfully removed note must retire its
Noteinstance before any watcher, editor, lifecycle flush, or upload callback can save it again. Existing-note writes must fail closed if the file disappears, and UI rows may be removed only for filesystem operations that succeeded. - Note bodies load only when a note is opened (and for Trash), so
note.contentis empty for most notes. Anything deciding whether a note refers to something reads unloaded bodies from disk: Clean Unused Attachments trashed every image of an unopened note untilStorage.findOrphanAttachmentsdid, and it now returns nil, cleaning nothing, when any note cannot be read. - Version history lives in
Business/NoteVersionManager.swiftandControllers/VersionHistoryViewController.swift; keep file IO off the main thread and UI updates on the main thread. - PNG export must prepare the current DOM, inject export styles and wait for media on every invocation. Cleanup removes those styles, so cached note content never proves a subsequent export is ready.
- Mermaid and PDF export span
Business/HtmlManager.swift,Helpers/PdfExportController.swift, andExtensions/MPreviewView+Export.swift. Wait for images and Mermaid rendering before capture. - Note-list search (
ViewController+Data.swift) filters by folder scope first, then hands plainNoteSearchCandidatevalues toNoteContentMatcherin a detached task, which reads unloaded bodies from disk; the main thread only snapshots and applies results. Never load or lowercase note bodies on the main thread for search, and match titles without the.mdextension. - Async note/image/file loading is intentional. Do not reintroduce blocking reads on the main thread for large notes or previews.
- Directory symlinks are supported by storage scanning. Avoid recursion loops and duplicate notes when following symlinked directories. Match resolved filesystem paths rather than raw URL equality when reconciling directory entries with stored Project or Note instances:
/tmpand/private/tmpaliases and directory hints can otherwise create twins after a move or rename. - Folder moves use
Storage.moveFolder(_:to:)in both sidebars. Only non-root folders may move within their current library, after pending saves succeed. Resolve destination symlinks, reject descendants, attachment/hidden/package folders and name conflicts, and use an exclusive same-volume rename. Preserve Note instances and version history, update the moved folder's parent, defer affected watcher events and note saves until disk and model paths agree, and let unrelated events continue. Quitting waits for the move to finish before the normal save flush. - Note attachments follow the shared
i/convention: images live in ani/folder next to the note, referenced as; the iPhone app in MiaoYan-Pro reads the same convention, so keep it stable. - Image upload posts to a local PicGo/PicList HTTP endpoint at
127.0.0.1:36677(Helpers/ClipboardManager.swift). The macOSInfo.plistATS permits this viaNSAllowsLocalNetworking; do not widen it back toNSAllowsArbitraryLoads. The markdown preview loads throughloadFileURL(file://), not a local web server, so ATS does not gate preview rendering. renderMarkdownHTMLinBusiness/Markdown.swiftis the single markdown-to-HTML funnel for preview, split view, export, PPT, and actions. Post-render transforms (the GitHub Alerts callout rewrite lives there) belong at the end of that function, never in individual call sites. Alert styling lives inDownView.bundle/css/typography.csswith dark overrides intheme-dark.css(the.darkmode *color rule forces explicit dark restatements).- Frontmatter stripping is a per-surface invariant, not a two-file rule: every surface that outputs note or markdown content (macOS preview/export, appcast/release-notes rendering, any future export) must strip leading YAML frontmatter, and a new rendering surface adds its stripping in the same commit (
---date/image---has leaked verbatim through the appcast body).Note.cleanMetaDatacallsFrontmatter.body(of:)inPackages/MiaoYanCore, which the iPhone app uses too, so a rule change is one edit plusswift test --package-path Packages/MiaoYanCore. CRLF gotcha:"\r\n"is one Swift grapheme, sorange(of: "\n---")never matches inside it; search both"\n---"and"\r\n---". Helpers/TypographyCleaner.swift(Edit → Clean Typography) must never rewrite protected regions: fenced/inline code, math, link targets, wikilinks, bare URLs, frontmatter. Extend the segment parser, don't bypass it.Helpers/HtmlToMarkdown.swiftconverts pasted HTML only when block-structure tags are present, so plain-text paste stays authoritative for code copied from editors; keep that gate.- New macOS menu items need the storyboard entry plus an ObjectID-keyed
.titleline in all fourMain.strings(es/ja/zh-Hans/zh-Hant); new toasts need the English text as key in all fourLocalizable.strings(Base has no Localizable.strings, English falls back to the key itself). Missing a file silently ships English to that locale.
This repository ships the direct-download app: GitHub Release assets plus an appcast entry, updated in place by Sparkle from https://miaoyan.app/appcast.xml, and picked up by Homebrew. Build with scripts/build.sh (Developer ID, notarization, Sparkle). The App Store Mac and iPhone builds come from the private tw93/MiaoYan-Pro, which merges this repository's main first, so App Store users never see this channel's appcast. miaoyan.app recommends the App Store and links only to the App Store and this repository, never to a Mac download (maintainer, 2026-10-11); the release assets stay for Sparkle and Homebrew users.
appcast.xmllives on the miaoyan.app site, not in this repository.scripts/release-ci/update_appcast.shproduces the entry andscripts/build.shprints the enclosure line, pointing at the GitHub release assetreleases/download/V{x.y.z}/MiaoYan_V{x.y.z}.zip. When the maintainer's private~/.config/miaoyan/build.shexists,scripts/build.shruns it instead, so a change to packaging, signing or the printed enclosure has to land in both. Historical entries retain their originalmiaoyan.app/Release/URLs.- Both install paths fetch release assets, never the tag tarball: homebrew-cask's
urlisreleases/download/V4.3.0/MiaoYan_V4.3.0.zipand the appcast enclosure is the same shape. Nothing pins a hash ofarchive/refs/tags/*.tar.gz, so a tag that has no release yet can be deleted and recut without breaking a consumer. Deleting a tag that does have a release breaksbrew install --cask miaoyanimmediately, because the cask names that asset.
- Font menus refresh when opened because the preferences controller is reused. Cache classification by the installed family set, not its count, and preserve the selected stored family when rebuilding a menu.
- The default editor, preview and interface face is the system pairing (maintainer, 2026-10-11, after comparing it with TsangerJinKai02, Hiragino Sans GB, New York with Songti and Charter with Kaiti): SF for Latin and the system's CJK face for the user's languages, PingFang for Chinese and Hiragino for a Japanese-first user. It is stored as
FontConfiguration.fallbackFont(PingFangSC-Regular);FontConfiguration.textFont(named:size:)turns that value, PingFang picked from the popup, or a face that is not installed intoNSFont.systemFont, whose hidden family still resolves throughNSFontManager, so the editor's family-based bold and italic keep working, and the preview gets the same pairing fromFontCatalog.fontStack. TsangerJinKai02 (TsangerJinKai02-W04) was the default before that; it is not bundled because redistribution needs a licence from Tsanger (personal non-commercial use is free), installs from that time still store it, and it stays a recommended face with a not-installed row. A stored font choice is never rewritten, not by a migration and not because the face is missing at read time;ViewControllerwatchesNSFont.fontSetChangedNotificationand applies a chosen face the moment it appears. The download button opens the foundry's direct.ttfURL (FontCatalog.retiredBundledDownloadURL); MiaoYan never hosts the file. - Tracking is one figure,
UserDefaultsManagement.letterSpacingEm(0.02em), shared by the editor kern, interface labels (scaled by each label's point size) and.hetiin typography.css. The earlier fixed 0.5pt/0.6pt and 0.04em were tuned in 2022 for LXGW WenKai and spread PingFang and all Latin apart; change the three together. - Font preferences hold two different spellings. The defaults and fallback in
Business/FontConfiguration.swiftare PostScript names (PingFangSC-Regular, andTsangerJinKai02-W04in older installs), while every value the font popups write is a family name (PingFang SC), because the popups are built fromavailableFontFamilies. Anything comparing a stored value against a default, or matching it to a popup row, has to resolve both sides throughFontCatalog.familyName(forStored:)first. Comparing the raw strings made the preview's Latin ordering unreachable the moment the user opened the popup, with both states rendering the same selected row. FontCatalog.fontStack(forStored:)decides who sets Latin, and the test is whether the user picked the face, not whether it is a CJK face. PingFang, the default, has Latin drawn for interface labels and yields it toui-sans-serif, system-ui, -apple-system; a face someone chose leads its own stack and sets the whole line, because TsangerJinKai02 and its like draw Latin to match their Chinese.- A CSS property's minimum version has to clear
MACOSX_DEPLOYMENT_TARGET, currently 12.0. When declarations have different minimum versions, retain a supported fallback and verify the absent-variable behavior on both engines. - The preview keeps the chosen face for bold. Swapping TsangerJinKai02 W05 in for headings and strong text (V4.3.1) made them visibly lighter than the W04 synthetic bold people were used to, and the maintainer asked for W04 back; do not reintroduce a heavier-face substitution. Body text uses
-webkit-font-smoothing: subpixel-antialiased;antialiasedonhtmlthinned every paragraph in the same release. - The code font reaches fenced code blocks only (
pre code, and not insideblockquote). Inline code and quoted code inherit the surrounding text face. The stored default isFontConfiguration.followTextFont, which means the editor font in the editor and the preview stack in the preview; checkUserDefaultsManagement.codeFollowsTextbefore treatingcodeFontNameas a face. Raw HTML tags in the editor go throughCodeBlockHighlighter.highlightInlineHTML, neverhighlightCode, which would give them the code font and the.codeBlockmark: it sets every tag (opening, closing, self-closing, comments, nested or across lines, but not an autolink or a tag inside a code span) in the editor's own text face and size and the markup colour, and leaves text between tags as prose (maintainer's pick, 2026-10-19, from rendered candidates: the system face two points down read as code beside TsangerJinKai, and the old patterns missed<video><source></video>and multi-line<table>blocks). Markdown's own punctuation (#,**,>, list markers, link and image brackets, autolink< >) takesNotesTextProcessor.markupFont(system face, one point down) throughmarkSyntaxinMarkdownRuleHighlighter, because TsangerJinKai draws<,>,*and"wide and heavy (maintainer's pick, 2026-09-28). Chinese punctuation stays in the text face's own marks (TsangerJinKai's half-width ones when it is chosen); the maintainer prefers them. ppt.htmlloads reveal's stylesheets, notbase.cssortypography.css, so none of the--text-font*variables reach it.--r-main-fontis the only one reveal reads. A preview font change is not done until the PPT branch ofpreviewStyle()has been checked too.
- Tag format is uppercase
Vx.y.z. - One release per batch. Shipping cadence here is roughly monthly (V4.0.0 to V4.1.0 to V4.2.0 were 29 and 28 days apart); once a version is out, further work waits for the next one. Only a regression that version introduced, or a fix users cannot otherwise obtain, justifies a follow-up tag. V4.3.0 and V4.3.1 went out 1.4 hours apart because a batch of font work landed right after a release and was tagged on the spot instead of being held, which prompted every Sparkle user to update twice in one afternoon.
- Version changes must keep both
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONinMiaoYan.xcodeproj/project.pbxprojaligned with the release tag. Sparkle comparessparkle:versionin appcast.xml againstCFBundleVersion(mapped fromCURRENT_PROJECT_VERSION), notCFBundleShortVersionString. If the two diverge, users get an infinite update prompt loop (V3.5.1 incident, #524). .github/RELEASE_NOTES.mdis the public release note source: a# V{x.y.z} {Codename} {emoji}heading, a Chinese numbered list, a---separator, then an English numbered list mapped one-to-one.scripts/release-ci/render_release_body.shrenders it into the GitHub release body and appcast content; codenames follow the list inscripts/release-ci/generate_release_content.sh.- Release titles follow
V{x.y.z} {Codename} {emoji}(e.g.V4.0.0 Valstrax 🚀). Before drafting notes,gh release viewthe previous release and mirror its exact body shape instead of rebuilding it from memory. - Publishing ends with the six positive reactions (
+1,laugh,heart,hooray,rocket,eyes) added viagh apiand read back to confirm. Never add-1orconfused. - Direct-download Sparkle signing must use the MiaoYan release key, not the default Sparkle Keychain account. Before pushing appcast changes, verify the signature against the published ZIP and the app's embedded
SUPublicEDKeywithscripts/release-ci/verify_sparkle_signature.sh; a signature-only appcast fix is valid only when ZIP bytes and length are unchanged. The DMG, ZIP, and Sparkle metadata carry the same version, and the ZIP the appcast points at is the exact file that was signed, never a re-zipped copy. - Direct-download release builds use repository scripts; no tracked workflow packages a release.
- Release automation depends on maintainer-managed signing, notarization, and Sparkle credentials. Do not document or commit local credential paths, private key filenames, or secret values.
- Swift changes: run the Debug
xcodebuildcommand above. - UI or interaction fixes: launch the built app and exercise the changed flow before reporting done; a green build is not visual proof. If the first fix attempt does not hold, stop guessing and add
#if DEBUGruntime logging to capture evidence before the next code change. - Lint or formatting changes: run SwiftLint and swift-format checks.
- Release or signing changes: verify version alignment and inspect the relevant repository script; do not assume a tracked
release.ymlexists. - Release note changes: inspect
.github/RELEASE_NOTES.mdand the affectedscripts/release-ci/renderer. - Export changes: verify Mermaid, images, PDF pagination, and async readiness behavior together.
- Documentation-only changes: check links and command accuracy.