From 3a2a3c279e60bb121a4b0e33b15c339ca1682b96 Mon Sep 17 00:00:00 2001 From: soreavis <263610811+soreavis@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:42:46 +0200 Subject: [PATCH] Add wikilink rendering extension --- QLMarkdown.xcodeproj/project.pbxproj | 12 ++ QLMarkdown/Base.lproj/Main.storyboard | 22 +++ QLMarkdown/Settings+ext.swift | 1 + QLMarkdown/Settings+render.swift | 17 ++ QLMarkdown/Settings.swift | 8 + QLMarkdown/ViewController.swift | 9 + Resources/default.css | 6 + cmark-extra/extensions/extra-extensions.c | 2 + cmark-extra/extensions/wikilink.c | 200 ++++++++++++++++++++++ cmark-extra/extensions/wikilink.h | 15 ++ cmark-gfm | 2 +- examples/test-wikilink.md | 49 ++++++ examples/test1.md | 8 + qlmarkdown_cli/qlmarkdown_cli.swift | 7 + 14 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 cmark-extra/extensions/wikilink.c create mode 100644 cmark-extra/extensions/wikilink.h create mode 100644 examples/test-wikilink.md diff --git a/QLMarkdown.xcodeproj/project.pbxproj b/QLMarkdown.xcodeproj/project.pbxproj index 260e6c07..a80accf8 100644 --- a/QLMarkdown.xcodeproj/project.pbxproj +++ b/QLMarkdown.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 831A8C6B258ABD8900E36182 /* syntaxhighlight.c in Sources */ = {isa = PBXBuildFile; fileRef = 83598EF525818BCB004664D3 /* syntaxhighlight.c */; }; 831A8C6E258ABD8D00E36182 /* emoji.c in Sources */ = {isa = PBXBuildFile; fileRef = 83EF95222585476800B56FA3 /* emoji.c */; }; 831A8C71258ABD9300E36182 /* mention.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C2258403CA000AFE3C /* mention.c */; }; + 7273677933E44FAD8B3EEA1D /* wikilink.c in Sources */ = {isa = PBXBuildFile; fileRef = 1DDD0092C9DF487BBB99BB1D /* wikilink.c */; }; 831A8C74258ABD9600E36182 /* checkbox.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C625840429000AFE3C /* checkbox.c */; }; 831A8CC1258AC18300E36182 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83F4B99125868A7400FC58A7 /* Settings.swift */; }; 8320D5942D1C25B1005868BD /* QLMarkdown Shortcut Extension.appex in Embed ExtensionKit Extensions */ = {isa = PBXBuildFile; fileRef = 8320D58A2D1C25B1005868BD /* QLMarkdown Shortcut Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -76,6 +77,7 @@ 8320D5E32D1C3AA7005868BD /* sup_ext.c in Sources */ = {isa = PBXBuildFile; fileRef = 83CF71CD2C60023E0066A195 /* sup_ext.c */; }; 8320D5E42D1C3AA7005868BD /* syntaxhighlight.c in Sources */ = {isa = PBXBuildFile; fileRef = 83598EF525818BCB004664D3 /* syntaxhighlight.c */; }; 8320D5E52D1C3AA7005868BD /* mention.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C2258403CA000AFE3C /* mention.c */; }; + 138DFA4523EA4D8DA48F233D /* wikilink.c in Sources */ = {isa = PBXBuildFile; fileRef = 1DDD0092C9DF487BBB99BB1D /* wikilink.c */; }; 8320D5E62D1C3AA7005868BD /* checkbox.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C625840429000AFE3C /* checkbox.c */; }; 8320D5E72D1C3AA7005868BD /* math_ext.c in Sources */ = {isa = PBXBuildFile; fileRef = 835886B229E960050088DF7D /* math_ext.c */; }; 8320D5E82D1C3AA7005868BD /* extra-extensions.c in Sources */ = {isa = PBXBuildFile; fileRef = 834A4F8129E92AE800692964 /* extra-extensions.c */; }; @@ -99,6 +101,7 @@ 83437985271D49FF00A5D6EA /* MIMEtype.c in Sources */ = {isa = PBXBuildFile; fileRef = 83327D2E2594066300E76CF6 /* MIMEtype.c */; }; 83437986271D4A1900A5D6EA /* inlineimage.c in Sources */ = {isa = PBXBuildFile; fileRef = 83760300258C1813004799ED /* inlineimage.c */; }; 83437987271D4A1900A5D6EA /* mention.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C2258403CA000AFE3C /* mention.c */; }; + CB9185D831D741DCB49AF8D3 /* wikilink.c in Sources */ = {isa = PBXBuildFile; fileRef = 1DDD0092C9DF487BBB99BB1D /* wikilink.c */; }; 83437988271D4A1900A5D6EA /* checkbox.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C625840429000AFE3C /* checkbox.c */; }; 83437989271D4A1900A5D6EA /* heads_utils.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 83F8825C25991BFC008C5071 /* heads_utils.cpp */; }; 8343798D271D4A1900A5D6EA /* heads.c in Sources */ = {isa = PBXBuildFile; fileRef = 83F88258259906C7008C5071 /* heads.c */; }; @@ -243,6 +246,7 @@ 8376EAE427E8C67F00AB44B8 /* c_log.c in Sources */ = {isa = PBXBuildFile; fileRef = 8376EAE127E8C67F00AB44B8 /* c_log.c */; }; 837F97D6271EA12A009F4605 /* qlmarkdown_cli in Resources */ = {isa = PBXBuildFile; fileRef = 837F97CE271EA098009F4605 /* qlmarkdown_cli */; }; 838109C3258403CA000AFE3C /* mention.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C2258403CA000AFE3C /* mention.c */; }; + 0342FC81767440D68CFDD7B4 /* wikilink.c in Sources */ = {isa = PBXBuildFile; fileRef = 1DDD0092C9DF487BBB99BB1D /* wikilink.c */; }; 838109C725840429000AFE3C /* checkbox.c in Sources */ = {isa = PBXBuildFile; fileRef = 838109C625840429000AFE3C /* checkbox.c */; }; 8382D05C2664051400210A0A /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8382D05B2664051400210A0A /* Yams */; }; 8382D05E2664052E00210A0A /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 8382D05D2664052E00210A0A /* Yams */; }; @@ -651,6 +655,8 @@ 837F97CE271EA098009F4605 /* qlmarkdown_cli */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = qlmarkdown_cli; sourceTree = BUILT_PRODUCTS_DIR; }; 838109C1258403CA000AFE3C /* mention.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = mention.h; sourceTree = ""; }; 838109C2258403CA000AFE3C /* mention.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = mention.c; sourceTree = ""; }; + 8C0194F6D1944DC681276B11 /* wikilink.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = wikilink.h; sourceTree = ""; }; + 1DDD0092C9DF487BBB99BB1D /* wikilink.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = wikilink.c; sourceTree = ""; }; 838109C525840429000AFE3C /* checkbox.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = checkbox.h; sourceTree = ""; }; 838109C625840429000AFE3C /* checkbox.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = checkbox.c; sourceTree = ""; }; 838A5D5C29F1139200D704AB /* makefile2.makefile */ = {isa = PBXFileReference; lastKnownFileType = text; path = makefile2.makefile; sourceTree = ""; usesTabs = 1; }; @@ -843,6 +849,8 @@ 835886B229E960050088DF7D /* math_ext.c */, 838109C1258403CA000AFE3C /* mention.h */, 838109C2258403CA000AFE3C /* mention.c */, + 8C0194F6D1944DC681276B11 /* wikilink.h */, + 1DDD0092C9DF487BBB99BB1D /* wikilink.c */, 83CF71CC2C60023E0066A195 /* sup_ext.h */, 83CF71CD2C60023E0066A195 /* sup_ext.c */, 83CF71D12C6005370066A195 /* sub_ext.h */, @@ -1504,6 +1512,7 @@ 834A4EF829E8AD8C00692964 /* table.c in Sources */, 834A4F1929E8AD8D00692964 /* blocks.c in Sources */, 831A8C71258ABD9300E36182 /* mention.c in Sources */, + 7273677933E44FAD8B3EEA1D /* wikilink.c in Sources */, 836B747E259E2D7B00A6A5F7 /* AboutViewController.swift in Sources */, 835886B429E960050088DF7D /* math_ext.c in Sources */, 834CAB9D2593650900673002 /* emoji_utils.cpp in Sources */, @@ -1561,6 +1570,7 @@ 8320D5E32D1C3AA7005868BD /* sup_ext.c in Sources */, 8320D5E42D1C3AA7005868BD /* syntaxhighlight.c in Sources */, 8320D5E52D1C3AA7005868BD /* mention.c in Sources */, + 138DFA4523EA4D8DA48F233D /* wikilink.c in Sources */, 8320D5E62D1C3AA7005868BD /* checkbox.c in Sources */, 8320D5E72D1C3AA7005868BD /* math_ext.c in Sources */, 8320D5E82D1C3AA7005868BD /* extra-extensions.c in Sources */, @@ -1622,6 +1632,7 @@ 834A4F2829E8AD8D00692964 /* houdini_href_e.c in Sources */, 834A4EF929E8AD8C00692964 /* table.c in Sources */, 83437987271D4A1900A5D6EA /* mention.c in Sources */, + CB9185D831D741DCB49AF8D3 /* wikilink.c in Sources */, 834A4F1429E8AD8C00692964 /* man.c in Sources */, 834A4F6529E8AD8E00692964 /* houdini_html_u.c in Sources */, 834A4F0C29E8AD8C00692964 /* xml.c in Sources */, @@ -1715,6 +1726,7 @@ 83720A2825B8E93D00B49FEC /* decode.c in Sources */, 834A4F8229E92AE800692964 /* extra-extensions.c in Sources */, 838109C3258403CA000AFE3C /* mention.c in Sources */, + 0342FC81767440D68CFDD7B4 /* wikilink.c in Sources */, 834A4F6629E8AD8E00692964 /* registry.c in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/QLMarkdown/Base.lproj/Main.storyboard b/QLMarkdown/Base.lproj/Main.storyboard index 6e1dc80c..02b6c47f 100644 --- a/QLMarkdown/Base.lproj/Main.storyboard +++ b/QLMarkdown/Base.lproj/Main.storyboard @@ -824,6 +824,7 @@ + @@ -1591,6 +1592,27 @@ + + + + + + + diff --git a/QLMarkdown/Settings+ext.swift b/QLMarkdown/Settings+ext.swift index 9d1c34ea..61e5c0b6 100644 --- a/QLMarkdown/Settings+ext.swift +++ b/QLMarkdown/Settings+ext.swift @@ -200,6 +200,7 @@ extension Settings { defaults.set(mermaidExtension.stripDefaultUrl(cacheUrl: self.mermaidFileUrl, cdnUrl: Self.mermaidWebUrl).toDict(), forKey: Self.CodingKeys.mermaidExtension.rawValue) defaults.set(mentionExtension, forKey: Self.CodingKeys.mentionExtension.rawValue) + defaults.set(wikilinkExtension, forKey: Self.CodingKeys.wikilinkExtension.rawValue) defaults.set(checkboxExtension, forKey: Self.CodingKeys.checkboxExtension.rawValue) defaults.set(headsExtension, forKey: Self.CodingKeys.headsExtension.rawValue) diff --git a/QLMarkdown/Settings+render.swift b/QLMarkdown/Settings+render.swift index 9898a9a9..b1de0237 100644 --- a/QLMarkdown/Settings+render.swift +++ b/QLMarkdown/Settings+render.swift @@ -206,6 +206,15 @@ extension Settings { } } + if self.wikilinkExtension { + if let ext = cmark_find_syntax_extension("wikilink") { + cmark_parser_attach_syntax_extension(parser, ext) + os_log("Enabled markdown `wikilink` extension.", log: OSLog.rendering, type: .debug) + } else { + os_log("Could not enable markdown `wikilink` extension!", log: OSLog.rendering, type: .error) + } + } + if self.headsExtension { if let ext = cmark_find_syntax_extension("heads") { cmark_parser_attach_syntax_extension(parser, ext) @@ -650,6 +659,14 @@ table.debug td { html_debug += "off" } html_debug += "\n" + + html_debug += "wikilink extension" + if self.wikilinkExtension { + html_debug += "on " + (cmark_find_syntax_extension("wikilink") == nil ? " (NOT AVAILABLE" : "") + } else { + html_debug += "off" + } + html_debug += "\n" html_debug += "strikethrough extension" switch self.strikethroughExtension { diff --git a/QLMarkdown/Settings.swift b/QLMarkdown/Settings.swift index 4d10dfc6..fcd7b189 100644 --- a/QLMarkdown/Settings.swift +++ b/QLMarkdown/Settings.swift @@ -247,6 +247,7 @@ class Settings: Codable { case mathExtension case mermaidExtension case mentionExtension + case wikilinkExtension case subExtension case supExtension case tableExtension @@ -475,6 +476,7 @@ class Settings: Codable { var mathExtension: JSExtension = .link(url: nil) var mermaidExtension: JSExtension = .link(url: nil) var mentionExtension: Bool = false + var wikilinkExtension: Bool = false var subExtension: Bool = false var supExtension: Bool = false var tableExtension: Bool = true @@ -548,6 +550,7 @@ class Settings: Codable { self.mermaidExtension = try container.decode(JSExtension.self, forKey:.mermaidExtension) self.mentionExtension = try container.decode(Bool.self, forKey:.mentionExtension) + self.wikilinkExtension = try container.decode(Bool.self, forKey:.wikilinkExtension) self.checkboxExtension = try container.decode(Bool.self, forKey:.checkboxExtension) self.headsExtension = try container.decode(Bool.self, forKey:.headsExtension) self.highlightExtension = try container.decode(Bool.self, forKey: .hightlightExtension) @@ -625,6 +628,7 @@ class Settings: Codable { try container.encode(self.mermaidExtension, forKey: .mermaidExtension) try container.encode(self.mentionExtension, forKey: .mentionExtension) + try container.encode(self.wikilinkExtension, forKey: .wikilinkExtension) try container.encode(self.checkboxExtension, forKey: .checkboxExtension) try container.encode(self.headsExtension, forKey: .headsExtension) try container.encode(self.highlightExtension, forKey: .hightlightExtension) @@ -717,6 +721,7 @@ class Settings: Codable { self.mathExtension = s.mathExtension self.mermaidExtension = s.mermaidExtension self.mentionExtension = s.mentionExtension + self.wikilinkExtension = s.wikilinkExtension self.checkboxExtension = s.checkboxExtension self.headsExtension = s.headsExtension @@ -794,6 +799,9 @@ class Settings: Codable { if let ext = defaultsDomain[Self.CodingKeys.mentionExtension.rawValue] as? Bool { mentionExtension = ext } + if let ext = defaultsDomain[Self.CodingKeys.wikilinkExtension.rawValue] as? Bool { + wikilinkExtension = ext + } if let ext = defaultsDomain[Self.CodingKeys.checkboxExtension.rawValue] as? Bool { checkboxExtension = ext } diff --git a/QLMarkdown/ViewController.swift b/QLMarkdown/ViewController.swift index 792c8d21..c67322ea 100644 --- a/QLMarkdown/ViewController.swift +++ b/QLMarkdown/ViewController.swift @@ -80,6 +80,13 @@ class ViewController: NSViewController { } } + @objc dynamic var wikilinkExtension: Bool = Settings.factorySettings.wikilinkExtension { + didSet { + guard oldValue != wikilinkExtension else { return } + isDirty = true + } + } + @objc dynamic var syntaxHighlightExtension: Bool = Settings.factorySettings.syntaxHighlightExtension { didSet { guard oldValue != syntaxHighlightExtension else { return } @@ -1357,6 +1364,7 @@ document.addEventListener('scroll', function(e) { self.mermaidExtensionEmbed = settings.mermaidExtension.getMode()?.embed ?? false self.mentionExtension = settings.mentionExtension + self.wikilinkExtension = settings.wikilinkExtension self.syntaxHighlightExtension = settings.syntaxHighlightExtension self.emojiExtension = settings.emojiExtension != .disabled @@ -1418,6 +1426,7 @@ document.addEventListener('scroll', function(e) { settings.mathExtension = self.mathExtension ? (self.mathExtensionEmbed ? .embed(url: nil) : .link(url: nil)) : .disabled settings.mermaidExtension = self.mermaidExtension ? (self.mermaidExtensionEmbed ? .embed(url: nil) : .link(url: nil)) : .disabled settings.mentionExtension = self.mentionExtension + settings.wikilinkExtension = self.wikilinkExtension settings.emojiExtension = self.emojiExtension ? (self.emojiImageOption ? .images : .font) : .disabled diff --git a/Resources/default.css b/Resources/default.css index 75ff6fff..78aabd05 100644 --- a/Resources/default.css +++ b/Resources/default.css @@ -22,6 +22,7 @@ --frame-border: #e3e4e8; --frame-color: #242528; --mention-color: #242528; + --wikilink-color: #8250df; --email-toggle-color: #4b4d58; /* unused variable */ --email-toggle-background: #e3e4e8; /* unused variable */ --email-quoted-color: #6a6d7c; /* unused variable */ @@ -73,6 +74,7 @@ --frame-border: #ff08d6; --frame-color: #242528; --mention-color: #242528; + --wikilink-color: #ab7df8; --email-toggle-color: #4b4d58; --email-toggle-background: #e3e4e8; --email-quoted-color: #6a6d7c; @@ -175,6 +177,10 @@ a:hover { text-decoration: underline; } +.wikilink { + color: var(--wikilink-color); +} + details summary { outline: 0; } diff --git a/cmark-extra/extensions/extra-extensions.c b/cmark-extra/extensions/extra-extensions.c index 2374605f..1bc7bbc6 100644 --- a/cmark-extra/extensions/extra-extensions.c +++ b/cmark-extra/extensions/extra-extensions.c @@ -12,6 +12,7 @@ #include "plugin.h" #include "mention.h" +#include "wikilink.h" // #include "checkbox.h" #include "syntaxhighlight.h" #include "inlineimage.h" @@ -24,6 +25,7 @@ static int extra_extensions_registration(cmark_plugin *plugin) { cmark_plugin_register_syntax_extension(plugin, create_mention_extension()); + cmark_plugin_register_syntax_extension(plugin, create_wikilink_extension()); //cmark_plugin_register_syntax_extension(plugin, create_checkbox_extension()); cmark_plugin_register_syntax_extension(plugin, create_inlineimage_extension()); diff --git a/cmark-extra/extensions/wikilink.c b/cmark-extra/extensions/wikilink.c new file mode 100644 index 00000000..50b6024b --- /dev/null +++ b/cmark-extra/extensions/wikilink.c @@ -0,0 +1,200 @@ +// +// wikilink.c +// QLMarkdown +// +// Created by soreavis on 30/06/26. +// +// Wikilinks: `[[Page Name]]` and `[[Target|Display]]` are rewritten into styled +// links (``), matching the syntax used by Obsidian +// and similar tools. cmark-gfm reserves `[` for its own link parser, so this can't be +// an inline-match extension — instead it post-processes the parsed tree and splits +// text nodes, like cmark-gfm's own `autolink` extension. +// + +#include "wikilink.h" + +#include + +#include "../../cmark-gfm/src/parser.h" +#include "../../cmark-gfm/src/render.h" +#include "../../cmark-gfm/src/houdini.h" +#include "../../cmark-gfm/src/scanners.h" + +// Trim leading/trailing spaces and tabs from [*start, *start + *len). +static void trim(const uint8_t **start, size_t *len) { + while (*len > 0 && ((*start)[0] == ' ' || (*start)[0] == '\t')) { + (*start)++; + (*len)--; + } + while (*len > 0 && ((*start)[*len - 1] == ' ' || (*start)[*len - 1] == '\t')) { + (*len)--; + } +} + +// A located `[[ … ]]` wikilink, as byte offsets into the text buffer. +typedef struct { + size_t open; // the opening `[[` + size_t inner; // the inner text + size_t inner_len; // length of the inner text (non-empty, free of `[`/`]`) + size_t close_end; // just past the closing `]]` + bool found; +} wikilink_match; + +// Locate the next wikilink in data[from..len). The inner text must be non-empty and free +// of `[`/`]`, so malformed or nested brackets are skipped rather than half-matched. +static wikilink_match find_wikilink(const uint8_t *data, size_t len, size_t from) { + for (size_t i = from; i + 1 < len; i++) { + if (data[i] != '[' || data[i + 1] != '[') { + continue; + } + size_t s = i + 2; + size_t j = s; + while (j + 1 < len && !(data[j] == ']' && data[j + 1] == ']')) { + if (data[j] == '[' || data[j] == ']') { + break; // stray bracket: not a clean wikilink + } + j++; + } + if (j + 1 >= len || data[j] != ']' || data[j + 1] != ']' || j == s) { + continue; // no closing `]]`, or empty `[[]]` + } + return (wikilink_match){ .open = i, .inner = s, .inner_len = j - s, .close_end = j + 2, .found = true }; + } + return (wikilink_match){ .found = false }; +} + +// Build a CMARK_NODE_LINK whose url is `target` and whose single text child is +// `display`, tagged with this extension so html_render() emits the wikilink markup. +static cmark_node *make_wikilink(cmark_syntax_extension *ext, cmark_parser *parser, + const uint8_t *target, size_t target_len, + const uint8_t *display, size_t display_len) { + cmark_node *link = cmark_node_new_with_mem(CMARK_NODE_LINK, parser->mem); + + cmark_strbuf url; + cmark_strbuf_init(parser->mem, &url, (bufsize_t)target_len + 1); + cmark_strbuf_put(&url, target, (bufsize_t)target_len); + link->as.link.url = cmark_chunk_buf_detach(&url); + + cmark_node *text = cmark_node_new_with_mem(CMARK_NODE_TEXT, parser->mem); + cmark_strbuf label; + cmark_strbuf_init(parser->mem, &label, (bufsize_t)display_len + 1); + cmark_strbuf_put(&label, display, (bufsize_t)display_len); + text->as.literal = cmark_chunk_buf_detach(&label); + cmark_node_append_child(link, text); + + cmark_node_set_syntax_extension(link, ext); + return link; +} + +// Split `text` on every wikilink it contains, inserting the link nodes inline. +// Modelled on cmark-gfm's autolink postprocess_text(). +static void postprocess_text(cmark_syntax_extension *ext, cmark_parser *parser, cmark_node *text) { + if (text->as.literal.len < 5 || + memchr(text->as.literal.data, '[', text->as.literal.len) == NULL) { + return; // shortest wikilink is "[[x]]"; no '[' means nothing to do + } + + cmark_chunk detached = text->as.literal; + text->as.literal = cmark_chunk_dup(&detached, 0, detached.len); + + const uint8_t *data = detached.data; + size_t len = detached.len; + size_t start = 0; // start of the segment still held by `text` + size_t search = 0; + + while (true) { + wikilink_match m = find_wikilink(data, len, search); + if (!m.found) { + break; + } + + // Split the inner text on the first `|` into target / display, then trim both. + const uint8_t *t = data + m.inner; + size_t t_len = m.inner_len; + const uint8_t *bar = memchr(t, '|', m.inner_len); + const uint8_t *d = t; + size_t d_len = m.inner_len; + if (bar != NULL) { + t_len = (size_t)(bar - t); + d = bar + 1; + d_len = m.inner_len - t_len - 1; + } + trim(&t, &t_len); + trim(&d, &d_len); + if (t_len == 0) { + search = m.open + 1; // empty target: leave `[[` literal, keep scanning + continue; + } + if (d_len == 0) { // `[[Target|]]` → display falls back to the target + d = t; + d_len = t_len; + } + + cmark_node *link = make_wikilink(ext, parser, t, t_len, d, d_len); + cmark_node_insert_after(text, link); + + cmark_node *post = cmark_node_new_with_mem(CMARK_NODE_TEXT, parser->mem); + post->as.literal = cmark_chunk_dup(&detached, (bufsize_t)m.close_end, (bufsize_t)(len - m.close_end)); + cmark_node_insert_after(link, post); + + text->as.literal = cmark_chunk_dup(&detached, (bufsize_t)start, (bufsize_t)(m.open - start)); + cmark_chunk_to_cstr(parser->mem, &text->as.literal); + + text = post; + start = m.close_end; + search = m.close_end; + } + + cmark_chunk_to_cstr(parser->mem, &text->as.literal); + cmark_chunk_free(parser->mem, &detached); +} + +static cmark_node *postprocess(cmark_syntax_extension *ext, cmark_parser *parser, cmark_node *root) { + cmark_consolidate_text_nodes(root); + + cmark_iter *iter = cmark_iter_new(root); + cmark_event_type ev; + bool in_link = false; + while ((ev = cmark_iter_next(iter)) != CMARK_EVENT_DONE) { + cmark_node *node = cmark_iter_get_node(iter); + cmark_node_type type = cmark_node_get_type(node); + if (in_link) { + if (ev == CMARK_EVENT_EXIT && type == CMARK_NODE_LINK) { + in_link = false; + } + continue; + } + if (ev == CMARK_EVENT_ENTER && type == CMARK_NODE_LINK) { + in_link = true; // don't create links inside existing links + } else if (ev == CMARK_EVENT_ENTER && type == CMARK_NODE_TEXT) { + postprocess_text(ext, parser, node); + } + } + cmark_iter_free(iter); + + return root; +} + +static void html_render(cmark_syntax_extension *extension, + struct cmark_html_renderer *renderer, + cmark_node *node, cmark_event_type ev_type, int options) { + cmark_strbuf *html = renderer->html; + if (ev_type == CMARK_EVENT_ENTER) { + cmark_strbuf_puts(html, "as.link.url, 0)) { + houdini_escape_href(html, node->as.link.url.data, node->as.link.url.len); + } + cmark_strbuf_puts(html, "\">"); + } else { + cmark_strbuf_puts(html, ""); + } +} + +cmark_syntax_extension *create_wikilink_extension(void) { + cmark_syntax_extension *ext = cmark_syntax_extension_new("wikilink"); + + cmark_syntax_extension_set_postprocess_func(ext, postprocess); + cmark_syntax_extension_set_html_render_func(ext, html_render); + + return ext; +} diff --git a/cmark-extra/extensions/wikilink.h b/cmark-extra/extensions/wikilink.h new file mode 100644 index 00000000..fa0d2262 --- /dev/null +++ b/cmark-extra/extensions/wikilink.h @@ -0,0 +1,15 @@ +// +// wikilink.h +// QLMarkdown +// +// Created by soreavis on 30/06/26. +// + +#ifndef wikilink_h +#define wikilink_h + +#include "cmark-gfm-core-extensions.h" + +cmark_syntax_extension *create_wikilink_extension(void); + +#endif /* wikilink_h */ diff --git a/cmark-gfm b/cmark-gfm index c168d57a..587a12bb 160000 --- a/cmark-gfm +++ b/cmark-gfm @@ -1 +1 @@ -Subproject commit c168d57acfa1f688e519e4e829f9a28d559bd5fa +Subproject commit 587a12bb54d95ac37241377e6ddc93ea0e45439b diff --git a/examples/test-wikilink.md b/examples/test-wikilink.md new file mode 100644 index 00000000..d0aa4bb4 --- /dev/null +++ b/examples/test-wikilink.md @@ -0,0 +1,49 @@ +# Wikilinks + +With the `wikilink` extension a `[[Page Name]]` or `[[Target|Display]]` is rendered as a +styled link (``), matching the syntax used by Obsidian +and similar tools. It is off by default; `default.css` styles the `.wikilink` class. + +## Basic + +- A simple link: [[Page Name]] +- An aliased link: [[Target|Display]] +- Surrounding spaces are trimmed: [[ Spaced Out ]] +- Paths keep their slashes: [[Folder/Sub/Note]] +- Two on one line: [[First]] and [[Second]]. +- No boundary needed: pre[[Inline]]post. +- Special characters are escaped: [[Café & Bar]] + +## In other blocks + +Wikilinks work anywhere text does — in headings, lists, tables, and quotes. + +### A heading with a [[Linked Page]] + +| Column | Wikilink | +|--------|----------| +| row | [[Cell Link]] | + +> A quote linking to [[Some Note]]. + +## Edge cases + +| Input | Expected | +|-------|----------| +| `[[]]` | left literal (empty) | +| `[[ ]]` | left literal (whitespace only) | +| `[[A\|]]` | link to `A`, shown as `A` (empty display falls back) | +| `[[\|D]]` | left literal (empty target) | +| `[[A\|B\|C]]` | link to `A`, shown as `B\|C` (split on first `\|`) | +| `[[A]` | left literal (no closing) | +| `[[a]b]]` | left literal (stray bracket inside) | + +Rendered: [[]] · [[ ]] · [[A|]] · [[|D]] · [[A|B|C]] · [[A] · [[a]b]] + +## Not converted + +Inside a code span it stays literal: `[[Not A Link]]`. + +Markdown inside the brackets prevents recognition: [[**Bold**]]. + +Existing Markdown links are left alone: [a real link](https://example.com). diff --git a/examples/test1.md b/examples/test1.md index 1872291c..5f31d165 100644 --- a/examples/test1.md +++ b/examples/test1.md @@ -26,6 +26,7 @@ The previous block placed at the top of the document, starting with `---` and en - [Strikethrough extension](#strikethrough-extension) - [Syntax Highlight extension](#syntax-highlight-extension) - [Task list extension](#task-list-extension) + - [Wikilink extension](#wikilink-extension) - [Options](#options) (The links on the TOC works only if the `heads` extension is enabled). @@ -139,6 +140,13 @@ function test(array $a, string $b, $c = null): boolean { * [ ] step 3 +## Wikilink extension + +With the `wikilink` extension `[[Page Name]]` and `[[Target|Display]]` are rendered as links, matching Obsidian and similar tools. + +See [[Getting Started]], the [[Reference|API reference]], or a note in [[Folder/Daily]]. + + # Options The `smart quote` option format the quote as "curly" [^footnote1]. diff --git a/qlmarkdown_cli/qlmarkdown_cli.swift b/qlmarkdown_cli/qlmarkdown_cli.swift index 0dfaac10..938ec042 100644 --- a/qlmarkdown_cli/qlmarkdown_cli.swift +++ b/qlmarkdown_cli/qlmarkdown_cli.swift @@ -141,6 +141,9 @@ struct ExtensionsOptions: ParsableArguments { @Option(help: ArgumentHelp("Translate mentions to link to the GitHub account", valueName: "on|off")) var githubMentions: BoolArgumentEnum? = nil + @Option(help: ArgumentHelp("Render [[wikilinks]] as links.", valueName: "on|off")) + var wikilink: BoolArgumentEnum? = nil + @Option(help: ArgumentHelp("Create anchors for the heads.", valueName: "on|off")) var headsAnchor: BoolArgumentEnum? = nil @@ -307,6 +310,9 @@ struct QLMarkdownCLI: ParsableCommand { if let o = extensions.githubMentions { settings.mentionExtension = o == .on } + if let o = extensions.wikilink { + settings.wikilinkExtension = o == .on + } if let o = extensions.headsAnchor { settings.headsExtension = o == .on } @@ -417,6 +423,7 @@ struct QLMarkdownCLI: ParsableCommand { print(" --emoji: using images") } print(" --github-mentions: \(settings.mentionExtension ? "on" : "off")") + print(" --wikilink: \(settings.wikilinkExtension ? "on" : "off")") print(" --heads-anchor: \(settings.headsExtension ? "on" : "off")") print(" --highlight: \(settings.highlightExtension ? "on" : "off")") print(" --inline-images: \(settings.inlineImageExtension ? "on" : "off")")