feat(themes): port the original document palettes

Refs #7

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-10 20:22:31 -05:00
1 parent e7b6985a34
commit 0507ca5ac4
37 files changed
+1273 -25

No files matched your search

+3 -2
View File
@@ -1,6 +1,6 @@
# MarkdownViewr
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The first reader implementation opens Markdown, renders GFM and supports manual and automatic reload. Document themes, navigation and printing are still in development; hosted automatic-reload acceptance is pending. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The first reader implementation opens Markdown, renders GFM and supports manual and automatic reload. The 20 original document themes are implemented; user-theme editing, navigation and printing remain in development; hosted automatic-reload acceptance is pending. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
A Windows Markdown viewer with live reload, themes and document navigation.
@@ -31,7 +31,8 @@ Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
- **File → Open / Ctrl+O:** choose a Markdown document. A literal file path can also be supplied when launching the executable.
- **Live reload:** saves automatically refresh the document, including replacement saves. Deletion keeps the last version visible until the file is restored. The relative reading position is preserved.
- **File → Reload:** reread the current file manually. Local images stay within the document folder; remote images and authored scripts/styles are blocked.
- **View:** choose the light, dark, or system theme; turn the startup splash and the
- **View → Document theme:** choose one of Darin's 20 built-in palettes. The saved document palette is independent of the Windows shell theme; changing it retains the reading position.
- **View:** choose the light, dark, or system shell theme; turn the startup splash and the
startup update check on or off. Choices are saved immediately.
- **Help → Activity log:** a live view of the app's log, with an errors-only filter and a
shortcut to the log folder.
+28
View File
@@ -107,3 +107,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## markdownViewr built-in themes
The 20 original JSON themes are copied unchanged from Darin Kelkhoff's markdownViewr at commit 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021.
Source: <https://github.com/darinkelkhoff/markdownViewr/tree/6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021/markdownViewr/Resources/themes>
MIT License
Copyright (c) 2026 Darin Kelkhoff
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -2,7 +2,7 @@
Build a Windows counterpart to [Darin Kelkhoff's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), using the KofTwentyTwo AppKit foundations and standards. The agreed display name is MarkdownViewr, repository MarkdownViewr.Windows, and permanent application ID markdownviewr-windows.
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports bounded local images, manual reload and automatic saves with relative scroll preservation. First-reader hosted UI acceptance passed; the new live-reload scenarios await hosted acceptance. The full feature list below remains the target; the first reader is not a completed port.
The first reader implementation opens Markdown with a Windows picker or literal launch argument, renders sanitized GFM and supports bounded local images, manual reload and automatic saves with relative scroll preservation. The original 20 document themes and persistent palette selection are implemented. First-reader hosted UI acceptance passed; the new live-reload scenarios await hosted acceptance. The full feature list below remains the target; the first reader is not a completed port.
## Reference and feature scope
+4 -3
View File
@@ -43,6 +43,7 @@ name and content view. The deletion assertion remains unchanged; it also require
the initial Opened status to be accessible. Build and formatting pass locally;
hosted recovery proof is pending. Accessibility head 23928bc still failed the initial status lookup (24/25 UI tests passed); properties alone did not resolve it. The next diagnostic retains the native tree and fixture-window PNG in hosted installed evidence. No deletion assertion is weakened. Scripts 38100976565 reproduced the analyzer
engine crash; no scripts/settings/workflow changed and issue #5 remains open.
## Repository and authorization
Public repository: [MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows).
@@ -58,12 +59,12 @@ CI signing and final signed release artifacts remain unverified. Published AppKi
## Next work
Verify reload/browser network acceptance and preserve its exact hosted handles.
Then port Darin's themes and implement TOC/collapse, source/find/vim navigation,
Built-in theme implementation now loads all 20 original resources, with persistent document selection, Windows font fallbacks and viewport retention. 117 core tests pass at 100% coverage; zero-warning build and formatting pass. User-theme editing and actual browser selection acceptance remain pending. Continue TOC/collapse, source/find/vim navigation,
frontmatter, editor, print, multiple windows, recents and Windows associations.
Original reference is pinned at 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021 under
AppKit/artifacts/markdownviewr-reference. No original code/resources copied yet;
AppKit/artifacts/markdownviewr-reference. The 20 original JSON themes are embedded unchanged in the core assembly; their full MIT notice ships with the app.
retain Darin's MIT notices when adapting them. Full PRODUCT.md scope remains open.
Reference preparation: all 20 original JSON themes have been downloaded into
AppKit/artifacts/markdownviewr-reference/markdownViewr/Resources/themes and checked
against their Git blob hashes at the pinned commit. They are not yet in the app.
against their Git blob hashes at the pinned commit. They now match the embedded app resources by Git blob hash.
+2 -1
View File
@@ -12,7 +12,8 @@
- [ ] Discuss the desired first release and Windows-specific behavior with the owner.
- [x] Implement/test directory watching and validated relative viewport state for saves/replacements/deletion recovery.
- [ ] Complete actual browser save/lock/replacement/deletion/scroll acceptance (issue #6).
- [ ] Port Darin's built-in themes; add editable user themes, custom CSS and code font settings.
- [x] Port Darin's 20 built-in themes and persistent document selection; validate schema, font escaping and invariant CSS.
- [ ] Verify hosted theme selection/viewport acceptance; add editable user themes, custom CSS and code font settings.
- [ ] Add TOC, section collapse, source pane, synchronized navigation, find and vim keys.
- [ ] Add YAML frontmatter modes, safe configured editor launch and printing options.
- [ ] Add multiple document windows, recent files and Windows activation/file associations.
+2 -2
View File
@@ -21,7 +21,7 @@ Versions and transitive hashes are committed in the NuGet locks.
Host the sanitized result in WinUI WebView2. Serve an intercepted logical HTTPS
document, with a random resource namespace for each open; no local HTTP listener.
The document response carries a deny-by-default CSP and no-referrer/no-store headers.
Only application-generated CSS is allowed; authored styles, IDs and classes are removed.
Document-authored styles, IDs and classes are removed. Application theme CSS is generated from validated six-digit colors, bounded finite sizes and escaped font names. Theme custom CSS escapes every less-than sign before HTML embedding, preventing style-element breakout; CSP and resource interception continue to deny remote resources.
Disable scripts, web messages, host objects, frames, downloads, popups and permission
requests. Reject all browser resources except the exact document and allowlisted
@@ -45,7 +45,7 @@ Bound documents to 16 Mi characters and individual images to 16 MiB.
## Consequences
The reader supports a picker, literal launch arguments, manual reload and debounced save watching.
It does not yet reproduce Darin's themes, syntax colors or navigation. Automatic save
The 20 original themes are embedded unchanged, with their MIT notice shipped in THIRD-PARTY-NOTICES.md. System/SF Mono defaults map to Windows font fallbacks. Document theme selection persists separately from the AppKit shell theme and retains the viewport. User-theme editing, syntax colors and navigation remain pending. Automatic save
reload uses fixed native ExecuteScriptAsync queries on the checked current document;
this API works while authored scripts remain disabled. Browser results must be exactly
two finite numeric fractions, clamped to 0–1; only invariant numeric arguments enter the
+1
View File
@@ -22,6 +22,7 @@ physical directory. It does not authorize native commands or remote resource acc
| Picker / literal process argument | Read-only bounded snapshot; no shell interpolation |
| Markdown / raw HTML | Explicit tag/attribute/scheme allowlist; disabled checkbox inputs; no authored scripts, CSS, IDs or classes |
| File change notifications | Directory subscription scoped to selected path; 350 ms quiet-period debounce; replacement/deletion recovery; bounded transient retries; subscriptions released on window close |
| Theme JSON / CSS | Embedded original palettes; bounded schema-version-one parser; validated hex colors and finite sizes; code-point escaped font names; less-than signs escaped before style embedding; no network capability |
| Browser viewport | Fixed origin-checked native query; validate two finite fractions; invariant numeric restore only; authored scripts/messages stay disabled |
| Browser surface | Scripts, messages, host objects, permissions, downloads, frames and popups disabled; restrictive CSP |
| Local images | Per-document URI prefix; one decode; filename/extension policy; final open-handle containment; 16 MiB limit |
@@ -8,7 +8,9 @@ using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core;
/// <summary>Persistent AppKit shell preferences; document preferences will extend this type.</summary>
/// <summary>Persistent AppKit shell and Markdown reading preferences.</summary>
public sealed class AppSettings : ShellSettings
{
/// <summary>The selected document theme; independent of the Windows shell theme.</summary>
public string DocumentTheme { get; set; } = "Catppuccin Mocha";
}
@@ -23,4 +23,8 @@
<PackageReference Include="HtmlSanitizer" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Themes/BuiltIn/*.json" />
</ItemGroup>
</Project>
@@ -0,0 +1,37 @@
{
"name": "Catppuccin Latte",
"colors": {
"background": "#eff1f5",
"text": "#4c4f69",
"heading1": "#d20f39",
"heading2": "#1e66f5",
"heading3": "#8839ef",
"heading4": "#df8e1d",
"heading5": "#40a02b",
"heading6": "#179299",
"link": "#1e66f5",
"codeBackground": "#e6e9ef",
"codeText": "#40a02b",
"blockquoteBorder": "#8839ef",
"blockquoteBackground": "#e6e9ef",
"highlightBackground": "#f9e2af",
"highlightText": "#4c4f69"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Catppuccin Mocha",
"colors": {
"background": "#1e1e2e",
"text": "#cdd6f4",
"heading1": "#cba6f7",
"heading2": "#89b4fa",
"heading3": "#89b4fa",
"heading4": "#74c7ec",
"heading5": "#74c7ec",
"heading6": "#74c7ec",
"link": "#89dceb",
"codeBackground": "#181825",
"codeText": "#a6e3a1",
"blockquoteBorder": "#cba6f7",
"blockquoteBackground": "#24243a",
"highlightBackground": "#f9e2af",
"highlightText": "#1e1e2e"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Dracula",
"colors": {
"background": "#282a36",
"text": "#f8f8f2",
"heading1": "#bd93f9",
"heading2": "#8be9fd",
"heading3": "#8be9fd",
"heading4": "#ff79c6",
"heading5": "#ff79c6",
"heading6": "#ff79c6",
"link": "#8be9fd",
"codeBackground": "#1e1f29",
"codeText": "#50fa7b",
"blockquoteBorder": "#bd93f9",
"blockquoteBackground": "#2e3042",
"highlightBackground": "#f1fa8c",
"highlightText": "#282a36"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Everforest Dark",
"colors": {
"background": "#2d353b",
"text": "#d3c6aa",
"heading1": "#e67e80",
"heading2": "#7fbbb3",
"heading3": "#d699b6",
"heading4": "#dbbc7f",
"heading5": "#a7c080",
"heading6": "#83c092",
"link": "#7fbbb3",
"codeBackground": "#272e33",
"codeText": "#a7c080",
"blockquoteBorder": "#d699b6",
"blockquoteBackground": "#343f44",
"highlightBackground": "#dbbc7f",
"highlightText": "#2d353b"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "GitHub Dark",
"colors": {
"background": "#0d1117",
"text": "#e6edf3",
"heading1": "#79c0ff",
"heading2": "#79c0ff",
"heading3": "#79c0ff",
"heading4": "#79c0ff",
"heading5": "#79c0ff",
"heading6": "#79c0ff",
"link": "#58a6ff",
"codeBackground": "#161b22",
"codeText": "#e6edf3",
"blockquoteBorder": "#3b434b",
"blockquoteBackground": "#141a22",
"highlightBackground": "#e3b341",
"highlightText": "#0d1117"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "GitHub Light",
"colors": {
"background": "#ffffff",
"text": "#1f2328",
"heading1": "#0550ae",
"heading2": "#0550ae",
"heading3": "#0550ae",
"heading4": "#0550ae",
"heading5": "#0550ae",
"heading6": "#0550ae",
"link": "#0969da",
"codeBackground": "#f6f8fa",
"codeText": "#1f2328",
"blockquoteBorder": "#d0d7de",
"blockquoteBackground": "#f6f8fa",
"highlightBackground": "#fff8c5",
"highlightText": "#1f2328"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Gruvbox Dark",
"colors": {
"background": "#282828",
"text": "#ebdbb2",
"heading1": "#fb4934",
"heading2": "#83a598",
"heading3": "#b8bb26",
"heading4": "#fabd2f",
"heading5": "#d3869b",
"heading6": "#8ec07c",
"link": "#83a598",
"codeBackground": "#1d2021",
"codeText": "#b8bb26",
"blockquoteBorder": "#d65d0e",
"blockquoteBackground": "#32302f",
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Gruvbox Light",
"colors": {
"background": "#fbf1c7",
"text": "#3c3836",
"heading1": "#cc241d",
"heading2": "#458588",
"heading3": "#98971a",
"heading4": "#d79921",
"heading5": "#b16286",
"heading6": "#689d6a",
"link": "#458588",
"codeBackground": "#f2e5bc",
"codeText": "#79740e",
"blockquoteBorder": "#d65d0e",
"blockquoteBackground": "#f2e5bc",
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Kanagawa",
"colors": {
"background": "#1f1f28",
"text": "#dcd7ba",
"heading1": "#c34043",
"heading2": "#7e9cd8",
"heading3": "#957fb8",
"heading4": "#e6c384",
"heading5": "#76946a",
"heading6": "#6a9589",
"link": "#7fb4ca",
"codeBackground": "#16161d",
"codeText": "#98bb6c",
"blockquoteBorder": "#957fb8",
"blockquoteBackground": "#2a2a37",
"highlightBackground": "#ff9e3b",
"highlightText": "#1f1f28"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Monokai",
"colors": {
"background": "#272822",
"text": "#f8f8f2",
"heading1": "#f92672",
"heading2": "#66d9ef",
"heading3": "#a6e22e",
"heading4": "#e6db74",
"heading5": "#fd971f",
"heading6": "#ae81ff",
"link": "#66d9ef",
"codeBackground": "#1e1f1c",
"codeText": "#a6e22e",
"blockquoteBorder": "#f92672",
"blockquoteBackground": "#2e2f2a",
"highlightBackground": "#e6db74",
"highlightText": "#272822"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Nord",
"colors": {
"background": "#2e3440",
"text": "#d8dee9",
"heading1": "#88c0d0",
"heading2": "#81a1c1",
"heading3": "#5e81ac",
"heading4": "#b48ead",
"heading5": "#a3be8c",
"heading6": "#ebcb8b",
"link": "#88c0d0",
"codeBackground": "#3b4252",
"codeText": "#a3be8c",
"blockquoteBorder": "#81a1c1",
"blockquoteBackground": "#3b4252",
"highlightBackground": "#ebcb8b",
"highlightText": "#2e3440"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "One Dark",
"colors": {
"background": "#282c34",
"text": "#abb2bf",
"heading1": "#e06c75",
"heading2": "#61afef",
"heading3": "#c678dd",
"heading4": "#e5c07b",
"heading5": "#98c379",
"heading6": "#56b6c2",
"link": "#61afef",
"codeBackground": "#21252b",
"codeText": "#98c379",
"blockquoteBorder": "#c678dd",
"blockquoteBackground": "#2c313c",
"highlightBackground": "#e5c07b",
"highlightText": "#282c34"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "One Light",
"colors": {
"background": "#fafafa",
"text": "#383a42",
"heading1": "#e45649",
"heading2": "#4078f2",
"heading3": "#a626a4",
"heading4": "#c18401",
"heading5": "#50a14f",
"heading6": "#0184bc",
"link": "#4078f2",
"codeBackground": "#f0f0f0",
"codeText": "#50a14f",
"blockquoteBorder": "#a626a4",
"blockquoteBackground": "#f0f0f0",
"highlightBackground": "#ffd700",
"highlightText": "#383a42"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Rosé Pine Dawn",
"colors": {
"background": "#faf4ed",
"text": "#575279",
"heading1": "#907aa9",
"heading2": "#286983",
"heading3": "#286983",
"heading4": "#56949f",
"heading5": "#56949f",
"heading6": "#56949f",
"link": "#286983",
"codeBackground": "#f2e9e1",
"codeText": "#d7827e",
"blockquoteBorder": "#907aa9",
"blockquoteBackground": "#f2e9e1",
"highlightBackground": "#f6c177",
"highlightText": "#575279"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Ros\u00e9 Pine",
"colors": {
"background": "#191724",
"text": "#e0def4",
"heading1": "#eb6f92",
"heading2": "#9ccfd8",
"heading3": "#c4a7e7",
"heading4": "#f6c177",
"heading5": "#31748f",
"heading6": "#ebbcba",
"link": "#9ccfd8",
"codeBackground": "#1f1d2e",
"codeText": "#9ccfd8",
"blockquoteBorder": "#c4a7e7",
"blockquoteBackground": "#1f1d2e",
"highlightBackground": "#f6c177",
"highlightText": "#191724"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Solarized Dark",
"colors": {
"background": "#002b36",
"text": "#839496",
"heading1": "#b58900",
"heading2": "#268bd2",
"heading3": "#268bd2",
"heading4": "#2aa198",
"heading5": "#2aa198",
"heading6": "#2aa198",
"link": "#268bd2",
"codeBackground": "#073642",
"codeText": "#859900",
"blockquoteBorder": "#b58900",
"blockquoteBackground": "#073642",
"highlightBackground": "#b58900",
"highlightText": "#002b36"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Solarized Light",
"colors": {
"background": "#fdf6e3",
"text": "#657b83",
"heading1": "#dc322f",
"heading2": "#268bd2",
"heading3": "#6c71c4",
"heading4": "#b58900",
"heading5": "#2aa198",
"heading6": "#859900",
"link": "#268bd2",
"codeBackground": "#eee8d5",
"codeText": "#859900",
"blockquoteBorder": "#6c71c4",
"blockquoteBackground": "#eee8d5",
"highlightBackground": "#b58900",
"highlightText": "#fdf6e3"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Tokyo Night Light",
"colors": {
"background": "#d5d6db",
"text": "#343b58",
"heading1": "#8c4351",
"heading2": "#34548a",
"heading3": "#5a4a78",
"heading4": "#8f5e15",
"heading5": "#33635c",
"heading6": "#0f4b6e",
"link": "#34548a",
"codeBackground": "#cbccd1",
"codeText": "#33635c",
"blockquoteBorder": "#5a4a78",
"blockquoteBackground": "#cbccd1",
"highlightBackground": "#f9c859",
"highlightText": "#343b58"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Tokyo Night",
"colors": {
"background": "#1a1b26",
"text": "#a9b1d6",
"heading1": "#f7768e",
"heading2": "#7aa2f7",
"heading3": "#bb9af7",
"heading4": "#e0af68",
"heading5": "#9ece6a",
"heading6": "#73daca",
"link": "#7aa2f7",
"codeBackground": "#16161e",
"codeText": "#9ece6a",
"blockquoteBorder": "#bb9af7",
"blockquoteBackground": "#1f2335",
"highlightBackground": "#e0af68",
"highlightText": "#1a1b26"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,37 @@
{
"name": "Zenburn",
"colors": {
"background": "#3f3f3f",
"text": "#dcdccc",
"heading1": "#cc9393",
"heading2": "#7cb8bb",
"heading3": "#8cd0d3",
"heading4": "#f0dfaf",
"heading5": "#dfaf8f",
"heading6": "#c3bf9f",
"link": "#93e0e3",
"codeBackground": "#2b2b2b",
"codeText": "#7f9f7f",
"blockquoteBorder": "#cc9393",
"blockquoteBackground": "#4f4f4f",
"highlightBackground": "#f0dfaf",
"highlightText": "#3f3f3f"
},
"fonts": {
"body": "System",
"heading": "System",
"code": "SF Mono"
},
"sizes": {
"baseFontSize": 14,
"h1Size": 28,
"h2Size": 23,
"h3Size": 20,
"h4Size": 18,
"h5Size": 16,
"h6Size": 15,
"codeFontSize": 13,
"lineHeight": 1.7
},
"customCSS": ""
}
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Validates Darin's version-one theme format before generating passive document CSS.</summary>
public sealed class ReaderTheme
{
private const int MaximumCharacters = 128 * 1024;
private static readonly string[] s_colors = ["background", "text", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "link", "codeBackground", "codeText", "blockquoteBorder", "blockquoteBackground", "highlightBackground", "highlightText"];
private static readonly string[] s_sizes = ["baseFontSize", "h1Size", "h2Size", "h3Size", "h4Size", "h5Size", "h6Size", "codeFontSize", "lineHeight"];
/// <summary>Retains only the validated display name and generated CSS.</summary>
private ReaderTheme(string name, string styles)
{
Name = name;
Styles = styles;
}
/// <summary>The display name, never an HTML or filesystem identifier.</summary>
public string Name { get; }
/// <summary>CSS safe for an inline style element; document resource policy still applies.</summary>
public string Styles { get; }
/// <summary>Accepts the original schema, including themes without an explicit version number.</summary>
public static ReaderTheme Parse(string json)
{
ArgumentNullException.ThrowIfNull(json);
if(json.Length > MaximumCharacters)
{
throw new JsonException("The theme exceeds the 128 Ki-character limit.");
}
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 });
JsonElement root = document.RootElement;
RequireObject(root);
if(root.TryGetProperty("schemaVersion", out JsonElement version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out int number) || number != 1))
{
throw new JsonException("Only theme schema version 1 is supported.");
}
string name = ReadString(root, "name");
if(string.IsNullOrWhiteSpace(name) || name.Length > 80 || name.Any(char.IsControl))
{
throw new JsonException("Theme names must contain 1–80 printable characters.");
}
string styles = BuildStyles(root);
return new ReaderTheme(name, styles);
}
/// <summary>Constructs variables only from validated colors, finite sizes and escaped font families.</summary>
private static string BuildStyles(JsonElement root)
{
var styles = new StringBuilder(":root {");
JsonElement colors = ReadObject(root, "colors");
foreach(string field in s_colors)
{
string value = ReadString(colors, field);
if(value.Length != 7 || value[0] != '#' || !value.AsSpan(1).ToString().All(char.IsAsciiHexDigit))
{
throw new JsonException("Theme colors must use six-digit hexadecimal notation.");
}
styles.Append("--").Append(field).Append(':').Append(value).Append(';');
}
AddSizes(styles, ReadObject(root, "sizes"));
JsonElement fonts = ReadObject(root, "fonts");
foreach(string field in new[] { "body", "heading", "code" })
{
styles.Append("--").Append(field).Append("Font:").Append(FontFamily(ReadString(fonts, field))).Append(';');
}
styles.Append('}').Append(BaseStyles);
if(root.TryGetProperty("customCSS", out _))
{
// CSS can style the page, but it cannot close the HTML style element.
styles.Append(ReadString(root, "customCSS").Replace("<", "\\3c ", StringComparison.Ordinal));
}
return styles.ToString();
}
/// <summary>Bounds font sizes and line height, rejecting non-finite or non-numeric values.</summary>
private static void AddSizes(StringBuilder styles, JsonElement sizes)
{
foreach(string field in s_sizes)
{
bool lineHeight = string.Equals(field, "lineHeight", StringComparison.Ordinal);
if(!sizes.TryGetProperty(field, out JsonElement element) || element.ValueKind != JsonValueKind.Number ||
!element.TryGetDouble(out double value) || !double.IsFinite(value) || value < (lineHeight ? 0.5 : 6) || value > (lineHeight ? 4 : 96))
{
throw new JsonException("Theme sizes must be finite and within the supported reading range.");
}
styles.Append("--").Append(field).Append(':').Append(value.ToString("R", CultureInfo.InvariantCulture));
styles.Append(lineHeight ? ";" : "px;");
}
}
/// <summary>Maps macOS defaults to Windows and escapes every custom font character as a CSS code point.</summary>
private static string FontFamily(string value)
{
if(string.IsNullOrWhiteSpace(value) || value.Length > 80 || value.Any(char.IsControl))
{
throw new JsonException("Font names must contain 1–80 printable characters.");
}
return value switch
{
"System" => "system-ui, sans-serif",
"SF Mono" => "Consolas, 'Cascadia Code', monospace",
_ => "'" + string.Concat(value.EnumerateRunes().Select(rune => "\\" + rune.Value.ToString("X", CultureInfo.InvariantCulture) + " ")) + "'",
};
}
/// <summary>Reports malformed required strings consistently as theme input errors.</summary>
private static string ReadString(JsonElement parent, string name)
{
if(!parent.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String)
{
throw new JsonException("The theme is missing a required string: " + name);
}
return value.GetString()!;
}
/// <summary>Requires each schema group to be an object.</summary>
private static JsonElement ReadObject(JsonElement parent, string name)
{
if(!parent.TryGetProperty(name, out JsonElement value))
{
throw new JsonException("The theme is missing a required object: " + name);
}
RequireObject(value);
return value;
}
/// <summary>Prevents wrong-shaped JSON from reaching property accessors.</summary>
private static void RequireObject(JsonElement element)
{
if(element.ValueKind != JsonValueKind.Object)
{
throw new JsonException("A theme schema group must be an object.");
}
}
private const string BaseStyles = """
html { background: var(--background); color: var(--text); }
body { max-width: 900px; margin: 0 auto; padding: 32px; overflow-wrap: anywhere;
font: var(--baseFontSize)/var(--lineHeight) var(--bodyFont); }
h1,h2,h3,h4,h5,h6 { font-family: var(--headingFont); line-height: 1.25; margin-top: 1.5em; }
h1 { color: var(--heading1); font-size: var(--h1Size); }
h2 { color: var(--heading2); font-size: var(--h2Size); }
h3 { color: var(--heading3); font-size: var(--h3Size); }
h4 { color: var(--heading4); font-size: var(--h4Size); }
h5 { color: var(--heading5); font-size: var(--h5Size); }
h6 { color: var(--heading6); font-size: var(--h6Size); }
pre { padding: 16px; overflow: auto; background: var(--codeBackground); border-radius: 6px; }
code,kbd { color: var(--codeText); font: var(--codeFontSize)/var(--lineHeight) var(--codeFont); }
table { border-collapse: collapse; } th,td { border: 1px solid var(--blockquoteBorder); padding: 6px 12px; }
blockquote { margin-left: 0; padding: 8px 20px; border-left: 4px solid var(--blockquoteBorder); background: var(--blockquoteBackground); }
img { max-width: 100%; height: auto; } a { color: var(--link); }
::selection { background: var(--highlightBackground); color: var(--highlightText); }
""";
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Themes;
/// <summary>Loads the original bundled themes from assembly resources, independent of the install directory.</summary>
public static class ThemeCatalog
{
private static readonly ReaderTheme[] s_builtIn = LoadBuiltIn();
/// <summary>The bundled themes sorted by display name.</summary>
public static IReadOnlyList<ReaderTheme> BuiltIn { get; } = Array.AsReadOnly(s_builtIn);
/// <summary>Restores a saved theme by name, falling back to Darin's default after removal or invalid settings.</summary>
public static ReaderTheme Find(string? name)
=> s_builtIn.FirstOrDefault(theme => string.Equals(theme.Name, name, StringComparison.Ordinal))
?? s_builtIn.Single(theme => string.Equals(theme.Name, "Catppuccin Mocha", StringComparison.Ordinal));
/// <summary>Parses the shipped JSON with the same validation used for user themes.</summary>
private static ReaderTheme[] LoadBuiltIn()
{
System.Reflection.Assembly assembly = typeof(ThemeCatalog).Assembly;
return assembly.GetManifestResourceNames()
.Where(name => name.Contains(".Themes.BuiltIn.", StringComparison.Ordinal) && name.EndsWith(".json", StringComparison.Ordinal))
.Select(name =>
{
using Stream stream = assembly.GetManifestResourceStream(name)!;
using var reader = new StreamReader(stream);
return ReaderTheme.Parse(reader.ReadToEnd());
})
.OrderBy(theme => theme.Name, StringComparer.Ordinal)
.ToArray();
}
}
+20 -13
View File
@@ -16,17 +16,6 @@ namespace MarkdownViewr.Windows;
internal sealed class DocumentBrowser
{
private const string SecurityHeaders = "Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nCache-Control: no-store";
private const string Styles = """
:root { color-scheme: light dark; font: 16px/1.65 system-ui, sans-serif; }
body { max-width: 900px; margin: 0 auto; padding: 32px; overflow-wrap: anywhere; }
h1,h2,h3,h4,h5,h6 { line-height: 1.25; margin-top: 1.5em; }
pre { padding: 16px; overflow: auto; background: light-dark(#f3f4f6,#22272e); border-radius: 6px; }
code,kbd { font-family: Consolas, monospace; }
table { border-collapse: collapse; } th,td { border: 1px solid #888; padding: 6px 12px; }
blockquote { margin-left: 0; padding-left: 20px; border-left: 4px solid #888; }
img { max-width: 100%; height: auto; } a { color: light-dark(#0969da,#58a6ff); }
""";
private readonly WebView2 _view;
private readonly Action<string> _status;
private CoreWebView2Environment? _environment;
@@ -38,14 +27,18 @@ internal sealed class DocumentBrowser
private bool _pageReady;
private bool _restoreViewport;
private bool _closed;
private Core.Themes.ReaderTheme _theme;
private string _sanitizedHtml = "";
private string _title = "MarkdownViewr";
/// <summary>Attaches a native status callback; the document itself cannot invoke host commands.</summary>
public DocumentBrowser(WebView2 view, Action<string> status)
public DocumentBrowser(WebView2 view, Action<string> status, Core.Themes.ReaderTheme theme)
{
_view = view;
_status = status;
_theme = theme;
}
@@ -87,16 +80,30 @@ internal sealed class DocumentBrowser
public void Show(DocumentFiles? files, string sanitizedHtml, string title, Core.Documents.DocumentViewport viewport = default)
{
_files = files;
_sanitizedHtml = sanitizedHtml;
_title = title;
_viewport = viewport;
_pageReady = false;
_restoreViewport = true;
_policy = new Core.Documents.DocumentResourcePolicy();
_html = Encoding.UTF8.GetBytes($"""<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{WebUtility.HtmlEncode(title)}</title><style>{Styles}</style></head><body>{sanitizedHtml}</body></html>""");
_html = Encoding.UTF8.GetBytes($"""<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{WebUtility.HtmlEncode(title)}</title><style>{_theme.Styles}</style></head><body>{sanitizedHtml}</body></html>""");
_view.CoreWebView2.Navigate(_policy.DocumentUri.AbsoluteUri);
}
/// <summary>Changes only the document appearance and preserves its current relative viewport.</summary>
public async Task ApplyThemeAsync(Core.Themes.ReaderTheme theme)
{
Core.Documents.DocumentViewport viewport = await CaptureViewportAsync();
_theme = theme;
if(!_closed)
{
Show(_files, _sanitizedHtml, _title, viewport);
}
}
/// <summary>Marks pending browser continuations inactive before closing the native surface.</summary>
public void Close()
{
@@ -27,6 +27,8 @@
<MenuFlyoutItem x:Name="ExitMenuItem" Text="Exit" Click="Exit_Click" />
</MenuBarItem>
<MenuBarItem Title="View">
<MenuFlyoutSubItem x:Name="DocumentThemesMenu" Text="Document theme" />
<MenuFlyoutSeparator />
<RadioMenuFlyoutItem x:Name="ThemeSystem" Text="System theme" GroupName="Theme" Tag="System" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeLight" Text="Light theme" GroupName="Theme" Tag="Light" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeDark" Text="Dark theme" GroupName="Theme" Tag="Dark" Click="Theme_Click" />
+41 -1
View File
@@ -7,6 +7,7 @@ using KofTwentyTwo.AppKit.Settings;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Storage.Pickers;
@@ -34,7 +35,8 @@ public sealed partial class MainWindow : Window, IDisposable
public MainWindow()
{
InitializeComponent();
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message);
_browser = new DocumentBrowser(DocumentView, message => StatusText.Text = message, Core.Themes.ThemeCatalog.Find(_settings.DocumentTheme));
CreateDocumentThemeMenu();
Title = AppIdentity.Info.DisplayName;
this.ResizeForDpi(1100, 760);
this.SetMinimumSize(640, 420);
@@ -284,6 +286,44 @@ public sealed partial class MainWindow : Window, IDisposable
/// <summary>Builds the original document theme choices without mixing them with the AppKit shell theme.</summary>
private void CreateDocumentThemeMenu()
{
string selected = Core.Themes.ThemeCatalog.Find(_settings.DocumentTheme).Name;
foreach(Core.Themes.ReaderTheme theme in Core.Themes.ThemeCatalog.BuiltIn)
{
var item = new RadioMenuFlyoutItem { Text = theme.Name, Tag = theme, GroupName = "DocumentTheme", IsChecked = string.Equals(selected, theme.Name, StringComparison.Ordinal) };
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(item, "DocumentTheme:" + theme.Name);
item.Click += DocumentTheme_Click;
DocumentThemesMenu.Items.Add(item);
}
}
/// <summary>Persists the document palette and redisplays the current snapshot at its reading position.</summary>
private async void DocumentTheme_Click(object sender, RoutedEventArgs e)
{
var theme = (Core.Themes.ReaderTheme)((FrameworkElement)sender).Tag;
_settings.DocumentTheme = theme.Name;
Save();
try
{
_browserInitialization ??= _browser.InitializeAsync();
await _browserInitialization;
await _browser.ApplyThemeAsync(theme);
}
catch(System.Runtime.InteropServices.COMException exception)
{
if(!_closed)
{
StatusText.Text = "The document theme could not be displayed.";
App.Log.Error("The document theme could not be displayed.", exception);
}
}
}
/// <summary>View, theme: applies the chosen theme to the window and saves it.</summary>
private void Theme_Click(object sender, RoutedEventArgs e)
{
@@ -66,7 +66,8 @@ public class AppSettingsTests
{
using var temp = new TempDirectory();
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
Assert.True(store.Save(new AppSettings { Theme = "Dark" }));
Assert.True(store.Save(new AppSettings { Theme = "Dark", DocumentTheme = "GitHub Light" }));
Assert.Equal("Dark", store.Load().Theme);
Assert.Equal("GitHub Light", store.Load().DocumentTheme);
}
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using MarkdownViewr.Windows.Core.Themes;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Exercises the original themes, schema errors and CSS/HTML boundary with hostile theme input.</summary>
public sealed class ReaderThemeTests
{
/// <summary>The complete bundled collection is usable and retains Darin's default.</summary>
[Fact]
public void BuiltIn_OriginalResources_LoadsTwentyThemes()
{
Assert.Equal(20, ThemeCatalog.BuiltIn.Count);
Assert.Equal("Catppuccin Mocha", ThemeCatalog.Find(null).Name);
Assert.Equal("Catppuccin Mocha", ThemeCatalog.Find("removed theme").Name);
Assert.Equal("GitHub Light", ThemeCatalog.Find("GitHub Light").Name);
Assert.Equal(ThemeCatalog.BuiltIn.Select(theme => theme.Name).Order(StringComparer.Ordinal), ThemeCatalog.BuiltIn.Select(theme => theme.Name), StringComparer.Ordinal);
Assert.All(ThemeCatalog.BuiltIn, theme => Assert.Contains("var(--background)", theme.Styles, StringComparison.Ordinal));
}
/// <summary>Color, font and size values become CSS variables using invariant numbers.</summary>
[Fact]
public void Parse_OriginalTheme_PreservesPaletteAndMapsFonts()
{
CultureInfo previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
var theme = ReaderTheme.Parse(OriginalJson());
Assert.Equal("Catppuccin Mocha", theme.Name);
Assert.Contains("--background:#1e1e2e;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--bodyFont:system-ui, sans-serif;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--codeFont:Consolas, 'Cascadia Code', monospace;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--baseFontSize:14px;", theme.Styles, StringComparison.Ordinal);
Assert.Contains("--lineHeight:1.7;", theme.Styles, StringComparison.Ordinal);
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
/// <summary>Custom font and CSS values cannot escape the containing style element or inject font declarations.</summary>
[Fact]
public void Parse_CustomStrings_EscapesHtmlAndFontDelimiters()
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
root["schemaVersion"] = 1;
root["fonts"]!["body"] = "A';} <script>";
root["customCSS"] = "body { color: red; } </style><script>alert(1)</script>";
string styles = ReaderTheme.Parse(root.ToJsonString()).Styles;
Assert.DoesNotContain("<", styles, StringComparison.Ordinal);
Assert.Contains("--bodyFont:'\\41 \\27 \\3B \\7D \\20 \\3C \\73 \\63 \\72 \\69 \\70 \\74 \\3E ';", styles, StringComparison.Ordinal);
Assert.Contains("\\3c /style>", styles, StringComparison.Ordinal);
}
/// <summary>Optional custom CSS is not required by older theme files.</summary>
[Fact]
public void Parse_NoCustomCss_LoadsTheme()
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
root.AsObject().Remove("customCSS");
Assert.Equal("Catppuccin Mocha", ReaderTheme.Parse(root.ToJsonString()).Name);
}
/// <summary>Malformed JSON, oversized files and non-object roots fail before generating CSS.</summary>
[Theory]
[InlineData("null")]
[InlineData("[]")]
[InlineData("{")]
public void Parse_InvalidRoot_RejectsInput(string json) => Assert.ThrowsAny<JsonException>(() => ReaderTheme.Parse(json));
/// <summary>Null and oversized input are rejected before parser work.</summary>
[Fact]
public void Parse_OutsideSizeContract_RejectsInput()
{
Assert.Throws<ArgumentNullException>(() => ReaderTheme.Parse(null!));
Assert.Throws<JsonException>(() => ReaderTheme.Parse(new string(' ', 128 * 1024 + 1)));
}
/// <summary>Required objects, strings, version, numeric bounds and colors cannot carry invalid schema values.</summary>
[Theory]
[InlineData("schemaVersion", "2")]
[InlineData("schemaVersion", "1.5")]
[InlineData("schemaVersion", "\"1\"")]
[InlineData("name", "\"\"")]
[InlineData("name", "\"\\n\"")]
[InlineData("name", "null")]
[InlineData("colors", "null")]
[InlineData("colors.background", "\"red\"")]
[InlineData("colors.background", "\"#zzzzzz\"")]
[InlineData("colors.background", "\";color:red\"")]
[InlineData("fonts.body", "\"\"")]
[InlineData("fonts.code", "\"\\n\"")]
[InlineData("sizes.baseFontSize", "5")]
[InlineData("sizes.h1Size", "97")]
[InlineData("sizes.h2Size", "1e400")]
[InlineData("sizes.h3Size", "\"20\"")]
[InlineData("sizes.lineHeight", "0.4")]
[InlineData("sizes.lineHeight", "4.1")]
[InlineData("customCSS", "false")]
public void Parse_InvalidProperty_RejectsInput(string path, string json)
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent[parts[^1]] = JsonNode.Parse(json);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
/// <summary>Missing fields never reach unchecked JSON property access.</summary>
[Theory]
[InlineData("name")]
[InlineData("colors")]
[InlineData("fonts")]
[InlineData("sizes")]
[InlineData("colors.text")]
[InlineData("sizes.h4Size")]
public void Parse_MissingProperty_RejectsInput(string path)
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent.AsObject().Remove(parts[^1]);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
/// <summary>Names and font families cannot produce unbounded CSS or inaccessible labels.</summary>
[Theory]
[InlineData("name")]
[InlineData("fonts.body")]
public void Parse_OverlongLabel_RejectsInput(string path)
{
JsonNode root = JsonNode.Parse(OriginalJson())!;
string[] parts = path.Split('.');
JsonNode parent = parts.Length == 1 ? root : root[parts[0]]!;
parent[parts[^1]] = new string('A', 81);
Assert.Throws<JsonException>(() => ReaderTheme.Parse(root.ToJsonString()));
}
/// <summary>Loads the exact bundled reference instead of maintaining a duplicate test schema.</summary>
private static string OriginalJson()
{
using Stream stream = typeof(ReaderTheme).Assembly.GetManifestResourceStream("MarkdownViewr.Windows.Core.Themes.BuiltIn.catppuccin-mocha.json")!;
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
@@ -89,6 +89,22 @@ public class AppTests
/// <summary>The original document themes persist independently and keep the displayed document intact.</summary>
[Fact]
public void DocumentTheme_ChangedChoice_PersistsAndRetainsDocument()
{
using var session = new AppSession(document: "# Theme acceptance");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Theme acceptance")), "the document heading", TimeSpan.FromSeconds(30));
session.InvokeMenuItem("View", "DocumentThemesMenu");
session.ClickElement("DocumentTheme:GitHub Light");
session.WaitFor(
() => session.ReadSettings().Contains("\"DocumentTheme\": \"GitHub Light\"", StringComparison.Ordinal) ? session.MainWindow : null,
"the selected document theme to be saved");
session.WaitFor(() => session.FindInApp(cf => cf.ByName("Theme acceptance")), "the document after its theme changes");
Assert.DoesNotContain("\"Theme\": \"Light\"", session.ReadSettings(), StringComparison.Ordinal);
}
/// <summary>Updates: a build that was not installed by Velopack says updates need an install.</summary>
[Fact]
public void CheckForUpdates_InALooseBuild_SaysUpdatesNeedAnInstall()