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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Glance.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
7EE0465C246EB5DE006172D9 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE0465B246EB5DE006172D9 /* AppIcon.swift */; };
7EE10F84244F679B007214D8 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE10F82244F6774007214D8 /* Log.swift */; };
7EE10F85244F696F007214D8 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE10F82244F6774007214D8 /* Log.swift */; };
8FFE4252F0E1494C9A1A6003 /* markdown-mermaid.min.js in Resources */ = {isa = PBXBuildFile; fileRef = 066A70F7C2734C3AA484CF2F /* markdown-mermaid.min.js */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -164,6 +165,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
066A70F7C2734C3AA484CF2F /* markdown-mermaid.min.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = "markdown-mermaid.min.js"; sourceTree = "<group>"; };
102D56C426206A36000B5C0E /* MainVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainVC.swift; sourceTree = "<group>"; };
103654F32F72623D006F2538 /* SevenZipPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SevenZipPreview.swift; sourceTree = "<group>"; };
10779C0D26218B2B008A903C /* GlanceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GlanceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -550,6 +552,7 @@
isa = PBXGroup;
children = (
7EB3643D244B03CC00D7F96F /* markdown-main.css */,
066A70F7C2734C3AA484CF2F /* markdown-mermaid.min.js */,
);
path = markdown;
sourceTree = "<group>";
Expand Down Expand Up @@ -791,6 +794,7 @@
7EB36444244B03CC00D7F96F /* KaTeX_AMS-Regular.ttf in Resources */,
7EB36464244B03CC00D7F96F /* KaTeX_AMS-Regular.woff in Resources */,
7EB36482244B03CC00D7F96F /* markdown-main.css in Resources */,
8FFE4252F0E1494C9A1A6003 /* markdown-mermaid.min.js in Resources */,
7EB36462244B03CC00D7F96F /* KaTeX_Size4-Regular.woff in Resources */,
7EB3644A244B03CC00D7F96F /* KaTeX_Caligraphic-Bold.woff2 in Resources */,
7EB3644D244B03CC00D7F96F /* KaTeX_Caligraphic-Regular.woff in Resources */,
Expand Down
1 change: 1 addition & 0 deletions HTMLConverter/htmlconverter.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var markdownFrontMatterRegex = regexp.MustCompile(`^---\n([\s\S]*?)\n---\n`)
var markdownParser = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
Mermaid,
highlighting.NewHighlighting(
highlighting.WithFormatOptions(
htmlFormatter.WithClasses(true),
Expand Down
101 changes: 101 additions & 0 deletions HTMLConverter/htmlconverter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,107 @@ func TestConvertMarkdownToHTMLWithSyntaxHighlighting(t *testing.T) {
assert.True(t, strings.Contains(actual, `<pre tabindex="0" class="chroma">`))
}

func TestConvertMarkdownToHTMLWithMermaid(t *testing.T) {
source := "# Diagram\n\n```mermaid\nflowchart LR\n A --> B\n```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
assert.True(t, strings.Contains(actual, `<pre class="mermaid" data-glance-mermaid="1">`))
assert.True(t, strings.Contains(actual, "flowchart LR"))
// `>` must be HTML-escaped so the source survives until mermaid reads it
// from the DOM as text content
assert.True(t, strings.Contains(actual, "A --&gt; B"))
// The mermaid block must not have been routed through chroma syntax highlighting
assert.False(t, strings.Contains(actual, `class="chroma"`))
}

func TestConvertMarkdownToHTMLWithMermaidAndCodeBlock(t *testing.T) {
source := "```mermaid\nflowchart LR\n A --> B\n```\n\n```js\nconst x = 1;\n```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
// mermaid block bypasses chroma
assert.True(t, strings.Contains(actual, `<pre class="mermaid" data-glance-mermaid="1">`))
// regular code block still gets chroma syntax highlighting
assert.True(t, strings.Contains(actual, `<pre tabindex="0" class="chroma">`))
}

func TestConvertMarkdownToHTMLWithMermaidUppercase(t *testing.T) {
// Language matching must be case-insensitive (chroma is too)
source := "```Mermaid\nflowchart LR\n A --> B\n```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
assert.True(t, strings.Contains(actual, `data-glance-mermaid="1"`))
assert.False(t, strings.Contains(actual, `class="chroma"`))
}

func TestConvertMarkdownToHTMLWithMermaidExtraInfoArgs(t *testing.T) {
// Goldmark splits the info string at the first space, so extra args
// after the language identifier must not break detection
source := "```mermaid foo bar\nflowchart LR\n A --> B\n```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
assert.True(t, strings.Contains(actual, `data-glance-mermaid="1"`))
}

func TestConvertMarkdownToHTMLWithMermaidEmpty(t *testing.T) {
source := "```mermaid\n```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
// Empty blocks still emit the sentinel; mermaid will render nothing,
// but the block must not fall back to chroma
assert.True(t, strings.Contains(actual, `data-glance-mermaid="1"`))
assert.False(t, strings.Contains(actual, `class="chroma"`))
}

func TestConvertMarkdownToHTMLWithMermaidHTMLEscaping(t *testing.T) {
// Diagram syntax containing HTML-special chars in labels must survive
// as HTML entities so mermaid reads the original via textContent
source := "```mermaid\nflowchart LR\n A[\"<b>label</b>\"] --> B\n```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
assert.True(t, strings.Contains(actual, `&lt;b&gt;label&lt;/b&gt;`))
assert.False(t, strings.Contains(actual, `<b>label</b>`))
}

func TestConvertMarkdownToHTMLWithMermaidInBlockquote(t *testing.T) {
// Mermaid blocks nested inside other block-level constructs must
// still be rewritten (parent.ReplaceChild handles non-document parents)
source := "> ```mermaid\n> flowchart LR\n> A --> B\n> ```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
assert.True(t, strings.Contains(actual, `data-glance-mermaid="1"`))
}

func TestConvertMarkdownToHTMLWithLiteralMermaidPreInCode(t *testing.T) {
// The Swift side detects mermaid blocks via the data-glance-mermaid
// sentinel. Goldmark must NEVER emit that sentinel for content that's
// not a real mermaid block — even when the user pastes the literal
// HTML inside an inline-code span, a fenced code block, or an indented
// code block.
source := "Here is some text: `<pre class=\"mermaid\" data-glance-mermaid=\"1\">`\n\n" +
"```html\n<pre class=\"mermaid\" data-glance-mermaid=\"1\">\n```\n\n" +
" <pre class=\"mermaid\" data-glance-mermaid=\"1\">\n"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
assert.False(t, strings.Contains(actual, `<pre class="mermaid" data-glance-mermaid="1">`))
}

func TestConvertMarkdownToHTMLWithMultipleMermaidBlocks(t *testing.T) {
// Two consecutive mermaid blocks must both be rewritten — guards
// against a future regression where the transformer only handles
// the first match
source := "```mermaid\nflowchart LR\n A --> B\n```\n\n" +
"```mermaid\nsequenceDiagram\n Alice->>Bob: hi\n```"
actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
assert.Equal(t, 2, strings.Count(actual, `data-glance-mermaid="1"`))
}

func TestMermaidBlockOpenTagContainsSentinel(t *testing.T) {
// The Swift-side detection in QLPlugin/Views/Previews/MarkdownPreview.swift
// hardcodes the sentinel literal `data-glance-mermaid="1"`. Renaming it
// in Go without updating Swift would silently break runtime detection
// (the Swift code would compile and the Go tests would still pass
// without this assertion). This test pins the contract.
const sentinel = `data-glance-mermaid="1"`
assert.True(
t,
strings.Contains(MermaidBlockOpenTag, sentinel),
"MermaidBlockOpenTag must contain %q so Swift detection works",
sentinel,
)
}

func TestConvertNotebookToHTML(t *testing.T) {
source := `{"cells":[{"cell_type":"code","execution_count":1,"metadata":{},"outputs":[{"name":"stdout","output_type":"stream","text":["Hello world\n"]}],"source":["print(\"Hello world\")"]}],"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.8.2"}},"nbformat":4,"nbformat_minor":4}` // nolint:lll
actual := convertToGoString(convertNotebookToHTML(convertToCString(source)))
Expand Down
121 changes: 121 additions & 0 deletions HTMLConverter/mermaid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"strings"

"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)

const mermaidLanguage = "mermaid"

// MermaidBlockOpenTag is the opening tag emitted for mermaid diagrams. Swift
// uses the `data-glance-mermaid` attribute (a sentinel users cannot forge via
// markdown) to decide whether to inject the mermaid runtime.
const MermaidBlockOpenTag = `<pre class="mermaid" data-glance-mermaid="1">`

// KindMermaidBlock is the NodeKind for mermaid diagram blocks.
var KindMermaidBlock = ast.NewNodeKind("MermaidBlock")

// MermaidBlock represents a fenced code block whose info string is `mermaid`.
// It is rendered as a `<pre class="mermaid">` element so the client-side
// mermaid runtime can replace it with an SVG diagram.
type MermaidBlock struct {
ast.BaseBlock
}

func (n *MermaidBlock) Kind() ast.NodeKind {
return KindMermaidBlock
}

// IsRaw matches the semantics of FencedCodeBlock: the contents are raw text
// that must not be inline-parsed.
func (n *MermaidBlock) IsRaw() bool {
return true
}

func (n *MermaidBlock) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}

// mermaidTransformer rewrites FencedCodeBlock nodes whose language is `mermaid`
// (case-insensitive) into MermaidBlock nodes, so they bypass syntax highlighting.
type mermaidTransformer struct{}

func (t *mermaidTransformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) {
source := reader.Source()
var targets []*ast.FencedCodeBlock
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if cb, ok := n.(*ast.FencedCodeBlock); ok {
if strings.EqualFold(string(cb.Language(source)), mermaidLanguage) {
targets = append(targets, cb)
}
// Fenced code blocks have no relevant block-level descendants
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})

for _, cb := range targets {
replacement := &MermaidBlock{}
replacement.SetLines(cb.Lines())
replacement.SetBlankPreviousLines(cb.HasBlankPreviousLines())
parent := cb.Parent()
parent.ReplaceChild(parent, cb, replacement)
}
}

// mermaidRenderer renders MermaidBlock nodes as a sentinel-marked `<pre>`.
type mermaidRenderer struct{}

func (r *mermaidRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(KindMermaidBlock, r.render)
}

func (r *mermaidRenderer) render(
w util.BufWriter,
source []byte,
n ast.Node,
entering bool,
) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
block, ok := n.(*MermaidBlock)
if !ok {
return ast.WalkStop, nil
}
_, _ = w.WriteString(MermaidBlockOpenTag)
lines := block.Lines()
for i := range lines.Len() {
segment := lines.At(i)
_, _ = w.Write(util.EscapeHTML(segment.Value(source)))
}
_, _ = w.WriteString("</pre>\n")
return ast.WalkContinue, nil
}

