>So, not going 100% backwards-compatible might be ok-ish.
>However, please do announce changes in the return value in the ORG-NEWS.

>Let's put :package-version '(Org . "10.0") here to indicate that the meaning
> slightly changed.

>Here and elsewhere, please use =...= for verbatim Org markup example, not 
>~...~.

>Same problem with export. Use #+begin_example

Hi Ihor, all of the above comments should have been addressed in the patch.
Please let me know if you have any other thoughts.


On Sun, Mar 1, 2026 at 6:42 PM Ihor Radchenko <[email protected]> wrote:
>
> Lei Zhe <[email protected]> writes:
>
> > Sorry for the late reply.
> > Please check the updated patch.
>
> Thanks!
>
> >>> 2. in ORG-NEWS, we need to document that
> >>> org-babel-tangle-use-relative-file-links is ignored
> > Done.
>
> There are some in org-manual.org. This is minor though.
> I will provide some more comments on the manual and other parts of the
> patch below.
>
> >>> 4. *** New function ~org-babel-tangle--compute-targets~
> >>>   is not needed. We do not announce changes and additions to the
> >>>    internal functions (with "--" in their name)
> > I have removed it from the patch.
> >
> >>> 5. We are introducing a breaking change in the return value of
> >>>     `org-babel-tangle-single-block'. As discussed earlier, we should
> >>>    probably introduce a new optional argument ALLOW-MULTIPLE to
> >>>    preserve backwards-compatibility as much as possible.
> > Since relative links are handled by guarding the logic in
> > `org-babel-tangle--unbracketed-link`, there should be no compatibility
> > issues with the current approach.
>
> I am mostly worried about third-party code.
> I have checked the code on github, and, luckily, it does not look like
> anything will be broken - most code uses
> (car (org-babel-tangle-single-block ... 'only-this-block))
>
> So, not going 100% backwards-compatible might be ok-ish.
> However, please do announce changes in the return value in the ORG-NEWS.
>
> > +  Example:
> > +  #+begin_src emacs-lisp :tangle '("config.el" "backup.el") 
> > :tangle-directory '("~/.config" "/backup")
> > +  (message "Tangling to multiple targets specified by :tangle and 
> > :tangle-directory")
> > +  #+end_src
>
> This is not right. The code block will be exported without header
> arguments into the manual. Please do
> #+begin_example
> ,#+begin_src ...
> ...
> #+end_example
>
> > +Source blocks can now be tangled to multiple target files using
> > +the ~:tangle~ header argument.  The following forms are now supported:
> > +
> > +- ~:tangle "file1.el"~ (existing behavior)
> > +- ~:tangle '("file1.el" "file2.el")~ (new: list of files)
> > +- ~:tangle (get-tangle-targets)~ (new: function returning paths)
> > +- ~:tangle 'tangle-targets~ (new: variable returning paths)
>
> Here and elsewhere, please use =...= for verbatim Org markup example, not 
> ~...~.
>
> > +Example:
> > +#+begin_src emacs-lisp :tangle '("config.el" "backup.el") 
> > :tangle-directory '("~/.config" "/backup")
> > +(message "Tangling to multiple targets specified by :tangle and 
> > :tangle-directory")
> > +#+end_src
>
> Same problem with export. Use #+begin_example
>
> >  (defcustom org-babel-tangle-use-relative-file-links t
> > -  "Use relative path names in links from tangled source back the Org file."
> > +  "Use relative path names in links from tangled source back the Org file.
> > +
> > +Note that relative links are not used when a code block is tangled into
> > +multiple target files."
> >    :group 'org-babel-tangle
> >    :type 'boolean)
>
> Let's put :package-version '(Org . "10.0") here to indicate that the meaning
> slightly changed.
>
> All the above are minor changes. Otherwise, the patch is ready to be
> merged.
>
> --
> Ihor Radchenko // yantar92,
> Org mode maintainer,
> Learn more about Org mode at <https://orgmode.org/>.
> Support Org development at <https://liberapay.com/org-mode>,
> or support my work at <https://liberapay.com/yantar92>
From d4cd5eefcfc49673dae0b0b8cfb3457125751048 Mon Sep 17 00:00:00 2001
From: llcc <[email protected]>
Date: Fri, 4 Apr 2025 20:47:11 +0800
Subject: [PATCH] ob-tangle.el: Support tangling a source block to multiple
 targets

* lisp/ob-tangle.el (org-babel-tangle--compute-targets): New function
to compute multiple target file paths for a source block, supporting
the new :tangle-directory header argument.
(org-babel-tangle-collect-blocks): Refactor to handle the new nested
list structure that supports multiple tangle targets per block.
(org-babel-tangle-single-block): Modify to return a list of file-block
pairs instead of a single pair, enabling multiple target support.
(org-babel-tangle--unbracketed-link): Add guard for stringp check to
handle list vaulues in :tangle parameter.

* testing/lisp/test-ob-tangle.el (ob-tangle/collect-blocks): New test
helper function and variable.
(ob-tangle/collect-blocks): Extend tests to cover :tangle-directory
header and multiple tangle targets.
---
 doc/org-manual.org             | 46 ++++++++++++++++--
 etc/ORG-NEWS                   | 54 +++++++++++++++++++++
 lisp/ob-tangle.el              | 87 ++++++++++++++++++++++++----------
 testing/lisp/test-ob-tangle.el | 57 ++++++++++++++++++----
 4 files changed, 206 insertions(+), 38 deletions(-)

diff --git a/doc/org-manual.org b/doc/org-manual.org
index 9c4c27877..601eabdc1 100644
--- a/doc/org-manual.org
+++ b/doc/org-manual.org
@@ -19653,10 +19653,48 @@ to source file(s).
 
 - {{{var(FILENAME)}}} ::
 
-  Export the code block to source file whose file name is derived from
-  any string passed to the =tangle= header argument.  Org derives the
-  file name as being relative to the directory of the Org file's
-  location.  Example: =:tangle FILENAME=.
+  Export the code block to source file(s) whose file name(s) are
+  derived from the value passed to the =tangle= header argument.  Org
+  derives the file names as being relative to the directory of the Org
+  file's location, or relative to the directory specified by the
+  =tangle-directory= header argument if provided.
+
+  The FILENAME can take several forms:
+  - A single file name (string) ::
+
+    Example: =:tangle script.py=.
+    
+  - A list of file names ::
+
+    Tangle the block to multiple files.  Example: =:tangle
+    '("src/main.py" "src/utils.py")=.
+    
+  - A variable or function call ::
+
+    Evaluate to get the target file name(s).  Example: =:tangle
+    'tangle-targets= or =:tangle (get-tangle-targets)=.
+
+  When both =:tangle-directory= and multiple =:tangle= files are
+  specified, the block is tangled to all combinations of directories
+  and files.
+
+  Example:
+  #+begin_example
+  ,#+begin_src emacs-lisp :tangle '("config.el" "backup.el") :tangle-directory '("~/.config" "/backup")
+  (message "Tangling to multiple targets specified by :tangle and :tangle-directory")
+  ,#+end_src
+  #+end_example
+
+  This tangles the block to four files:
+  - ~/.config/config.el
+  - ~/.config/backup.el
+  - /backup/config.el
+  - /backup/backup.el
+  
+#+cindex: @samp{tangle-directory}, header argument
+The =tangle-directory= header argument specifies one or more base
+directories for relative tangle file paths. This works similarly to
+=tangle= and accepts the same types of values.
 
 #+cindex: comment trees
 Additionally, code blocks inside commented subtrees (see [[*Comment
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index 40fa1e6aa..b1e464dc4 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -30,6 +30,18 @@ Please send Org bug reports to mailto:[email protected].
 The "Tags and Priorities" section of the global context menu is split
 into a "Tags" section and a "Priorities" section.
 
+*** ~org-babel-tangle-single-block~ may return multiple target files
+
+When called with non-nil ONLY-THIS-BLOCK,
+~org-babel-tangle-single-block~ can now return multiple target file
+entries if a source block tangles to more than one file.
+
+Previously, the returned list always contained exactly one element.
+The return value structure is unchanged, but callers should no longer
+assume that the outer list has length one, which means ~(car
+(org-babel-tangle-single-block ...))~ is not guaranteed to return
+the correct ~(FILE . ...)~ cons.
+
 ** New features
 
 # We list the most important features, and the features that may
@@ -40,6 +52,40 @@ into a "Tags" section and a "Priorities" section.
 Priorities can now be increased, decreased, set to the default, and
 set interactively from the priority context menus.
 
+*** ob-tangle.el now supports tangling to multiple targets
+
+Source blocks can now be tangled to multiple target files using
+the ~:tangle~ header argument.  The following forms are now supported:
+
+- =:tangle "file1.el"= (existing behavior)
+- =:tangle '("file1.el" "file2.el")= (new: list of files)
+- =:tangle (get-tangle-targets)= (new: function returning paths)
+- =:tangle 'tangle-targets= (new: variable returning paths)
+
+Additionally, the new ~:tangle-directory~ header argument specifies
+base directories for relative tangle paths:
+
+- =:tangle-directory "/tmp"= (single directory)
+- =:tangle-directory '("/tmp/a" "/tmp/b")= (multiple directories)
+- =:tangle-directory (get-tangle-dirs)= (function returning directories)
+- =:tangle-directory 'tangle-dirs= (variable returning directories)
+
+When both ~:tangle-directory~ and multiple ~:tangle~ files are specified,
+the block is tangled to all combinations of directories and files.
+
+Example:
+#+begin_example
+,#+begin_src emacs-lisp :tangle '("config.el" "backup.el") :tangle-directory '("~/.config" "/backup")
+(message "Tangling to multiple targets specified by :tangle and :tangle-directory")
+,#+end_src
+#+end_example
+
+This tangles the block to four files:
+- ~/.config/config.el
+- ~/.config/backup.el
+- /backup/config.el
+- /backup/backup.el
+
 ** New and changed options
 
 # Changes dealing with changing default values of customizations,
@@ -76,6 +122,14 @@ showed =0%= when a non-zero amount have been completed, e.g. 1/101.
 Those cases now show =1%= instead.  The same is true for the checkbox
 status summary type =X%= when defining columns for column view.
 
+*** ~org-babel-tangle-use-relative-file-links~ is ignored during tangling when =:tangle= specifies multiple targets.
+
+Previously, when ~org-babel-tangle-use-relative-file-links~ was
+non-nil, Org Babel attempted to generate relative commented links in
+tangled files.  This behavior no longer applies when =:tangle=
+specifies multiple targets; in that case, absolute links are always
+used and the variable is ignored.
+
 * Version 9.8
 ** Important announcements and breaking changes
 
diff --git a/lisp/ob-tangle.el b/lisp/ob-tangle.el
index 6fe553469..6e4ae07da 100644
--- a/lisp/ob-tangle.el
+++ b/lisp/ob-tangle.el
@@ -72,8 +72,12 @@ then the name of the language is used."
   :safe t)
 
 (defcustom org-babel-tangle-use-relative-file-links t
-  "Use relative path names in links from tangled source back the Org file."
+  "Use relative path names in links from tangled source back the Org file.
+
+Note that relative links are not used when a code block is tangled into
+multiple target files."
   :group 'org-babel-tangle
+  :package-version '(Org . "10.0")
   :type 'boolean)
 
 (defcustom org-babel-post-tangle-hook nil
@@ -490,7 +494,6 @@ source code blocks by languages matching a regular expression.
 Optional argument TANGLE-FILE can be used to limit the collected
 code blocks by target file."
   (let ((counter 0)
-        (buffer-fn (buffer-file-name (buffer-base-buffer)))
         last-heading-pos blocks)
     (org-babel-map-src-blocks (buffer-file-name)
       (let ((current-heading-pos
@@ -504,26 +507,22 @@ code blocks by target file."
 	  (setq last-heading-pos current-heading-pos)))
       (unless (or (org-in-commented-heading-p)
 		  (org-in-archived-heading-p))
-	(let* ((info (org-babel-get-src-block-info 'no-eval))
-	       (src-lang (nth 0 info))
-	       (src-tfile (cdr (assq :tangle (nth 2 info)))))
-	  (unless (or (string= src-tfile "no")
-                      ;; src block without lang
-                      (and (not src-lang) (string= src-tfile "yes"))
-		      (and tangle-file (not (equal tangle-file src-tfile)))
-                      ;; lang-re but either no lang or lang doesn't match
-		      (and lang-re
-                           (or (not src-lang)
-                               (not (string-match-p lang-re src-lang)))))
-	    ;; Add the spec for this block to blocks under its tangled
-	    ;; file name.
-	    (let* ((block (org-babel-tangle-single-block counter))
-                   (src-tfile (cdr (assq :tangle (nth 4 block))))
-		   (file-name (org-babel-effective-tangled-filename
-                               buffer-fn src-lang src-tfile))
-		   (by-fn (assoc file-name blocks)))
-	      (if by-fn (setcdr by-fn (cons (cons src-lang block) (cdr by-fn)))
-		(push (cons file-name (list (cons src-lang block))) blocks)))))))
+        (dolist (block (org-babel-tangle-single-block counter t))
+          (let ((src-file (car block))
+                (src-lang (caadr block)))
+            (unless (or (not src-file)
+                        ;; src block without lang
+                        (and (not src-lang) src-file)
+                        (and tangle-file (not (equal tangle-file src-file)))
+                        ;; lang-re but either no lang or lang doesn't match
+                        (and lang-re
+                             (or (not src-lang)
+                                 (not (string-match-p lang-re src-lang)))))
+              (setq blocks
+                    (mapcar (lambda (group)
+                              (cons (car group)
+                                    (apply #'append (mapcar #'cdr (cdr group)))))
+                            (seq-group-by #'car (push block blocks)))))))))
     ;; Ensure blocks are in the correct order.
     (mapcar (lambda (b) (cons (car b) (nreverse (cdr b))))
 	    (nreverse blocks))))
@@ -540,6 +539,7 @@ The PARAMS are the 3rd element of the info for the same src block."
                         (match-string 1 l))))
         (when bare
           (if (and org-babel-tangle-use-relative-file-links
+                   (stringp (cdr (assq :tangle params)))
                    (string-match org-link-types-re bare)
                    (string= (match-string 1 bare) "file"))
               (concat "file:"
@@ -549,6 +549,41 @@ The PARAMS are the 3rd element of the info for the same src block."
             bare))))))
 
 (defvar org-outline-regexp) ; defined in lisp/org.el
+
+(defun org-babel-tangle--compute-targets (buffer-fn info)
+  "Compute the list of target file paths for tangling a source block.
+
+BUFFER-FN is the absolute file name of the source buffer.  INFO is the
+source block information, as returned by `org-babel-get-src-block-info'."
+  (let* ((params         (nth 2 info))
+         (lang           (nth 0 info))
+         (tangle-dir-raw (cdr (assq :tangle-directory params)))
+         (tangle-targets (cdr (assq :tangle params)))
+         (tangle-dirs    (ensure-list tangle-dir-raw))
+         (tangle-files
+          (cond
+           ((and (stringp tangle-targets) (string= tangle-targets "yes"))
+            ;; Default to buffer name if :tangle yes
+            (list (file-name-nondirectory
+                   (org-babel-effective-tangled-filename buffer-fn lang tangle-targets))))
+           ((and (stringp tangle-targets) (string= tangle-targets "no")) nil)
+           (t (ensure-list tangle-targets)))))
+
+    (when tangle-files
+      (setq tangle-files
+            (cl-loop for file in tangle-files append
+                     (if (file-name-absolute-p file)
+                         (list file) ;; absolute paths stay as is
+                       (if tangle-dirs
+                           (mapcar (lambda (dir) (expand-file-name file dir)) tangle-dirs)
+                         (list file))))))
+
+    ;; Normalize final paths
+    (cl-remove-duplicates
+     (mapcar (lambda (file)
+               (org-babel-effective-tangled-filename buffer-fn lang file))
+             tangle-files))))
+
 (defun org-babel-tangle-single-block (block-counter &optional only-this-block)
   "Collect the tangled source for current block.
 Return the list of block attributes needed by
@@ -616,7 +651,6 @@ non-nil, return the full association list to be used by
 			 (match-end 0)
 		       (point-min))))
 	      (point)))))
