From e6f83235f791a6d3e5a64747102d0c84f060586a Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 28 Jul 2024 12:48:58 +0200 Subject: [PATCH 1/8] feat: refactor function definitions in clj --- scripts/generate-fun-docs.clj.sh | 28 ++++++++ src/stencil/functions.clj | 102 +++++++++++++++++++++++------ src/stencil/infix.clj | 6 +- src/stencil/model/fragments.clj | 12 +++- src/stencil/postprocess/html.clj | 20 +++++- src/stencil/postprocess/images.clj | 6 +- src/stencil/postprocess/links.clj | 7 +- src/stencil/postprocess/table.clj | 10 ++- 8 files changed, 156 insertions(+), 35 deletions(-) create mode 100644 scripts/generate-fun-docs.clj.sh diff --git a/scripts/generate-fun-docs.clj.sh b/scripts/generate-fun-docs.clj.sh new file mode 100644 index 00000000..8a8fed72 --- /dev/null +++ b/scripts/generate-fun-docs.clj.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env sh +test ; # file is both a valid SH and a valid CLJ file at the same time. + +test ; set -e && cd "$(dirname "$0")/.." && clojure -M -i scripts/generate-fun-docs.clj.sh > target/Functions.md && exit 0 + +;; file is a regular CLJ script from now on + +(println "HALI") + +(require 'stencil.functions) + +(println "# Functions") +(println) +(println "You can call functions from within the template files and embed the call result easily by writing `{%=functionName(arg1, arg2, arg3, ...)%}` expression in the document template.") +(println "This page contains a short description of the functions implemented in Stencil.") +(println) + +(doseq [k (sort (keys (methods stencil.functions/call-fn)))] + (printf "- [%s](#%s)\n" k k)) + +(println) + +(doseq [[k f] (sort (methods stencil.functions/call-fn))] + (printf "## %s\n\n" k) + (when-let [docs (:stencil.functions/docs (meta f))] + (println docs) + (println)) + (println)) diff --git a/src/stencil/functions.clj b/src/stencil/functions.clj index f4c45a42..92d3f09c 100644 --- a/src/stencil/functions.clj +++ b/src/stencil/functions.clj @@ -12,13 +12,27 @@ The rest of the arguments are the function call parameters." (fn [function-name & args-seq] function-name)) -(defmethod call-fn "range" - ([_ x] (range x)) - ([_ x y] (range x y)) - ([_ x y z] (range x y z))) +(defmacro def-stencil-fn [name docs & bodies] + (assert (string? name)) + (assert (string? docs)) + `(.addMethod ^clojure.lang.MultiFn call-fn ~name + ~(with-meta `(fn [_# & args#] (apply (fn ~@bodies) args#)) {::docs docs}))) -(defmethod call-fn "integer" [_ n] (some-> n biginteger)) -(defmethod call-fn "decimal" [_ f] (with-precision 8 (some-> f bigdec))) +(def-stencil-fn "range" + "Creates an array of numbers between bounds, for use in iteration forms. + Parameters are start value (default 0), upper bound, step size (default 1). + Eg.: range(4) = [0, 1, 2, 3], range(2,4) = [2, 3], range(2, 10, 2) = [2, 4, 8]" + ([x] (range x)) + ([x y] (range x y)) + ([x y z] (range x y z))) + +(def-stencil-fn "integer" + "Converts parameter to integer number. Returns null for missing value." + [n] (some-> n biginteger)) + +(def-stencil-fn "decimal" + "Converts parameter to decimal number. Returns null for missing value." + [f] (with-precision 8 (some-> f bigdec))) ;; The format() function calls java.lang.String.format() ;; but it predicts the argument types from the format string and @@ -36,7 +50,14 @@ get-types (fn [p] (or (some (fn [[k v]] (when (= k p) v)) @cache) (doto (get-types p) (->> (swap! cache (fn [c t] (take cache-size (cons [p t] c))))))))] - (defmethod call-fn "formatWithLocale" [_ locale pattern-str & args] + (def-stencil-fn "formatWithLocale" + "Similar to `format()` but first parameter is an IETF Language Tag. + + **Usage:** `formatWithLocale('hu', '%,.2f', number)` + + **Example:** + To format the value of price as a price string: {%=format('$ %(,.2f', price) %}. It may output $ (6,217.58)." + [locale pattern-str & args] (when-not (string? pattern-str) (fail "Format pattern must be a string!" {:pattern pattern-str})) (when (empty? args) @@ -55,28 +76,57 @@ (to-array) (String/format locale pattern-str))))) -(defmethod call-fn "format" [_ pattern-str & args] +(def-stencil-fn "format" + "Calls String.format function." + [pattern-str & args] (apply call-fn "formatWithLocale" (java.util.Locale/getDefault) pattern-str args)) ;; finds first nonempy argument -(defmethod call-fn "coalesce" [_ & args-seq] +(def-stencil-fn "coalesce" + "Accepts any number of arguments, returns the first not-empty value." + [& args-seq] (find-first (some-fn number? true? false? not-empty) args-seq)) -(defmethod call-fn "length" [_ items] (count items)) +(def-stencil-fn "length" + "The `length(x)` function returns the length of the value in `x`: +- Returns the number of characters when `x` is a string. +- Returns the number of elements the `x` is a list/array. +- Returns the number of key/value pairs when `x` is an object/map. +- Returns zero when `x` is `null`." + [items] (count items)) -(defmethod call-fn "contains" [_ item items] - (boolean (some #{(str item)} (map str items)))) +(def-stencil-fn "contains" + "Expects two arguments: a value and a list. Checks if list contains the value. + Usage: contains('myValue', myList)" + [item items] (boolean (some #{(str item)} (map str items)))) -(defmethod call-fn "sum" [_ items] - (reduce + items)) +(def-stencil-fn "sum" + "Expects one number argument containing a list with numbers. Sums up the numbers and returns result. + Usage: sum(myList)" + [items] (reduce + items)) -(defmethod call-fn "list" [_ & elements] (vec elements)) +(def-stencil-fn "list" + "Creates a list collection from the supplied arguments. + Intended to be used with other collections functions." + [& elements] (vec elements)) (defn- lookup [column data] (second (or (find data column) (find data (keyword column))))) -(defmethod call-fn "map" [_ ^String column data] +(def-stencil-fn "map" + "Selects values under a given key in a sequence of maps. + The first parameter is a string which contains what key to select: + - It can be a single key name + - It can be a nested key, separated by `.` character. For example: `outerkey.innerkey` + - It can be used for selecting from multidimensional arrays: `outerkey..innerkey` + + Example use cases with data: `{'items': [{'price': 10, 'name': 'Wood'}, {'price': '20', 'name': 'Stone'}]}` + + - `join(map('name', items), ',')`: to create a comma-separated string of item names. Prints `Wood, Stone`. + - `sum(map('price', items))`: to write the sum of item prices. Prints `30`. + " + [^String column data] (when-not (string? column) (fail "First parameter of map() must be a string!" {})) (reduce (fn [elems p] @@ -92,11 +142,21 @@ data (.split column "\\."))) -(defmethod call-fn "joinAnd" [_ elements ^String separator1 ^String separator2] +(def-stencil-fn "joinAnd" + "Joins a list of items using two separators. + The first separator is used to join the items except for the last item. + The second separator is used to join the last item. + When two items are supplied, then only the second separator is used. + + **Example:** call `joinAnd(xs, ', ', ' and ')` to get `'1, 2, 3 and 4'`." + [elements ^String separator1 ^String separator2] (case (count elements) - 0 "" - 1 (str (first elements)) - (str (clojure.string/join separator1 (butlast elements)) separator2 (last elements)))) + 0 "" + 1 (str (first elements)) + (str (clojure.string/join separator1 (butlast elements)) separator2 (last elements)))) -(defmethod call-fn "replace" [_ text pattern replacement] +(def-stencil-fn "replace" + "The replace(text, pattern, replacement) function replaces all occurrences + of pattern in text by replacement." + [text pattern replacement] (clojure.string/replace (str text) (str pattern) (str replacement))) diff --git a/src/stencil/infix.clj b/src/stencil/infix.clj index 32c35d83..76567a78 100644 --- a/src/stencil/infix.clj +++ b/src/stencil/infix.clj @@ -3,7 +3,7 @@ https://en.wikipedia.org/wiki/Shunting-yard_algorithm" (:require [stencil.util :refer [->int string whitespace?]] - [stencil.functions :refer [call-fn]] + [stencil.functions :refer [call-fn def-stencil-fn]] [stencil.grammar :as grammar])) (set! *warn-on-reflection* true) @@ -147,7 +147,9 @@ ;; Gives access to whole input payload. Useful when top level keys contain strange characters. ;; Example: you can write data()['key1']['key2'] instead of key1.key2. -(defmethod call-fn "data" [_] *calc-vars*) +(def-stencil-fn "data" + "The function returns the original whole template data object." + [] *calc-vars*) (defmethod eval-tree :fncall [[_ f & args]] (let [args (mapv eval-tree args)] diff --git a/src/stencil/model/fragments.clj b/src/stencil/model/fragments.clj index 4b834c70..0f047132 100644 --- a/src/stencil/model/fragments.clj +++ b/src/stencil/model/fragments.clj @@ -1,6 +1,6 @@ (ns stencil.model.fragments (:require [stencil.util :refer [eval-exception]] - [stencil.functions :refer [call-fn]] + [stencil.functions :refer [def-stencil-fn]] [stencil.types :refer [ControlMarker]] [stencil.ooxml :as ooxml] [clojure.data.xml :as xml])) @@ -33,7 +33,10 @@ (defrecord FragmentInvoke [result] ControlMarker) ;; custom XML content -(defmethod call-fn "xml" [_ content] +(def-stencil-fn "xml" + "Inserts OOXML fragment into the document from the parameter of this function call. + Usage: `{%=xml(ooxml)%}`" + [content] (assert (string? content)) (let [content (:content (xml/parse-str (str "" content "")))] (->FragmentInvoke {:frag-evaled-parts content}))) @@ -41,4 +44,7 @@ ;; inserts a page break at the current run. (let [br {:tag ooxml/br :attrs {ooxml/type "page"}} page-break (->FragmentInvoke {:frag-evaled-parts [br]})] - (defmethod call-fn "pageBreak" [_] page-break)) + (def-stencil-fn "pageBreak" + "Inserts page break into the document where the return value of this function is used. + Usage: `{%=pageBreak()%}`" + [] page-break)) diff --git a/src/stencil/postprocess/html.clj b/src/stencil/postprocess/html.clj index ae8bc31b..3d680081 100644 --- a/src/stencil/postprocess/html.clj +++ b/src/stencil/postprocess/html.clj @@ -2,7 +2,7 @@ "Replaces results of html() calls with external part relationships." (:require [clojure.zip :as zip] [clojure.data.xml :as xml] - [stencil.functions :refer [call-fn]] + [stencil.functions :refer [def-stencil-fn]] [stencil.postprocess.fragments :as fragments] [stencil.types :refer [ControlMarker]] [stencil.util :refer [find-first dfs-walk-xml dfs-walk-xml-node]] @@ -12,7 +12,23 @@ (defrecord HtmlChunk [content] ControlMarker) -(defmethod call-fn "html" [_ content] (->HtmlChunk content)) +(def-stencil-fn "html" + "It is possible to embed text with basic dynamic formatting using HTML notation. + The HTML code will be converted to OOXML and inserted in the document. + + Stencil uses a simple parsing algorithm to convert between the formats. At the moment only a limited set of basic formatting is implemented. You can use the following HTML tags: + - b, em, strong for bold text. + - i for italics. + - u for underlined text. + - s for strikethrough text. + - sup for superscript and sub for subscript. + - span elements have no effects. + - br tags can be used to insert line breaks. + + The rendering throws an exception on invalid HTML input or unexpected HTML tags. + + **Usage:** `{%=html(x) %}`" + [content] (->HtmlChunk content)) (def legal-tags "Set of supported HTML tags" diff --git a/src/stencil/postprocess/images.clj b/src/stencil/postprocess/images.clj index 89a7ec30..5e1063ef 100644 --- a/src/stencil/postprocess/images.clj +++ b/src/stencil/postprocess/images.clj @@ -1,7 +1,7 @@ (ns stencil.postprocess.images (:require [clojure.java.io :as io] [clojure.zip :as zip] - [stencil.functions :refer [call-fn]] + [stencil.functions :refer [def-stencil-fn]] [stencil.log :as log] [stencil.ooxml :as ooxml] [stencil.model.relations :as relations] @@ -87,7 +87,9 @@ :writer (bytes->writer bytes)})) ;; replaces the nearest image with the content -(defmethod call-fn "replaceImage" [_ data] +(def-stencil-fn "replaceImage" + "" + [data] (let [extra-file (img-data->extrafile data)] (relations/add-extra-file! extra-file) (->ReplaceImage (:new-id extra-file)))) \ No newline at end of file diff --git a/src/stencil/postprocess/links.clj b/src/stencil/postprocess/links.clj index 3b731896..73e5f0fc 100644 --- a/src/stencil/postprocess/links.clj +++ b/src/stencil/postprocess/links.clj @@ -1,6 +1,6 @@ (ns stencil.postprocess.links (:require [clojure.zip :as zip] - [stencil.functions :refer [call-fn]] + [stencil.functions :refer [def-stencil-fn]] [stencil.log :as log] [stencil.ooxml :as ooxml] [stencil.model.relations :as relations] @@ -47,7 +47,10 @@ :stencil.model/mode "External"})) ;; replaces the nearest link's URK with the parameter value -(defmethod call-fn "replaceLink" [_ url] +(def-stencil-fn "replaceLink" + "Replaces the link URL in the hyperlink preceding this expression. + It should be placed immediately after the link we want to modify." + [url] (let [new-relation (link-url->relation (str url))] (relations/add-extra-file! new-relation) (->ReplaceLink (:new-id new-relation)))) \ No newline at end of file diff --git a/src/stencil/postprocess/table.clj b/src/stencil/postprocess/table.clj index fea03eb6..405a296e 100644 --- a/src/stencil/postprocess/table.clj +++ b/src/stencil/postprocess/table.clj @@ -1,7 +1,7 @@ (ns stencil.postprocess.table "XML fa utofeldolgozasat vegzo kod." (:require [clojure.zip :as zip] - [stencil.functions :refer [call-fn]] + [stencil.functions :refer [def-stencil-fn]] [stencil.ooxml :as ooxml] [stencil.types :refer [ControlMarker]] [stencil.util :refer [find-first find-last fixpt iterations ->int find-first-in-tree xml-zip zipper?]])) @@ -24,7 +24,9 @@ (defrecord HideTableRowMarker [] ControlMarker) (defn hide-table-row-marker? [x] (instance? HideTableRowMarker x)) -(defmethod call-fn "hideColumn" [_ & args] +(def-stencil-fn "hideColumn" + "Stencil will remove the column of the table where the value produced by this function call is inserted." + [& args] (case (first args) ("cut") (->HideTableColumnMarker :cut) ("resize-last" "resizeLast" "resize_last") (->HideTableColumnMarker :resize-last) @@ -33,7 +35,9 @@ ;; default (->HideTableColumnMarker))) -(defmethod call-fn "hideRow" [_] (->HideTableRowMarker)) +(def-stencil-fn "hideRow" + "Stencil will remove the row of the table where the value produced by this function call is inserted." + [] (->HideTableRowMarker)) ;; columns narrower that this are goig to be removed (def min-col-width 20) From 641c17c3dcac8ed58b78eb8043f070aee7f8814a Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 13 Oct 2024 09:58:32 +0200 Subject: [PATCH 2/8] exclude def-stencil-fn from functions --- deps.edn | 1 + 1 file changed, 1 insertion(+) diff --git a/deps.edn b/deps.edn index de6a39c2..eee20a01 100644 --- a/deps.edn +++ b/deps.edn @@ -29,6 +29,7 @@ "--exclude-call" "stencil.util/trace" "--exclude-call" "stencil.util/fail" "--exclude-call" "clojure.spec.alpha/def" + "--exclude-call" "stencil.functions/def-stencil-fn" "-p" "src" "-s" "test"]} :test From 31c7b8a267b16c59b8f745de41d132d7efc86f73 Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 13 Oct 2024 10:03:42 +0200 Subject: [PATCH 3/8] Revert "exclude def-stencil-fn from functions" This reverts commit 641c17c3dcac8ed58b78eb8043f070aee7f8814a. --- deps.edn | 1 - 1 file changed, 1 deletion(-) diff --git a/deps.edn b/deps.edn index eee20a01..de6a39c2 100644 --- a/deps.edn +++ b/deps.edn @@ -29,7 +29,6 @@ "--exclude-call" "stencil.util/trace" "--exclude-call" "stencil.util/fail" "--exclude-call" "clojure.spec.alpha/def" - "--exclude-call" "stencil.functions/def-stencil-fn" "-p" "src" "-s" "test"]} :test From be1b13069fc7bea5df69ad5c4dd857168e8ab4aa Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 13 Oct 2024 10:14:18 +0200 Subject: [PATCH 4/8] unit test for pageBreak --- test/stencil/functions_test.clj | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/stencil/functions_test.clj b/test/stencil/functions_test.clj index 781f5011..261ace46 100644 --- a/test/stencil/functions_test.clj +++ b/test/stencil/functions_test.clj @@ -136,4 +136,7 @@ (is (thrown? ExceptionInfo (call-fn "replaceImage" "not data uri"))) (is (thrown? ExceptionInfo (call-fn "replaceImage" "data:image/unknown;base64,XXXXXXX"))) (is (thrown? ExceptionInfo (call-fn "replaceImage" "data:image/png;lalala"))) - (is (thrown? ExceptionInfo (call-fn "replaceImage" "data:image/png;lalala,XXXXXXX"))))) \ No newline at end of file + (is (thrown? ExceptionInfo (call-fn "replaceImage" "data:image/png;lalala,XXXXXXX"))))) + +(deftest test-pageBreak + (is (stencil.types/control? (call-fn "pageBreak")))) From e8cc103ebfc57f3da67277ae40063680f799ab99 Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 13 Oct 2024 10:19:34 +0200 Subject: [PATCH 5/8] dummy test for xml() --- test/stencil/functions_test.clj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/stencil/functions_test.clj b/test/stencil/functions_test.clj index 261ace46..7f6309e2 100644 --- a/test/stencil/functions_test.clj +++ b/test/stencil/functions_test.clj @@ -140,3 +140,7 @@ (deftest test-pageBreak (is (stencil.types/control? (call-fn "pageBreak")))) + +(deftest test-xml + (is (stencil.types/control? (call-fn "xml" "text"))) + #_ (is (thrown? ExceptionInfo (call-fn "xml" "invalid xml")))) From e378a6353ef5fc0180f04c9a955e4f36fd146002 Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 13 Oct 2024 11:54:22 +0200 Subject: [PATCH 6/8] dummy test for html() --- test/stencil/functions_test.clj | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/stencil/functions_test.clj b/test/stencil/functions_test.clj index 7f6309e2..8235cb8c 100644 --- a/test/stencil/functions_test.clj +++ b/test/stencil/functions_test.clj @@ -143,4 +143,8 @@ (deftest test-xml (is (stencil.types/control? (call-fn "xml" "text"))) - #_ (is (thrown? ExceptionInfo (call-fn "xml" "invalid xml")))) + #_(is (thrown? ExceptionInfo (call-fn "xml" "invalid xml")))) + +(deftest test-html + (is (stencil.types/control? (call-fn "html" "bold text"))) + (is (stencil.types/control? (call-fn "html" "one two three four")))) From a255ecf3ebeb5e30980b03e8e407ade011f96b71 Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 13 Oct 2024 13:00:23 +0200 Subject: [PATCH 7/8] fix: access --- scripts/generate-fun-docs.clj.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/generate-fun-docs.clj.sh diff --git a/scripts/generate-fun-docs.clj.sh b/scripts/generate-fun-docs.clj.sh old mode 100644 new mode 100755 From 990cb597e8f033671f21b10099b9854d5b1f63d3 Mon Sep 17 00:00:00 2001 From: janos erdos Date: Sun, 13 Oct 2024 17:46:15 +0200 Subject: [PATCH 8/8] writing docs for java functions --- .../stencil/functions/BasicFunctions.java | 18 ++++++++++++ .../erdos/stencil/functions/Function.java | 4 +++ scripts/generate-fun-docs.clj.sh | 28 +++++++++++++------ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/java-src/io/github/erdos/stencil/functions/BasicFunctions.java b/java-src/io/github/erdos/stencil/functions/BasicFunctions.java index c76c9c19..af44192c 100644 --- a/java-src/io/github/erdos/stencil/functions/BasicFunctions.java +++ b/java-src/io/github/erdos/stencil/functions/BasicFunctions.java @@ -35,6 +35,12 @@ else if (expr != null && expr.equals(value)) return null; } } + + @Override + public String getDocumentation() { + return "Select a value based on the first argument.\n" + + "Usage: `switch(expression, case-1, value-1, case-2, value-2, ..., optional-default-value)`"; + } }, /** @@ -50,6 +56,12 @@ public Object call(Object... arguments) { return arg; return null; } + + @Override + public String getDocumentation() { + return "Returns the first non-null or non-empty parameter.\n\n" + + "Accepts any number of arguments. Ignores null values, empty string and empty collections."; + } }, /** @@ -67,6 +79,12 @@ public Object call(Object... arguments) { || ((x instanceof Collection) && ((Collection) x).isEmpty()) || ((x instanceof Iterable) && !((Iterable) x).iterator().hasNext()); } + + @Override + public String getDocumentation() { + return "Returns true iff input is null, empty string or empty collection.\n\n" + + "Expects exactly 1 parameter."; + } }; @Override diff --git a/java-src/io/github/erdos/stencil/functions/Function.java b/java-src/io/github/erdos/stencil/functions/Function.java index 1cd00821..fe6e938a 100644 --- a/java-src/io/github/erdos/stencil/functions/Function.java +++ b/java-src/io/github/erdos/stencil/functions/Function.java @@ -26,4 +26,8 @@ public interface Function { * @return function identifier */ String getName(); + + default String getDocumentation() { + return "Documentation is not available"; + } } diff --git a/scripts/generate-fun-docs.clj.sh b/scripts/generate-fun-docs.clj.sh index 8a8fed72..246c8835 100755 --- a/scripts/generate-fun-docs.clj.sh +++ b/scripts/generate-fun-docs.clj.sh @@ -5,24 +5,34 @@ test ; set -e && cd "$(dirname "$0")/.." && clojure -M -i scripts/generate-fun-d ;; file is a regular CLJ script from now on -(println "HALI") - (require 'stencil.functions) +(defn get-java-functions [] + (for [f (.listFunctions (new io.github.erdos.stencil.functions.FunctionEvaluator))] + {:name (.getName f) + :docs (.getDocumentation f)})) + +(defn get-clj-functions [] + (for [[k v] (methods stencil.functions/call-fn)] + {:name k + :docs (:stencil.functions/docs (meta v))})) + +(def all-functions + (sort-by :name (concat (get-java-functions) (get-clj-functions)))) + (println "# Functions") (println) (println "You can call functions from within the template files and embed the call result easily by writing `{%=functionName(arg1, arg2, arg3, ...)%}` expression in the document template.") (println "This page contains a short description of the functions implemented in Stencil.") (println) -(doseq [k (sort (keys (methods stencil.functions/call-fn)))] - (printf "- [%s](#%s)\n" k k)) +;; Table of Contents +(doseq [f all-functions] + (printf "- [%s](#%s)\n" (:name f) (:name f))) (println) -(doseq [[k f] (sort (methods stencil.functions/call-fn))] - (printf "## %s\n\n" k) - (when-let [docs (:stencil.functions/docs (meta f))] - (println docs) - (println)) +(doseq [f all-functions] + (printf "## %s\n\n" (:name f)) + (println (:docs f)) (println))