This patch will close
https://list.orgmode.org/orgmode/[email protected]/.

This is mostly useful for people who export their org-documents to
HTML or markdown. However, I have added an open function that will
request a base URL so that users will still have the option to open
these URLs.
From 78b49b2db1db62227c2c6e1a59a562e2837f7656 Mon Sep 17 00:00:00 2001
From: ApollonDeParnasse <[email protected]>
Date: Fri, 22 May 2026 06:24:21 -0500
Subject: [PATCH] ol.el: New link type for relative urls

Links that start with 'rel-url' followed by a colon will be treated
as relative urls.  When these links are exported to markdown or HTML
documents, they are properly converted into relative URL links.  I have
added an open function that will request a base URL when called so that users
will still have the option to open these URLs as they would with absolute
URLs.

=* lisp/ol.el=
(org-link-parameters): New link type named 'rel-url'
for relative urls.
(org-link-open-relative-url): Function for the
`:follow' parameter for 'rel-url' type links.
(org-link-export-relative-url): Function for the
`:export' parameter for 'rel-url' type links.
=* testing/lisp/test-ol.el=
(test-org-link/plain-link-re): New test cases
for 'rel-url' type links.
=* testing/lisp/test-org-element.el=
(test-org-element-parse-relative-links-asserter): Asserter for
`test-org-element/link-parser/relative-urls'.
(test-org-element/link-parser/relative-urls): Assert org-element
properly parses 'rel-url' type links.
=* testing/lisp/test-ox-html.el=
(test-ox-html-relative-links-asserter): Asserter for
`ox-html/relative-links'.
(ox-html/relative-links): Assert `org-link-export-relative-url'
converts 'rel-url' type links into the proper HTML link format.
=* testing/lisp/test-ox-md.el=
(test-ox-md-relative-links-asserter): Asserter for
`ox-md/relative-links'.
(ox-md/relative-links): Assert `org-link-export-relative-url'
converts 'rel-url' type links into the proper markdown link format.
---
 doc/org-manual.org               |  5 +++
 etc/ORG-NEWS                     | 12 ++++++++
 lisp/ol.el                       | 26 +++++++++++++++-
 testing/lisp/test-ol.el          | 18 ++++++++++-
 testing/lisp/test-org-element.el | 34 ++++++++++++++++++++-
 testing/lisp/test-ox-html.el     | 49 ++++++++++++++++++++++++++++++
 testing/lisp/test-ox-md.el       | 52 +++++++++++++++++++++++++++++++-
 7 files changed, 192 insertions(+), 4 deletions(-)

diff --git a/doc/org-manual.org b/doc/org-manual.org
index 90a3880cf..f5c82dc3f 100644
--- a/doc/org-manual.org
+++ b/doc/org-manual.org
@@ -3403,6 +3403,9 @@ Here is the full set of built-in link types:
   For more information, see [[info:emacs#Name Help][Name Help]]
   and [[info:elisp#Documentation Groups][Documentation Groups]].
 
+- =rel-url= ::
+
+  Relative URL links.
 
 For =file:= and =id:= links, you can additionally specify a line
 number, or a text search string, separated by =::=.  In Org files, you
@@ -3449,6 +3452,8 @@ options:
 | shell      | =shell:ls *.org= (synchronous), =shell:inkscape&= (asynchronous)   |
 | elisp      | =elisp:(find-file "Elisp.org")= (Elisp form to evaluate)           |
 |            | =elisp:org-agenda= (interactive Elisp command)                     |
+| rel-url    | =rel-url:/en-US/docs/Web/HTML=                                     |
+
 
 #+cindex: VM links
 #+cindex: Wanderlust links
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index e62a8ec46..c3e20e5b9 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -138,6 +138,18 @@ A current limitation is that export blocks and keywords are only
 implemented for events and todos, and not yet for calendar-wide
 properties.
 
+*** ol.el now supports relative URLs.
+
+Links that start with 'rel-url' followed by a colon will be treated as relative urls.
+When these links are exported to markdown or HTML documents, they are properly converted
+into relative URL links. For example, =[[rel-url:/en-US/docs/Web/HTML][Origin-relative URL]]=
+will be exported to markdown as =[Origin-relative URL](/en-US/docs/Web/HTML)=. For HTML
+documents, =[[rel-url:/en-US/docs/Web/HTML][Origin-relative URL]]= will be exported as
+#+begin_example
+<a href=\"/en-US/docs/Web/HTML\">Origin-relative URL</a>
+#+end_example
+
+These links can also be opened if the user provides a base URL when requested.
 ** New and changed options
 
 # Changes dealing with changing default values of customizations,
diff --git a/lisp/ol.el b/lisp/ol.el
index 73645fb97..98d4936d5 100644
--- a/lisp/ol.el
+++ b/lisp/ol.el
@@ -88,7 +88,7 @@
 (declare-function org-element-contents-begin "org-element" (node))
 (declare-function org-element-contents-end "org-element" (node))
 (declare-function org-property-or-variable-value "org" (var &optional inherit))
-
+(declare-function url-expand-file-name "url-expand" (url &optional default))
 
 ;;; Customization
 
@@ -2435,6 +2435,30 @@ string\"."
 			   :follow
 			   (lambda (url arg)
 			     (browse-url (concat scheme ":" url) arg))))
+;; relative urls
+(defun org-link-open-relative-url (relative-url arg)
+  "Open RELATIVE-URL with `browse-url'.
+The user will be asked for the base url.
+ARG will be passed to the browser function."
+  (let ((base-url (read-string "base url: ")))
+    (browse-url (url-expand-file-name relative-url base-url) arg)))
+
+
+(defun org-link-export-relative-url (path description backend _)
+  "Export a relative url type link.
+PATH is the path of the link.  PATH
+is the DOI name.  DESCRIPTION is the description of the link,
+or nil.  BACKEND is a symbol representing the backend used for
+export."
+  (let ((desc (or description path)))
+    (pcase backend
+      (`html (format "<a href=\"%s\">%s</a>" path desc))
+      (`md (format "[%s](%s)" desc path))
+      (_ path))))
+
+(org-link-set-parameters "rel-url"
+                         :follow #'org-link-open-relative-url
+                         :export #'org-link-export-relative-url)
 
 ;;;; "shell" link type
 (defun org-link--open-shell (path _)
diff --git a/testing/lisp/test-ol.el b/testing/lisp/test-ol.el
index c8ec09616..d4e2ebdbe 100644
--- a/testing/lisp/test-ol.el
+++ b/testing/lisp/test-ol.el
@@ -778,7 +778,23 @@ See https://github.com/yantar92/org/issues/4.";
    (equal
     '("http" "//foo.com/(something)?after=parens")
     (test-ol-parse-link-in-text
-        "The <point>http://foo.com/(something)?after=parens link"))))
+     "The <point>http://foo.com/(something)?after=parens link")))
+  (should
+   (equal
+    '("rel-url" "/Learn_web_development/Howto/Web_mechanics/What_is_a_URL")
+    (test-ol-parse-link-in-text
+     "The <point>rel-url:/Learn_web_development/Howto/Web_mechanics/What_is_a_URL link")))
+  (should
+   (equal
+    '("rel-url" "worg/org-contribute.html#patches")
+    (test-ol-parse-link-in-text
+     "The <point>rel-url:worg/org-contribute.html#patches")))
+  (should
+   (equal
+    '("rel-url" "~bzg/org-mode-tests/tree/master/item/local/init.el")
+    (test-ol-parse-link-in-text
+     "The <point>rel-url:~bzg/org-mode-tests/tree/master/item/local/init.el"))))
+
 
 ;;; Insert Links
 