-         (src-tfile (cdr (assq :tangle params)))
 	 (result
 	  (list start-line
 		(if org-babel-tangle-use-relative-file-links
@@ -629,9 +663,10 @@ non-nil, return the full association list to be used by
 		  (org-trim (org-remove-indentation body)))
 		comment)))
     (if only-this-block
-        (let* ((file-name (org-babel-effective-tangled-filename
-                           file src-lang src-tfile)))
-          (list (cons file-name (list (cons src-lang result)))))
+        (let* ((file-names (org-babel-tangle--compute-targets file info)))
+          (mapcar (lambda (file-name)
+                    (cons file-name (list (cons src-lang result))))
+                  file-names))
       result)))
 
 (defun org-babel-tangle-comment-links (&optional info)
diff --git a/testing/lisp/test-ob-tangle.el b/testing/lisp/test-ob-tangle.el
index 640d910b5..a65b1f758 100644
--- a/testing/lisp/test-ob-tangle.el
+++ b/testing/lisp/test-ob-tangle.el
@@ -579,6 +579,14 @@ another block
         (set-buffer-modified-p nil))
       (kill-buffer buffer))))
 
+(defun ob-tangle/tangle-targets ()
+  "Return tangle targets for testing."
+  '("relative.el" "/tmp/absolute.el"))
+
+(defvar ob-tangle/tangle-targets
+  '("relative.el" "/tmp/absolute.el")
+  "Tangle targets variable for testing.")
+
 (ert-deftest ob-tangle/collect-blocks ()
   "Test block collection into groups for tangling."
   (org-test-with-temp-text-in-file "" ; filled below, it depends on temp file name
@@ -628,6 +636,18 @@ another block
 \"H1: no language and inherited :tangle relative.el in properties\"
 #+end_src
 
+#+begin_src emacs-lisp :tangle '(\"relative.el\" \"/tmp/absolute.el\")
+\"H1: :tangle relative.el and /tmp/absolute.el\"
+#+end_src
+
+#+begin_src emacs-lisp :tangle 'ob-tangle/tangle-targets
+\"H1: :tangle relative.el and /tmp/absolute.el\"
+#+end_src
+
+#+begin_src emacs-lisp :tangle (ob-tangle/tangle-targets)
+\"H1: :tangle relative.el and /tmp/absolute.el\"
+#+end_src
+
 * H2 without :tangle in properties
 
 #+begin_src emacs-lisp
@@ -664,7 +684,19 @@ another block
 
 #+begin_src
 \"H2: without language and thus without :tangle\"
-#+end_src"
+#+end_src
+
+* H3 with :tangle-directory
+
+#+begin_src emacs-lisp :tangle-directory /tmp/a :tangle '(\"foo.el\" \"bar.el\") :mkdirp yes
+\"H3: :tangle /tmp/foo.el and /tmp/bar.el\"
+#+end_src
+
+#+begin_src emacs-lisp :tangle-directory '(\"/tmp/a\" \"/tmp/b\") :tangle '(\"foo.el\" \"bar.el\") :mkdirp yes
+\"H3: :tangle /tmp/a/foo.el, /tmp/a/bar.el, /tmp/b/foo.el and /tmp/b/bar.el\"
+#+end_src
+
+"
                     `((?a . ,el-file-abs)
                       (?r . ,el-file-rel))))
       ;; We check the collected blocks to tangle by counting equal
@@ -687,10 +719,15 @@ another block
                                               ;; From `org-babel-tangle-collect-blocks'.
                                               collected-blocks)))))
         (should (equal (funcall normalize-expected-targets-alist
-                                `(("/tmp/absolute.el" . 4)
-                                  ("relative.el" . 6)
+                                `(("/tmp/absolute.el" . 7)
+                                  ("/tmp/a/foo.el" . 2)
+                                  ("/tmp/a/bar.el" . 2)
+                                  ("/tmp/b/foo.el" . 1)
+                                  ("/tmp/b/bar.el" . 1)
+                                  ("relative.el" . 8)
                                   ;; file name differs between tests
-                                  (,el-file-abs . 4)))
+                                  (,el-file-abs . 4)
+                                  ))
                        (funcall count-blocks-in-target-files
                                 (org-babel-tangle-collect-blocks))))
         ;; Simulate TARGET-FILE to test as `org-babel-tangle' and
@@ -702,12 +739,16 @@ another block
                 (list (cons :tangle el-file-abs)))))
           (should (equal
                    (funcall normalize-expected-targets-alist
-                            `(("/tmp/absolute.el" . 4)
-                              ("relative.el" . 6)
+                            `(("/tmp/absolute.el" . 7)
+                              ("/tmp/a/foo.el" . 2)
+                              ("/tmp/a/bar.el" . 2)
+                              ("/tmp/b/foo.el" . 1)
q+                              ("/tmp/b/bar.el" . 1)
+                              ("relative.el" . 8)
                               ;; Default :tangle header now also
                               ;; points to the file name derived from the name of
-                              ;; the Org file, so 6 blocks should go there.
-                              (,el-file-abs . 6)))
+                              ;; the Org file, so 5 blocks should go there.
+                              (,el-file-abs . 5)))
                    (funcall count-blocks-in-target-files
                             (org-babel-tangle-collect-blocks)))))))))
 
-- 
2.50.1 (Apple Git-155)

Reply via email to