diff --git a/README.md b/README.md index c1d36d5..5eafbdf 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Read .md files and Mermaid diagrams offline, without opening an editor. About 74 [Website](https://abooodbah.github.io/leanmark/) · [Microsoft Store](https://apps.microsoft.com/detail/9MSS4WKJ22ZV) · `winget install abooodbah.LeanMark` · `choco install leanmark` · [All Lean apps](https://abooodbah.github.io/) +[![winget version](https://img.shields.io/winget/v/abooodbah.LeanMark?label=winget)](https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/abooodbah/LeanMark) [![Microsoft Store](https://img.shields.io/badge/Microsoft%20Store-9MSS4WKJ22ZV-0078d4)](https://apps.microsoft.com/detail/9MSS4WKJ22ZV) [![License: MIT](https://img.shields.io/github/license/abooodbah/leanmark)](LICENSE) [![GitHub release downloads](https://img.shields.io/github/downloads/abooodbah/leanmark/total)](https://github.com/abooodbah/leanmark/releases) + **Read Markdown and Mermaid diagrams without opening an IDE. Everything renders offline, with no Electron and no telemetry.** @@ -98,7 +100,9 @@ Security. Later launches open normally. ### GitHub Releases Windows, Linux (.deb), and macOS builds are also on the -[Releases](https://github.com/abooodbah/leanmark/releases) page. +[Releases](https://github.com/abooodbah/leanmark/releases) page. To hear about +new versions, follow the [releases feed](https://github.com/abooodbah/leanmark/releases.atom) +in a feed reader. ### Android (closed test) diff --git a/site/index.html b/site/index.html index 20c08a9..2e1a62d 100644 --- a/site/index.html +++ b/site/index.html @@ -13,6 +13,7 @@ + @@ -32,9 +33,17 @@ "softwareVersion": "0.7.1", "license": "https://opensource.org/license/mit", "downloadUrl": "https://github.com/abooodbah/leanmark/releases/latest", - "codeRepository": "https://github.com/abooodbah/leanmark", "description": "A free, read-only Markdown viewer for Windows, Linux, and macOS with offline GitHub-flavored Markdown and Mermaid rendering.", "url": "https://abooodbah.github.io/leanmark/", + "screenshot": "https://abooodbah.github.io/leanmark/assets/leanmark-window.png", + "image": "https://abooodbah.github.io/leanmark/assets/social-preview.png", + "sameAs": [ + "https://github.com/abooodbah/leanmark", + "https://apps.microsoft.com/detail/9MSS4WKJ22ZV", + "https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/abooodbah/LeanMark", + "https://community.chocolatey.org/packages/leanmark", + "https://github.com/abooodbah/homebrew-tap" + ], "offers": { "@type": "Offer", "price": "0", @@ -42,7 +51,8 @@ }, "author": { "@type": "Person", - "name": "Abdulfatah Bahbouh" + "name": "Abdulfatah Bahbouh", + "url": "https://abooodbah.github.io/" } } @@ -156,7 +166,7 @@

A document opens as a document.

  • Live refresh after the file changes on disk
  • -
    +
    architecture.md local file @@ -176,7 +186,7 @@

    A document opens as a document.

    -
    +
    LeanMark rendering flow A Markdown file is parsed by MD4C, shown in a trusted local view, and diagrams are rendered by the bundled Mermaid library only when needed. @@ -349,6 +359,15 @@

    Native packages for three desktops.

    With Chocolatey, from an administrator shell choco install leanmark -y
    +
    + With Scoop, adding the Lean bucket first + scoop bucket add lean https://github.com/abooodbah/scoop-bucket + scoop install lean/leanmark +
    +
    + On macOS, with Homebrew + brew install --cask abooodbah/tap/leanmark +

    LeanMark is also in the Microsoft Store. @@ -385,7 +404,7 @@

    Reading Markdown files on the desktop

    How do I open an .md file on Windows?

    -

    Install LeanMark from the Microsoft Store, with winget install abooodbah.LeanMark, or from GitHub Releases, then open the file. Headings, tables, task lists, local images, and Mermaid diagrams render as a formatted page.

    +

    Install LeanMark from the Microsoft Store, with winget install abooodbah.LeanMark, or from GitHub Releases, then open the file. Headings, tables, task lists, local images, and Mermaid diagrams render as a formatted page. How to open .md files on Windows compares LeanMark with Notepad, VS Code, and browser extensions.

    How do I read a Markdown file that Claude, ChatGPT, or a coding agent saved?

    diff --git a/site/open-md-files-on-windows/index.html b/site/open-md-files-on-windows/index.html new file mode 100644 index 0000000..844058c --- /dev/null +++ b/site/open-md-files-on-windows/index.html @@ -0,0 +1,230 @@ + + + + + + + + How to open .md files on Windows: Notepad, VS Code, LeanMark + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +

    Guide

    +

    How to open .md files on Windows

    +

    + A .md file is plain text written in Markdown. Any text + editor opens it, but you only see the headings, tables, and diagrams + as a formatted page in a program that renders Markdown. These are + the common ways to do that on Windows, and what each one leaves out. +

    + +
    +
    +
    + +
    +
    +
    +

    Your options

    +

    Four ways to read a Markdown file

    +
    +
    +
    +

    Notepad

    +

    Notepad on Windows 11 can show Markdown with formatting and switch between the formatted view and the Markdown source, as Microsoft describes in its Notepad formatting announcement. That announcement lists bold, italic, links, lists, and headings; Mermaid diagrams are not among them. Notepad is an editor, so the file can be changed by accident.

    +
    +
    +

    Visual Studio Code

    +

    VS Code has a built-in Markdown preview: open the file and press Ctrl+Shift+V, or Ctrl+K V for the preview beside the source. Its Markdown documentation says the preview renders Mermaid diagrams. It is a full code editor, so reading one file means opening an editor first.

    +
    +
    +

    A browser extension

    +

    Markdown viewer extensions for Edge, Chrome, and Firefox render a .md file that you open in the browser. In Edge and Chrome the extension can read a file on your disk only after you turn on its access to file URLs in the extension's settings. Support for tables and Mermaid differs from one extension to the next.

    +
    +
    +

    A Markdown viewer such as LeanMark

    +

    LeanMark opens the file as a formatted, read-only page with GitHub-flavored Markdown tables, task lists, local images, and Mermaid diagrams rendered offline. It cannot edit, and it does not load remote images. The GitHub ZIP is unsigned; the Microsoft Store build is signed by Microsoft.

    +
    +
    +
    +
    + +
    +
    +
    +

    Files from AI tools

    +

    Markdown saved by ChatGPT, Claude, or a coding agent

    +

    + Chat assistants and coding agents often hand you Markdown: a + downloaded answer, a plan, or a README written into a project + folder. Opening it in a viewer shows the tables and diagrams instead + of the raw symbols. +

    +
    +
    +
    +

    Keep it open while it is written

    +

    When the file changes on disk, LeanMark refreshes the page and keeps your approximate reading position, so you can watch an agent's plan or report grow without reopening it.

    +
    +
    +

    Nothing in the file runs

    +

    Raw HTML in a document is not run, a strict Content Security Policy limits scripts, and remote images are not loaded. Links to websites open in your browser only after you click them.

    +
    +
    +
    +
    + +
    +
    +
    +

    Double-click to read

    +

    Make .md files open in your viewer

    +

    + Right-click a .md file, choose Open with, then Choose + another app, pick the program, and select Always. You can change it + later under Settings, Apps, Default apps. +

    +

    + For LeanMark, the optional installer inside the GitHub ZIP adds it + to Open With and to Default Apps for the current user. LeanMark + opens .md, .markdown, .mdown, + and .mkd files. +

    +
    +
    +
    + +
    +
    +
    +

    Measured

    +

    What LeanMark costs to open

    +

    + Median of ten warm launches of LeanMark 0.3.0 with a simple + document, counting LeanMark and every WebView2 process it starts. +

    +

    + Measured on Windows 11 Pro build 26200 with an Intel Core + i7-13620H, 15.6 GiB of RAM, and WebView2 Runtime 153. Hardware, + system state, and document complexity change the result. Linux and + macOS are not measured, and no comparison with other programs is + claimed. +

    + + Read the full measurement + + +
    +
    +
    +
    74.15 ms
    +
    Native window visible, simple document, 0.3.0
    +
    +
    +
    96.79 MiB
    +
    Peak private memory, full process tree, 0.3.0
    +
    +
    +
    +
    + +
    +
    +
    +

    Install

    +

    Get LeanMark on Windows

    +
    +
    + With winget + winget install abooodbah.LeanMark +
    +
    + From the Microsoft Store, with winget + winget install --id 9MSS4WKJ22ZV --source msstore +
    +
    + With Chocolatey, from an administrator shell + choco install leanmark -y +
    +
    + With Scoop, adding the Lean bucket first + scoop bucket add lean https://github.com/abooodbah/scoop-bucket + scoop install lean/leanmark +
    +

    + Builds for Linux and macOS are on the + releases page. + LeanMark is MIT licensed and free. +

    +
    +
    +
    + + + + diff --git a/site/sitemap.xml b/site/sitemap.xml index 436c459..2c212a6 100644 --- a/site/sitemap.xml +++ b/site/sitemap.xml @@ -2,6 +2,10 @@ https://abooodbah.github.io/leanmark/ - 2026-11-06 + 2026-11-07 + + + https://abooodbah.github.io/leanmark/open-md-files-on-windows/ + 2026-11-07 diff --git a/tests/Invoke-SiteBrowserSmoke.mjs b/tests/Invoke-SiteBrowserSmoke.mjs index eb1b726..b47f704 100644 --- a/tests/Invoke-SiteBrowserSmoke.mjs +++ b/tests/Invoke-SiteBrowserSmoke.mjs @@ -70,8 +70,9 @@ async function startStaticServer(siteRoot) { const requestUrl = new URL(request.url || "/", "http://127.0.0.1/"); let pathname = decodeURIComponent(requestUrl.pathname); - if (pathname === "/") { - pathname = "/index.html"; + // A folder URL is served as that folder's index.html, as on Pages. + if (pathname.endsWith("/")) { + pathname += "index.html"; } const candidate = resolve(siteRoot, "." + pathname.split("/").join(sep)); if (candidate !== siteRoot && !candidate.startsWith(rootPrefix)) { @@ -536,51 +537,65 @@ async function main() { { name: "tablet", width: 820, height: 1000, scheme: "light" }, { name: "mobile", width: 390, height: 844, scheme: "dark" }, ]; + // The landing page and the task page share the header, buttons, and + // stylesheet, so the same audit applies to both. + const pages = [ + { name: "", path: "" }, + { name: "task page ", path: "open-md-files-on-windows/" }, + ]; - for (const viewport of viewports) { - await client.send("Emulation.setDeviceMetricsOverride", { - width: viewport.width, - height: viewport.height, - deviceScaleFactor: 1, - mobile: viewport.width <= 390, - screenWidth: viewport.width, - screenHeight: viewport.height, - }); - await client.send("Emulation.setEmulatedMedia", { - features: [{ - name: "prefers-color-scheme", - value: viewport.scheme, - }], - }); - await client.send("Page.navigate", { - url: staticServer.url + "?viewport=" + viewport.name, - }); - await waitForDocument(client); - const audit = await evaluate(client, PAGE_AUDIT_EXPRESSION); - assertAudit(viewport.name, viewport, audit); - if (captureRoot) { - const screenshot = await client.send("Page.captureScreenshot", { - captureBeyondViewport: true, - format: "png", - fromSurface: true, + for (const page of pages) { + for (const viewport of viewports) { + await client.send("Emulation.setDeviceMetricsOverride", { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: 1, + mobile: viewport.width <= 390, + screenWidth: viewport.width, + screenHeight: viewport.height, }); - await fs.writeFile( - join(captureRoot, "site-" + viewport.name + ".png"), - Buffer.from(screenshot.data, "base64"), - ); + await client.send("Emulation.setEmulatedMedia", { + features: [{ + name: "prefers-color-scheme", + value: viewport.scheme, + }], + }); + await client.send("Page.navigate", { + url: staticServer.url + page.path + "?viewport=" + viewport.name, + }); + await waitForDocument(client); + const audit = await evaluate(client, PAGE_AUDIT_EXPRESSION); + assertAudit(page.name + viewport.name, viewport, audit); + if (captureRoot) { + const screenshot = await client.send("Page.captureScreenshot", { + captureBeyondViewport: true, + format: "png", + fromSurface: true, + }); + await fs.writeFile( + join( + captureRoot, + "site-" + (page.path ? "task-" : "") + viewport.name + ".png", + ), + Buffer.from(screenshot.data, "base64"), + ); + } } - } - const accessibility = await client.send("Accessibility.getFullAXTree"); - const interactiveWithoutName = accessibility.nodes.filter((node) => { - const role = node.role && node.role.value; - const name = node.name && node.name.value; - return (role === "link" || role === "button") && !String(name || "").trim(); - }); - if (interactiveWithoutName.length > 0) { - fail("The accessibility tree contains an unnamed link or button."); + const accessibility = await client.send("Accessibility.getFullAXTree"); + const interactiveWithoutName = accessibility.nodes.filter((node) => { + const role = node.role && node.role.value; + const name = node.name && node.name.value; + return (role === "link" || role === "button") && !String(name || "").trim(); + }); + if (interactiveWithoutName.length > 0) { + fail("The " + (page.name || "landing page ") + + "accessibility tree contains an unnamed link or button."); + } + console.log( + "PASS " + page.name + "accessibility tree exposes names for every link and button", + ); } - console.log("PASS accessibility tree exposes names for every link and button"); if (browserErrors.length > 0) { fail("Browser console or resource errors: " + browserErrors.join(" | ")); diff --git a/tests/Test-Site.ps1 b/tests/Test-Site.ps1 index 90b284e..6ec0ab8 100644 --- a/tests/Test-Site.ps1 +++ b/tests/Test-Site.ps1 @@ -102,8 +102,59 @@ Assert-Equal ([string]$structured.'@type') 'SoftwareApplication' 'JSON-LD must d Assert-Equal ([string]$structured.softwareVersion) $version 'JSON-LD softwareVersion must match package.json.' Assert-Equal ([string]$structured.offers.price) '0' 'JSON-LD must offer LeanMark at price 0.' Assert-Equal ([string]$structured.author.name) 'Abdulfatah Bahbouh' 'JSON-LD must name the author.' +function Get-JsonProperty { + param($Object, [string]$Name) + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + $property.Value +} +Assert-Equal ([string](Get-JsonProperty $structured.author 'url')) 'https://abooodbah.github.io/' 'JSON-LD author must link the Lean Apps page.' +# codeRepository belongs to SoftwareSourceCode, not SoftwareApplication; the +# repository and the package listings go in sameAs instead. +Assert-True ($null -eq $structured.PSObject.Properties['codeRepository']) 'JSON-LD must not use codeRepository on a SoftwareApplication.' +# There are no real ratings or reviews; never claim any. +Assert-NotMatch $jsonLd.Groups[1].Value '(?i)aggregateRating|"review' 'JSON-LD must not carry ratings or reviews.' +$sameAs = @(Get-JsonProperty $structured 'sameAs' | ForEach-Object { [string]$_ }) +foreach ($listing in @( + 'https://github.com/abooodbah/leanmark', + 'https://apps.microsoft.com/detail/9MSS4WKJ22ZV', + 'https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/abooodbah/LeanMark', + 'https://community.chocolatey.org/packages/leanmark', + 'https://github.com/abooodbah/homebrew-tap')) { + Assert-True ($sameAs -contains $listing) "JSON-LD sameAs is missing $listing" +} +Assert-Equal ([string](Get-JsonProperty $structured 'screenshot')) 'https://abooodbah.github.io/leanmark/assets/leanmark-window.png' 'JSON-LD screenshot must be the real product screenshot on the page.' +Assert-Equal ([string](Get-JsonProperty $structured 'image')) 'https://abooodbah.github.io/leanmark/assets/social-preview.png' 'JSON-LD image must be the social preview.' +Assert-Match $html '' 'The page must point feed readers to the releases feed.' +Assert-Match $html 'scoop install lean/leanmark' 'The install section must give the Scoop command.' +Assert-Match $html 'brew install --cask abooodbah/tap/leanmark' 'The install section must give the Homebrew command.' +Assert-Match $html 'href="open-md-files-on-windows/"' 'The landing page must link the task page.' Write-TestPass 'semantic metadata, release CTA, and script-free markup' +# The task page: one plain-HTML page that answers "how do I open .md files on +# Windows" with every real option, LeanMark being one of them. +$taskRelative = 'open-md-files-on-windows/index.html' +$taskPath = Join-Path $resolvedSite $taskRelative +Assert-True (Test-Path -LiteralPath $taskPath -PathType Leaf) "Task page is missing: $taskRelative" +$taskHtml = Read-TextFile $taskPath +Assert-Match $taskHtml '' 'The task page language must be explicit.' +Assert-Equal ([regex]::Matches($taskHtml, '(?is))').Count) 1 'The task page must have exactly one H1.' +Assert-Match $taskHtml '[^<]*\.md files on Windows[^<]*' 'The task page title must name the task.' +Assert-Match $taskHtml '' 'The task page must use the site stylesheet.' +Assert-Match $taskHtml '' 'The task page needs the skip link.' +Assert-Match $taskHtml '' 'The task page needs a main landmark.' +Assert-Match $taskHtml 'href="\.\./"' 'The task page must link back to the LeanMark page.' +Assert-Match $taskHtml 'href="https://abooodbah\.github\.io/"' 'The task page must link the Lean Apps hub.' +Assert-Match $taskHtml '74\.15(?:\s|)*ms' 'The task page must quote the measured window time as published.' +Assert-Match $taskHtml '96\.79(?:\s|)*MiB' 'The task page must quote the measured memory as published.' +Assert-Match $taskHtml '0\.3\.0' 'Measured numbers must keep the version they were measured on.' +Assert-NotMatch $taskHtml '(?is)]+\bsrc\s*=' 'The task page must not execute external or bundled JavaScript.' +Assert-NotMatch $taskHtml '(?i)javascript\s*:' 'The task page must not contain javascript: URLs.' +Assert-NotMatch $taskHtml '(?i)aggregateRating' 'The task page must not carry ratings.' +Write-TestPass 'task page metadata, landmarks, and sourced numbers' + Assert-Match $css ':focus-visible' 'Visible keyboard focus styling is required.' Assert-Match $css '@media\s*\(prefers-reduced-motion:\s*reduce\)' 'Reduced-motion support is required.' Assert-Match $css '@media\s*\(prefers-color-scheme:\s*dark\)' 'Dark color-scheme support is required.' @@ -116,20 +167,28 @@ Write-TestPass 'responsive, motion-safe, keyboard-visible local styling' $sitePrefix = $resolvedSite.TrimEnd([char[]]@('\', '/')) + [System.IO.Path]::DirectorySeparatorChar -$references = [regex]::Matches($html, '(?is)(?:href|src)\s*=\s*"([^"]+)"') -foreach ($reference in $references) { - $value = $reference.Groups[1].Value - if ($value.StartsWith('#') -or - $value.StartsWith('https://') -or - $value.StartsWith('http://') -or - $value.StartsWith('mailto:') -or - $value.StartsWith('data:')) { - continue +foreach ($page in @( + @{ Html = $html; Directory = $resolvedSite }, + @{ Html = $taskHtml; Directory = (Split-Path -Parent $taskPath) })) { + $references = [regex]::Matches($page.Html, '(?is)(?:href|src)\s*=\s*"([^"]+)"') + foreach ($reference in $references) { + $value = $reference.Groups[1].Value + if ($value.StartsWith('#') -or + $value.StartsWith('https://') -or + $value.StartsWith('http://') -or + $value.StartsWith('mailto:') -or + $value.StartsWith('data:')) { + continue + } + $relativePath = ($value -split '[?#]', 2)[0] + # A link to a folder is served as that folder's index.html. + if ($relativePath.EndsWith('/')) { + $relativePath += 'index.html' + } + $candidate = [System.IO.Path]::GetFullPath((Join-Path $page.Directory $relativePath)) + Assert-True ($candidate.StartsWith($sitePrefix, [StringComparison]::OrdinalIgnoreCase)) "A local site reference escapes the site root: $value" + Assert-True (Test-Path -LiteralPath $candidate -PathType Leaf) "A local site reference is broken: $value" } - $relativePath = ($value -split '[?#]', 2)[0] - $candidate = [System.IO.Path]::GetFullPath((Join-Path $resolvedSite $relativePath)) - Assert-True ($candidate.StartsWith($sitePrefix, [StringComparison]::OrdinalIgnoreCase)) "A local site reference escapes the site root: $value" - Assert-True (Test-Path -LiteralPath $candidate -PathType Leaf) "A local site reference is broken: $value" } Write-TestPass 'all local page references resolve inside the deployable site' @@ -152,7 +211,18 @@ try { } catch { throw "sitemap.xml is not well-formed XML: $($_.Exception.Message)" } -Assert-Match ([string]$sitemap.urlset.url.lastmod) '^\d{4}-\d{2}-\d{2}$' 'sitemap.xml must give a lastmod date.' +$sitemapUrls = @($sitemap.urlset.url) +foreach ($entry in $sitemapUrls) { + $lastmod = $entry['lastmod'] + $lastmodText = if ($null -eq $lastmod) { '' } else { [string]$lastmod.InnerText } + Assert-Match $lastmodText '^\d{4}-\d{2}-\d{2}$' ("sitemap.xml must give a lastmod date for {0}." -f $entry.loc) +} +$sitemapLocs = @($sitemapUrls | ForEach-Object { [string]$_.loc }) +foreach ($loc in @( + 'https://abooodbah.github.io/leanmark/', + 'https://abooodbah.github.io/leanmark/open-md-files-on-windows/')) { + Assert-True ($sitemapLocs -contains $loc) "sitemap.xml must list $loc" +} $manifest = Get-Content -LiteralPath (Join-Path $resolvedSite 'site.webmanifest') -Raw | ConvertFrom-Json Assert-Equal ([string]$manifest.name) 'LeanMark' 'Web app manifest name changed unexpectedly.'