type mermaidExtension struct{}

// Mermaid is a goldmark extension that handles ```mermaid``` fenced code blocks.
var Mermaid goldmark.Extender = &mermaidExtension{}

func (e *mermaidExtension) Extend(md goldmark.Markdown) {
// Transformer priority is uncontested in practice (no other extension
// touches FencedCodeBlock at parse time); 100 is a low-but-not-extreme
// slot. The renderer is the only one registered for KindMermaidBlock,
// so its priority is cosmetic.
md.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(&mermaidTransformer{}, 100),
))
md.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&mermaidRenderer{}, 500),
))
}
3,298 changes: 3,298 additions & 0 deletions QLPlugin/Resources/markdown/markdown-mermaid.min.js

Large diffs are not rendered by default.

63 changes: 60 additions & 3 deletions QLPlugin/Views/Previews/MarkdownPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ class MarkdownPreview: Preview {
forResource: "markdown-main",
withExtension: "css"
)
private let mermaidScriptURL = Bundle.main.url(
forResource: "markdown-mermaid.min",
withExtension: "js"
)

required init() {}

Expand Down Expand Up @@ -61,10 +65,63 @@ class MarkdownPreview: Preview {
return stylesheets
}

// The mermaid runtime is large; only inject it when the rendered HTML
// actually contains a diagram. Detection uses a sentinel data attribute
// emitted only by the Go-side mermaid renderer — users can't forge it
// through markdown because raw HTML is escaped by goldmark.
//
// MUST stay in sync with `MermaidBlockOpenTag` in HTMLConverter/mermaid.go.
// A Go-side test (`TestMermaidBlockOpenTagContainsSentinel`) pins the
// sentinel literal so it can't drift unnoticed.
private static let mermaidSentinel = "data-glance-mermaid=\"1\""