diff --git a/testing/lisp/test-org-element.el b/testing/lisp/test-org-element.el
index e5ab89ac0..2f9a35996 100644
--- a/testing/lisp/test-org-element.el
+++ b/testing/lisp/test-org-element.el
@@ -2628,6 +2628,38 @@ e^{i\\pi}+1=0
 		     (lambda (link) (org-element-property :type link))
 		     nil t nil t))))))
 
+(defun test-org-element-parse-relative-links-asserter (text expected-values)
+  "Helper function for `test-org-element/link-parser/relative-urls'.
+Asserts EXPECTED-VALUES matches the actual :type, :path
+and :description of link parsed in TEXT.  \"<point>\"
+string must be at the beginning of the link to be parsed."
+  (org-test-with-temp-text text
+                           (let* ((actual-element (org-element-link-parser))
+                                  (actual-values (list (org-element-property :type (org-element-link-parser))
+                                                       (org-element-property :path (org-element-link-parser)))))
+                             (should (equal actual-values expected-values)))))
+
+;; relative links
+(ert-deftest test-org-element/link-parser/relative-urls ()
+  (test-org-element-parse-relative-links-asserter
+   "The <point>[[rel-url:/en/install-emacs-on-android][install Emacs]] link"
+   '("rel-url" "/en/install-emacs-on-android"))
+  (test-org-element-parse-relative-links-asserter
+   "The <point>[[rel-url:/orgmode/87y0ijp82r.fsf@localhost/][org-meetup notes]] link"
+   '("rel-url" "/orgmode/87y0ijp82r.fsf@localhost/"))
+  (test-org-element-parse-relative-links-asserter
+   "The <point>[[rel-url:software/emacs/manual/html_node/elisp/Clickable-Text.html]] link"
+   '("rel-url" "software/emacs/manual/html_node/elisp/Clickable-Text.html"))
+  (test-org-element-parse-relative-links-asserter
+   "The <point>[[rel-url:cgit/org-mode.git/log/]] link"
+   '("rel-url" "cgit/org-mode.git/log/"))
+  ;; Works with <> links
+  (test-org-element-parse-relative-links-asserter
+   "The <point><rel-url:api/v1/path/> link"
+   '("rel-url" "api/v1/path/"))
+  (test-org-element-parse-relative-links-asserter
+   "The <point><rel-url:/account/history/abc.html> link"
+   '("rel-url" "/account/history/abc.html")))
 
 ;;;; Macro
 
@@ -4209,7 +4241,7 @@ DEADLINE: <2012-03-29 thu.> SCHEDULED: <2012-03-29 thu.> CLOSED: [2012-03-29 thu
       (:range-type daterange
                    :type inactive :year-start 2023 :month-start 7 :day-start 10
                    :hour-start 17 :minute-start 30)) nil))
-  
+
   ;;; End part is nil
   (should
    ;; Expected result: "<2023-07-10 Mon>--<2023-07-10 Mon>"
diff --git a/testing/lisp/test-ox-html.el b/testing/lisp/test-ox-html.el
index 11da941ab..4380c91fa 100644
--- a/testing/lisp/test-ox-html.el
+++ b/testing/lisp/test-ox-html.el
@@ -1130,5 +1130,54 @@ entirely."
           (org-html--priority 18 nil)))
   )
 
+(defun test-ox-html-relative-links-asserter (text expected-values)
+  "Test asserter for `org-html-link'.
+TEXT should contain links.  Confirm actual
+text created by `org-html-link' matches
+contains the values in
+EXPECTED-VALUES."
+  (org-test-with-temp-text (format "#+options: toc:nil\n%s" text)
+                           (let ((export-buffer "*Test HTML Export*")
+                                 (org-export-show-temporary-export-buffer nil))
+                             (org-export-to-buffer 'html export-buffer)
+                             (with-current-buffer export-buffer
+                               (mapc (lambda (expected-value)
+                                       (should (string-match-p (regexp-quote expected-value)
+                                                               (buffer-substring-no-properties
+		                                                (point-min)
+                                                                (point-max)))))
+                                     expected-values)))))
+
+(ert-deftest ox-html/relative-links ()
+  "Test `org-html-link' relative links."
+  (test-ox-html-relative-links-asserter
+   "[[rel-url:examples/babel.org][babel org file]]
+[[rel-url:examples/babel.el]]"
+   (list "<a href=\"examples/babel.org\">babel org file</a>"
+         "<a href=\"examples/babel.el\">examples/babel.el</a>"))
+
+  (test-ox-html-relative-links-asserter
+   "[[rel-url:/en/install-emacs-on-android][install Emacs]]
+[[rel-url:software/emacs/manual/html_node/elisp/Clickable-Text.html][Clickable Text]]"
+   (list "<a href=\"/en/install-emacs-on-android\">install Emacs</a>"
+         "<a href=\"software/emacs/manual/html_node/elisp/Clickable-Text.html\">Clickable Text</a>"))
+
+  ;; Works with <> links also
+  (test-ox-html-relative-links-asserter
+   "[[rel-url:/orgmode/87y0ijp82r.fsf@localhost/][org-meetup notes]]
+<rel-url:cgit/org-mode.git/log/>
+[[rel-url:../x][a hypertext anchor]]"
+   (list "<a href=\"/orgmode/87y0ijp82r.fsf@localhost/\">org-meetup notes</a>"
+         "<a href=\"cgit/org-mode.git/log/\">cgit/org-mode.git/log/</a>"
+         "<a href=\"../x\">a hypertext anchor</a>"))
+
+  (test-ox-html-relative-links-asserter
+   "<rel-url:/en-US/docs/Web/HTML>
+[[rel-url:#absolute-urls][Jump to Absolute URLs section]]
+[[rel-url:?q=url+query+string][Search for url query string]]"
+   (list "<a href=\"/en-US/docs/Web/HTML\">/en-US/docs/Web/HTML</a>"
+         "<a href=\"#absolute-urls\">Jump to Absolute URLs section</a>"
+         "<a href=\"?q=url+query+string\">Search for url query string</a>")))
+
 (provide 'test-ox-html)
 ;;; test-ox-html.el ends here
diff --git a/testing/lisp/test-ox-md.el b/testing/lisp/test-ox-md.el
index e1e1e4e2d..b499f0fc6 100644
--- a/testing/lisp/test-ox-md.el
+++ b/testing/lisp/test-ox-md.el
@@ -35,7 +35,7 @@
 *** level2
     lorem ipsum
 ** Footnotes
-[fn:1] a footnote 
+[fn:1] a footnote
 "
     (let ((org-md-toplevel-hlevel 4)
           (export-buffer "*Test MD Export*")
@@ -136,6 +136,56 @@
         (should (search-forward "[babel org file](examples/babel.org)"))
         (should (search-forward "[babel script](examples/babel.el)"))))))
 
+(defun test-ox-md-relative-links-asserter (text expected)
+  "Test asserter for `org-md-link'.
+TEXT should contain links.  Confirm actual
+text created by `org-md-link' matches
+EXPECTED text."
+  (org-test-with-temp-text (format "#+options: toc:nil\n%s" text)
+                           (let ((export-buffer "*Test MD Export*")
+                                 (org-export-show-temporary-export-buffer nil)
+                                 (org-md-link-org-files-as-md nil))
+                             (org-export-to-buffer 'md export-buffer)
+                             (with-current-buffer export-buffer
+                               (should (string-equal (org-trim (buffer-substring-no-properties
+		                                                (point-min) (point-max)))
+                                                     expected))))))
+
+(ert-deftest ox-md/relative-links ()
+  "Test `org-md-link' relative links."
+  (test-ox-md-relative-links-asserter
+   "[[rel-url:examples/babel.org][babel org file]]
+[[rel-url:examples/babel.el]]"
+   "[babel org file](examples/babel.org)
+[examples/babel.el](examples/babel.el)")
+
+  (test-ox-md-relative-links-asserter
+   "[[rel-url:/en/install-emacs-on-android][install Emacs]]
+[[rel-url:software/emacs/manual/html_node/elisp/Clickable-Text.html][Clickable Text]]"
+   "[install Emacs](/en/install-emacs-on-android)
+[Clickable Text](software/emacs/manual/html_node/elisp/Clickable-Text.html)")
+
+  (test-ox-md-relative-links-asserter
+   "[[rel-url:cgit/org-mode.git/log/]]
+[[rel-url:?q=url+query+string][Search for url query string]]"
+   "[cgit/org-mode.git/log/](cgit/org-mode.git/log/)
+[Search for url query string](?q=url+query+string)")
+
+  ;; Works with <> links also
+  (test-ox-md-relative-links-asserter
+   "[[rel-url:/orgmode/87y0ijp82r.fsf@localhost/][org-meetup notes]]
+<rel-url:api/v1/path/>"
+   "[org-meetup notes](/orgmode/87y0ijp82r.fsf@localhost/)
+[api/v1/path/](api/v1/path/)")
+
+  (test-ox-md-relative-links-asserter
+   "<rel-url:/account/history/abc.html>
+[[rel-url:/en-US/docs/Web/HTML][Origin-relative URL]]
+[[rel-url:#absolute-urls][Jump to Absolute URLs section]]"
+   "[/account/history/abc.html](/account/history/abc.html)
+[Origin-relative URL](/en-US/docs/Web/HTML)
+[Jump to Absolute URLs section](#absolute-urls)"))
+
 (ert-deftest ox-md/headline-priority ()
   "Test formatting of headlines with priority."
   (let ((export-buffer "*Test MD Export*")
-- 
2.54.0

Reply via email to