diff --git a/deps/cli/src/logseq/cli/common/export/text.cljs b/deps/cli/src/logseq/cli/common/export/text.cljs index 9edff27868..4b841b3579 100644 --- a/deps/cli/src/logseq/cli/common/export/text.cljs +++ b/deps/cli/src/logseq/cli/common/export/text.cljs @@ -21,8 +21,8 @@ (declare inline-ast->simple-ast block-ast->simple-ast) -(defn- block-heading - [{:keys [title _tags marker level _numbering priority _anchor _meta _unordered size]}] +(defn- block-heading-prefix + [{:keys [marker level priority size]}] (let [indent-style (get-in *state* [:export-options :indent-style]) priority* (and priority (raw-text (cli-export-common/priority->string priority))) heading* (if (= indent-style "dashes") @@ -31,23 +31,80 @@ size* (and size [space (raw-text (reduce str (repeat size "#")))]) marker* (and marker (raw-text marker))] (set! *state* (assoc *state* :current-level level)) - (let [simple-asts - (removev nil? (concatv - (when (and (get-in *state* [:export-options :newline-after-block]) - (not (get-in *state* [:newline-after-block :current-block-is-first-heading-block?]))) - [(newline* 2)]) - heading* size* - [space marker* space priority* space] - (mapcatv inline-ast->simple-ast title) - [(newline* 1)]))] + (removev nil? (concatv heading* size* + [space marker* space priority* space])))) + +(defn- heading-continuation-indent + [{:keys [level]}] + (case (get-in *state* [:export-options :indent-style]) + "dashes" (indent (dec level) 2) + ("spaces" "no-indent") (indent (dec level) 0) + (assert false (print-str "unknown indent-style:" (get-in *state* [:export-options :indent-style]))))) + +(defn- block-heading + [{:keys [title] :as heading}] + (let [simple-asts + (removev nil? (concatv + (when (and (get-in *state* [:export-options :newline-after-block]) + (not (get-in *state* [:newline-after-block :current-block-is-first-heading-block?]))) + [(newline* 2)]) + (block-heading-prefix heading) + (mapcatv inline-ast->simple-ast title) + [(newline* 1)]))] (set! *state* (assoc-in *state* [:newline-after-block :current-block-is-first-heading-block?] false)) - simple-asts))) + simple-asts)) (declare block-list) + +(defn- list-continuation-indent + [current-level] + (indent-with-2-spaces (dec current-level))) + +(defn- src-in-list-item + [{:keys [lines language]} continuation-indent] + (concatv [(raw-text "```")] + (when language [(raw-text language)]) + [(newline* 1)] + (mapv raw-text lines) + [continuation-indent (raw-text "```") (newline* 1)])) + +(defn- quote-line + [line] + (let [line (string/trimr line)] + (if (string/blank? line) + ">" + (str "> " line)))) + +(defn- quote-in-list-item + [block-coll continuation-indent] + (let [lines (->> block-coll + (mapcatv block-ast->simple-ast) + simple-asts->string + string/split-lines) + lines (if (seq lines) lines [""])] + (mapcatv (fn [idx line] + (concatv (when (pos? idx) [continuation-indent]) + [(raw-text (quote-line line)) + (newline* 1)])) + (range) + lines))) + +(defn- block-level-content-in-list-item + [content continuation-indent] + (when (= 1 (count content)) + (let [[ast-type ast-content] (first content)] + (case ast-type + "Src" + (src-in-list-item ast-content continuation-indent) + + "Quote" + (quote-in-list-item ast-content continuation-indent) + + nil)))) + (defn- block-list-item [{:keys [content items number _name checkbox]}] - (let [content* (mapcatv block-ast->simple-ast content) - number* (raw-text + (let [number* (raw-text (if number (str number ". ") "* ")) @@ -59,6 +116,9 @@ current-level (get *state* :current-level 1) indent' (when (> current-level 1) (indent (dec current-level) 0)) + continuation-indent (list-continuation-indent current-level) + content* (or (block-level-content-in-list-item content continuation-indent) + (mapcatv block-ast->simple-ast content)) items* (block-list items :in-list? true)] (concatv [indent' number* checkbox* space] content* @@ -114,29 +174,59 @@ (mapv (fn [line] (string/replace-first line pattern "")) lines))) (defn- block-src - [{:keys [lines language]}] + [{:keys [lines language]} {:keys [heading-prefix]}] (let [level (dec (get *state* :current-level 1)) lines* (if (= "no-indent" (get-in *state* [:export-options :indent-style])) (remove-max-prefix-spaces lines) lines)] - (concatv - [(indent-with-2-spaces level) (raw-text "```")] - (when language [(raw-text language)]) - [(newline* 1)] - (mapv raw-text lines*) - [(indent-with-2-spaces level) (raw-text "```") (newline* 1)]))) + (if heading-prefix + (concatv + (block-heading-prefix heading-prefix) + [(raw-text "```")] + (when language [(raw-text language)]) + [(newline* 1)] + (mapv raw-text lines*) + [(heading-continuation-indent heading-prefix) (raw-text "```") (newline* 1)]) + (concatv + [(indent-with-2-spaces level) (raw-text "```")] + (when language [(raw-text language)]) + [(newline* 1)] + (mapv raw-text lines*) + [(indent-with-2-spaces level) (raw-text "```") (newline* 1)])))) + +(defn- quote-block-lines + [block-coll] + (let [lines (->> block-coll + (mapcatv block-ast->simple-ast) + simple-asts->string + string/split-lines)] + (if (seq lines) lines [""]))) + +(defn- quote-lines-with-prefix + [lines prefix continuation-indent] + (mapcatv (fn [idx line] + (concatv (if (zero? idx) prefix [continuation-indent]) + [(raw-text (quote-line line)) + (newline* 1)])) + (range) + lines)) (defn- block-quote - [block-coll] + [block-coll {:keys [heading-prefix]}] (let [level (dec (get *state* :current-level 1))] - (binding [*state* (assoc *state* :indent-after-break-line? true)] - (concatv (mapcatv (fn [block] - (let [block-simple-ast (block-ast->simple-ast block)] - (when (seq block-simple-ast) - (concatv [(indent-with-2-spaces level) (raw-text ">") space] - block-simple-ast)))) - block-coll) - [(newline* 2)])))) + (if heading-prefix + (binding [*state* (assoc *state* :indent-after-break-line? true)] + (quote-lines-with-prefix (quote-block-lines block-coll) + (block-heading-prefix heading-prefix) + (heading-continuation-indent heading-prefix))) + (binding [*state* (assoc *state* :indent-after-break-line? true)] + (concatv (mapcatv (fn [block] + (let [block-simple-ast (block-ast->simple-ast block)] + (when (seq block-simple-ast) + (concatv [(indent-with-2-spaces level) (raw-text ">") space] + block-simple-ast)))) + block-coll) + [(newline* 2)]))))) (declare inline-latex-fragment) (defn- block-latex-fragment @@ -369,9 +459,9 @@ "Example" (block-example ast-content) "Src" - (block-src ast-content) + (block-src ast-content (meta block)) "Quote" - (block-quote ast-content) + (block-quote ast-content (meta block)) "Latex_Fragment" (block-latex-fragment ast-content) "Latex_Environment" @@ -489,5 +579,19 @@ ast*** (if-not (empty? config-for-walk-block-ast) (mapv (partial cli-export-common/walk-block-ast config-for-walk-block-ast) ast**) ast**) - simple-asts (mapcatv block-ast->simple-ast ast***)] - (simple-asts->string simple-asts))))) \ No newline at end of file + ast**** (loop [remaining ast*** + result []] + (if-let [block (first remaining)] + (let [[ast-type ast-content] block + next-block (second remaining) + [next-ast-type] next-block] + (if (and (= "Heading" ast-type) + (empty? (:title ast-content)) + (contains? #{"Quote" "Src"} next-ast-type)) + (recur (nnext remaining) + (conj result (with-meta next-block (assoc (meta next-block) :heading-prefix ast-content)))) + (recur (rest remaining) + (conj result block)))) + result)) + simple-asts (mapcatv block-ast->simple-ast ast****)] + (simple-asts->string simple-asts))))) diff --git a/deps/cli/src/logseq/cli/common/file.cljs b/deps/cli/src/logseq/cli/common/file.cljs index 844d176fba..6517708316 100644 --- a/deps/cli/src/logseq/cli/common/file.cljs +++ b/deps/cli/src/logseq/cli/common/file.cljs @@ -126,6 +126,64 @@ (or (property-value-block-content db b context) (db-content/recur-replace-uuid-in-block-title (d/entity db (:db/id b))))) +(defn- bounded-heading-level + [heading level] + (cond + (integer? heading) + (-> heading (max 1) (min 6)) + + (true? heading) + (min (inc level) 6) + + :else + nil)) + +(defn- strip-heading-prefix + [content] + (-> (string/replace content #"^\s?#+\s+" "") + (string/replace #"^\s?#+\s?$" ""))) + +(defn- quote-content + [content] + (->> (or (seq (string/split-lines content)) [""]) + (map (fn [line] + (if (string/blank? line) + ">" + (str "> " line)))) + (string/join "\n"))) + +(defn- code-fence + [content] + (apply str (repeat (max 3 (inc (apply max 0 (map count (re-seq #"`+" content))))) "`"))) + +(defn- fenced-code-content + [content lang] + (let [fence (code-fence content)] + (str fence (when-not (string/blank? lang) lang) + "\n" content "\n" fence))) + +(defn- displayed-math-content + [content] + (str "$$\n" content "\n$$")) + +(defn- format-markdown-block-content + [b content level heading-to-list?] + (let [content (or content "")] + (case (:logseq.property.node/display-type b) + :quote + (quote-content content) + + :code + (fenced-code-content content (:logseq.property.code/lang b)) + + :math + (displayed-math-content content) + + (if-let [heading-level (and (not heading-to-list?) + (bounded-heading-level (:logseq.property/heading b) level))] + (str (apply str (repeat heading-level "#")) " " (strip-heading-prefix content)) + content)))) + (defn- transform-content [db b level {:keys [heading-to-list? include-properties?] :or {include-properties? true}} context] @@ -144,9 +202,8 @@ prefix (str spaces-tabs "-") property-spaces-tabs (str spaces-tabs " ") content (if heading-to-list? - (-> (string/replace content #"^\s?#+\s+" "") - (string/replace #"^\s?#+\s?$" "")) - content) + (strip-heading-prefix content) + (format-markdown-block-content b content level heading-to-list?)) new-content (indented-block-content (string/trim content) property-spaces-tabs) sep (if (string/blank? new-content) "" diff --git a/src/test/frontend/handler/export_test.cljs b/src/test/frontend/handler/export_test.cljs index df510b7208..807b8022c2 100644 --- a/src/test/frontend/handler/export_test.cljs +++ b/src/test/frontend/handler/export_test.cljs @@ -15,7 +15,11 @@ uuid-5 #uuid "708f7836-c1e2-4212-bd26-b53c7e9f1449" uuid-6 #uuid "de7724d5-b045-453d-a643-31b81d310071" uuid-p3 #uuid "de13830f-9691-4074-a0d6-cc8ab9cf9074" - uuid-7 #uuid "f81f4f64-578a-42ff-8741-19adac45f42a"] + uuid-7 #uuid "f81f4f64-578a-42ff-8741-19adac45f42a" + uuid-p5 #uuid "9dfeae55-c426-4957-8de9-40ff71c622f0" + uuid-8 #uuid "c370c72d-97b8-45f1-8a87-184e1a77792c" + uuid-9 #uuid "253c84fb-bf6f-4936-8370-4662930c8e6d" + uuid-10 #uuid "e6741341-2426-4c46-b09f-6aec73a4357b"] [{:page {:block/title "page1"} :blocks [{:block/title "1" @@ -57,7 +61,25 @@ [{:block/title "issue" :build/keep-uuid? true :block/uuid uuid-7 - :build/properties {:user.property/reproducible-steps "Switch to a password protected graph"}}]}])) + :build/properties {:user.property/reproducible-steps "Switch to a password protected graph"}}]} + {:page {:block/title "page5" + :block/uuid uuid-p5} + :blocks + [{:block/title "Heading block" + :build/keep-uuid? true + :block/uuid uuid-8 + :build/properties {:logseq.property/heading 2}} + {:block/title "quote line 1\nquote line 2" + :build/keep-uuid? true + :block/uuid uuid-9 + :build/tags [:logseq.class/Quote-block] + :build/properties {:logseq.property.node/display-type :quote}} + {:block/title "(println \"hi\")\n(+ 1 2)" + :build/keep-uuid? true + :block/uuid uuid-10 + :build/tags [:logseq.class/Code-block] + :build/properties {:logseq.property.node/display-type :code + :logseq.property.code/lang "clojure"}}]}])) (use-fixtures :once {:before (fn [] @@ -108,6 +130,21 @@ [(uuid "f81f4f64-578a-42ff-8741-19adac45f42a")] {:remove-options #{:property}}))))) +(deftest export-page-as-markdown-preserves-semantic-block-formatting + (is (= (string/trim " +- ## Heading block +- > quote line 1 + > quote line 2 +- ```clojure + (println \"hi\") + (+ 1 2) + ```") + (string/trim + (export-text/export-blocks-as-markdown + (state/get-current-repo) + [(uuid "9dfeae55-c426-4957-8de9-40ff71c622f0")] + {:remove-options #{:property}}))))) + (deftest export-blocks-as-markdown-level (markdown-mirror/ (markdown-mirror/