private func getScripts(html: String) -> [Script] {
var scripts = [Script]()

guard html.contains(Self.mermaidSentinel) else {
return scripts
}

if let mermaidScriptURL = mermaidScriptURL {
scripts.append(Script(url: mermaidScriptURL))
} else {
os_log("Could not find Mermaid script", log: Log.render, type: .error)
return scripts
}

// Theme follows prefers-color-scheme to match the markdown stylesheet.
// `mermaid.run()` is called directly instead of relying on
// `startOnLoad`, because the legacy WebView's DOMContentLoaded timing
// is racy when scripts are emitted at the end of <body>.
// `securityLevel: 'strict'` is pinned explicitly to make the safety
// contract local to this file rather than implicit in mermaid's
// default. `.catch` keeps a single malformed diagram from killing
// sibling diagrams and surfaces parse errors in the WebView console.
let initScript = """
(function() {
var isDark = window.matchMedia
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: isDark ? 'dark' : 'default'
});
mermaid.run({ suppressErrors: true }).catch(function(e) {
console.error('mermaid.run failed:', e);
});
})();
"""
scripts.append(Script(content: initScript))

return scripts
}

func createPreviewVC(file: File) throws -> PreviewVC {
WebPreviewVC(
html: try getHTML(file: file),
stylesheets: getStylesheets()
let html = try getHTML(file: file)
return WebPreviewVC(
html: html,
stylesheets: getStylesheets(),
scripts: getScripts(html: html)
)
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Alternatively, you can install Glance directly. The installation is slightly com

<p><img src="./AppStore/Assets/Screenshots/ScreenshotSourceCode.png" alt="" width="600"></p>

- **Markdown** (rendered using [goldmark](https://github.com/yuin/goldmark)): `.md`, `.markdown`, `.mdown`, `.mkdn`, `.mkd`, `.Rmd`, `.qmd`
- **Markdown** (rendered using [goldmark](https://github.com/yuin/goldmark), with [Mermaid](https://mermaid.js.org/) diagram support): `.md`, `.markdown`, `.mdown`, `.mkdn`, `.mkd`, `.Rmd`, `.qmd`

<p><img src="./AppStore/Assets/Screenshots/ScreenshotMarkdown.png" alt="" width="600"></p>

Expand Down