Introduction
DSL for creating courses on Clojurecademy
Rationale
The main motivations for creating Clojurecademy are:
-
I want Clojure community to grow and try to help with this project to make it happen.
-
Making Clojure adoption as much easy as possible for new comers.(without initial setup etc.)
-
Making possible to teach anything related to programming in Clojure(e.g. Algorithms & Data Structures to Data Science).
-
Having good Clojure resources in any field by contributing Clojurecademy’s courses together.
-
Giving back to the community.
Requirements
-
Clojure 1.8
-
Java 8
-
Leiningen 2.7.1+
Getting Started
Let’s create new lein project
lein new app sample-course
The easiest way to use DSL in a Clojure project is by including it as a dependency in your project.clj:
[clojurecademy "0.1.0"]
Also, you need to add lein plugin for DSL as well:
[lein-clojurecademy "0.1.55"]
Also you need to create ~/.java.policy file in your home directory and add the following code:
grant {
permission java.security.AllPermission;
};
Components
There are several components to create a course such as manifest
, course
, chapter
,
sub-chapter
, subject
, instruction
, sub-instruction
and so on, now we
are going to examine those components in detail.
manifest
Every Clojurecademy course needs a manifest which specifies main characteristics of a course such as a name, descriptions and so on.
Let’s open core.clj file and define our manifest.
(ns clj.core
(:require [clojurecademy.dsl.core :refer :all]))
(def my-manifest
(manifest :title "Sample Course"
:report-bug-email-or-link "https://github.com/clojurecademy/sample-course/issues"
:name 'sample-course
:skip? true
:short-description "Sample Course Project for Clojurecademy DSL"
:long-description "It's designed to teach \"How to make a course on Clojurecademy?\""
:who-is-this-course-for :no-programming-experience))
:title |
Title of a course. |
:report-bug-email-or-link |
Email address or URL that related to the course(e.g. GitHub issue page URL) so users of your course can submit a bug report to you. |
:name |
Unique identifier for your project if you change it you will be creating new course so keep it that way unless you want same course couple of times. |
:skip? |
When it’s false that means users can’t jump into chapters randomly they need to go step by step from scratch, if it’s true they can jump into any chapter randomly and skip it whenever they want. |
:short-description |
Short description for a course which is limited to 125 characters. |
:long-description |
Long description for a course which is limited to 600 characters. |
:who-is-this-course-for |
Defining who should enroll this course such as people with no programming experience or people with some Clojure experience and so on.There are 3 predefined keywords(that turn into some strings). |
3 predefined options of :who-is-this-course-for
Keyword |
Equivalent String |
:clojure-experience |
"This course requires some Clojure experience." |
:programming-experience |
"This course requires some programming experience, Clojure experience is NOT required." |
:no-programming-experience |
"This course does not require any programming experience, it’s for total beginners to programming." |
*or you can override it with a string, such as: |
"This course is for …….." |
course
Now we are going to construct a course, let’s define our course like this(don’t worry we will talk about every component in detail later on):
;;We name it like course-map because under the hood DSL creates huge map data structure basically
;;all components made from Clojure maps
(def course-map
(course my-manifest
(chapter 'ch-intro ;name identifier for chapter
"Intro to Clojure" ;title for chapter
(sub-chapter
'sub-ch-basics ;name identifier for sub chapter
"Basics" ;title for sub chapter
(subject
'sub-about-clojure ;name identifier for subject
"Aobut Clojure" ;title for subject
(learn ;learn part for subject
(text
(p "Clojure is a functional programming language that runs on JVM."))))))))
We created our simple course without any instructions(we will talk about it) so what do we have up there?:
-
Course takes one manifest and multiple chapters as parameters.
-
Chapter takes name identifier, title and multiple sub chapters as parameters.
-
Sub Chapter takes name identifier, title and multiple subjects as parameters.
-
Subject takes name identifier, title and one instruction(and optional other components as well).
We need to make sure that our course is valid and going to run this command:
lein clojurecademy test
Got the following error:
Missing :clojurecademy
option in project.clj. You need to have a line in your project.clj
file that looks like:
:clojurecademy {:course-map your.ns/course-map}
That output means we need to define our course-map in project.clj file so let’s open our project.clj file and add this line:
:clojurecademy {:course-map clj.core/course-map}
Also we have to add this option as well:
:eval-in :leiningen
Then try again that lein clojurecademy test
, you should get the following output:
Map is valid. There is no test var defined.Please add defcoursetest for testing.
Now we get this success output which means our course is valid and we need to have at least one subject which has instruction then we will be able to deploy to Clojurecademy.
Now we are going to extend our course with an instruction so we will be able to expect users to provide some input
and validate their input that it’s valid or not.Here is the extended version that one subject(subj-hello-world
)
added to sub chapter called sub-ch-basics
:
(sub-chapter
'sub-ch-basics
"Basics"
(subject
'subj-about-clojure
"About Clojure"
(learn
(text
(p "Clojure is a functional programming language that runs on JVM."))))
(subject
'subj-hello-world
"Hello, World"
(learn
(text
(p "Now we are going to use Clojure's print functionality to see some output.Please follow the instructions")))
(instruction 'ins-clojure-helloworld ;name identifier
(run-pre-tests? false)
(initial-code :none)
(rule :no-rule? true)
(sub-instruction 'sub-ins-hello-world ;name identifier
(text
(p "Please print \"Hello, World\" to console "
"then click the Run button to see the result"))
(testing
(is (form-used? (println "Hello, World"))))))
'hello-world))
As you can see in this instruction we are asking users to write (println "Hello, World")
and click
Run button, so we can validate their input. We are going to talk about components that used in
here(run-pre-tests?
, initial-code
, etc.) in the following sections.
Instruction can have multiple sub-instruction
components and we have one here, every sub-instruction
can have one text
(for telling the user what to do) and one testing
component, since we can
have multiple is
components within testing it’s easy to write many is
assertions
to validate given input.
In the example is
component takes a form which is supposed to return either true or
false if it is true test passes if not it fails. form-used?
is a predefined function in the
platform basically it checks the given form that exists in the user’s code. You can check Predefined Functions.
At the bottom 'hello-world
indicates namespace of our subject.
Let’s run our command to be sure that we are on the right track:
lein clojurecademy test
Here what we get(again):
Map is valid. There is no test var defined.Please add defcoursetest for testing.
Let’s focus on this message: There is no test var defined.Please add defcoursetest for testing.
which means we need to write a test(not the test we know of) that validates our instruction so we will be sure that
this instruction has a working solution, the thing is we are going to write user’s code(code
that users provide to pass instruction).
Before writing test we need to add required ns:
[clojurecademy.dsl.test :refer [defcoursetest]]
Now, please add following code under the course-map, then run lein clojurecademy test
:
(defcoursetest my-test
[ch-intro sub-ch-basics subj-hello-world ins-clojure-helloworld sub-ins-hello-world]
(println "Hello, World"))
Here is the output which indicates everything is fine and you are ready to deploy your course to Clojurecademy:
Map is valid.
Hello, World
1 routes passed.
Every sub-instruction
needs defcoursetest
and you can define your test where ever you like
in clj files. Let’s examine defcoursetest
in depth:
my-test
is name identifier for defcoursetest
which should be unique it
works like def in Clojure.
[ch-intro sub-ch-basics subj-hello-world ins-clojure-helloworld sub-ins-hello-world]
indicates route for
this sub instruction. From chapter
to sub-instruction
(using name identifiers)
(println "Hello, World")
it’s assumed that users code (code that provided by user)
If you want to see your course on Clojurecademy immediately you can check here(Deployment),
you can regularly deploy your course as you add/change something in your course and see it visually all the time.
We said that instruction can have multiple sub-instruction
components so let’s add new subject
called subj-math-fns
under the subj-hello-world
:
(subject
'subj-math-fns
"Let's write some math functions"
(learn
(text
(p "To understand Clojure comprehensively we are going to write some basic math functions in this section.")))
(instruction 'ins-subj-math-fns
(run-pre-tests? false)
(initial-code :none)
(rule :no-rule? true)
(sub-instruction 'sub-ins-my-add
(text
(p "Please write a function called "
(hi "my-add")
" which adds given numbers"))
(testing
(is (= (my-add 1) 1))
(is (= (my-add 1 2) 3))
(is (= (my-add 1 2 3 4 5 6) 21))))
(sub-instruction 'sub-ins-my-subs
(text
(p "Please write a function called "
(hi "my-subs")
" which subtracts given numbers"))
(testing
(is (= (my-subs 1) -1))
(is (= (my-subs 2 1) 1))
(is (= (my-subs 100 1 2 3 4 5) 85)))))
'subj-math-fns)
And it’s defcourtests
would be like this:
(defcoursetest my-test-2
[ch-intro sub-ch-basics subj-math-fns ins-subj-math-fns sub-ins-my-add]
(defn my-add
[& args]
(apply + args)))
(defcoursetest my-test-3
[ch-intro sub-ch-basics subj-math-fns ins-subj-math-fns sub-ins-my-subs]
(defn my-subs
[& args]
(apply - args)))
run-pre-tests?
run-pre-tests?
can have either true or false as a parameter, if it is
true sub instructions before current sub instruction going to be checked(e.g. before executing 3. instruction 1.
and 2. instructions going to be executed to check that they pass or not), if false current sub instruction
will be checked only.
initial-code
initial-code
is Clojure code that will be provided in the editor to users, there are couple of ways to
initialize initial-code
.
Since initial-code
is a macro you can provide simple Clojure code like this:
(initial-code (println "Hello, World"))
You should use such a form when you don’t need formatting(single line codes are perfect for that purpose)
You can provide code in a string form(it’s good when you need nice formatting in the editor):
(initial-code "(defn my-add\n [a b]\n (+ a b))")
Also initial-code
treats to str function differently, when you want to provide long
and formatted code, probably it won’t fit into your editor nicely so you can separate into small chunk of
strings like this:
(initial-code (str "\n\n(println \"Scalars: \\n\")\n\n\n"
"(println \"Type of 1 is: \" (type 1) \"\\n\")\n\n\n"
"(println \"Type of 1.2 is: \" (type 1.2) \"\\n\")\n\n\n"
"(println \"Type of 1N is: \" (type 1N) \"\\n\")\n\n\n"
"(println \"Type of 'my-s is: \" (type 'my-s) \"\\n\")"))
Note
|
If you provide namespace in initial-code then you don’t have to declare ns in subject, for example: |
(subject
'subj-initial-code-ns-ex
"Let's see some initial code"
(learn
(text
(p "Check code in the editor")))
(instruction 'ins
(run-pre-tests? true)
;; ns will be fetched from initial-code
(initial-code "\n(ns clj.core\n (:require [clojure.string :as str]))\n\n(defn your-fn\n []\n )")
(rule :no-rule? true)
(sub-instruction 'sub-ins
(text
(p "Please click the Run button"))
(testing
;;mock true for demo
(is (true? true))))))
rule
rule
allows us to have some control over user’s code and it has a couple of powerful options such
as :restricted-fns
, :required-fns
and :only-use-one-fn?
.
When you want to restrict couple of functions/symbols you need to use :restricted-fns
;; users can't use last and reduce functions in their code
(rule :restricted-fns '[last reduce])
There will be some times you might want users to provide only one function(form) and you need to use
:only-use-one-fn?
;; users can only provide one function/form they can not write multiple functions in their editor, if they do they will get an exception
(rule :only-use-one-fn? true)
Also you might want users to use specific functions to construct their code and you will need to use
:required-fns
;;also you have to use :only-use-one-fn? with :required-fns, the reason is we want to control those functions used in a function.
(rule :required-fns '[reduce]
:only-use-one-fn? true)
is
is
components will be defined within a testing
component, basically is
components are test assertions and form after is returns either true(test passes) or false(test fails):
(is (= (my-last [1 2 3 4 5]) 5))
;;returns true
(is (= (my-last [1 2 3 4 5]) 4))
;; Error: (= 5 4) -> This assertion does not return true!
;You can override error message if you want to:
(is (= (my-last [1 2 3 4 5]) 4) "Overwritten error message, it fails!")
;; Error: (= 5 4) -> Overwritten error message, it fails!
;;There are couple of error messages types such as :none, :simple(default one) and :advanced
(is (= (my-last [1 2 3 4 5]) 4) "Overwritten error message, it fails!" :none)
;; Error: Overwritten error message, it fails!
(is (= (my-last [1 2 3 4 5]) 4) "Overwritten error message, it fails!" :simple)
;; Error: (= 5 4) -> Overwritten error message, it fails!
(is (= (my-last [1 2 3 4 5]) 4) "Overwritten error message, it fails!" :advanced)
;; Error: (= (my-last [1 2 3 4 5]) 4) => (= 5 4) -> Overwritten error message, it fails!
(is (= (my-last [1 2 3 4 5]) 4) :default :advanced)
;; Error: (= (my-last [1 2 3 4 5]) 4) => (= 5 4) -> This assertion does not return true!
Important
|
Also is component treats macros differently when it comes to error message representation(:advanced error message type), if you want to use a macro in is component, check error messages before releasing it, the reason is sometimes error messages might not make sense(especially having nested and long codes). |
text
text
can take 2 different sub text components called p(paragraph)
and
code
.You can define as many p
or code
components as you want within text:
(text
(p "Clojure is a" (hi "functional programming") "language."
"It runs on " (italic "JVM") " and " (bold "other platforms(JavaScript, CLR)")
"Check Clojure's Official site: " (link "Clojure Site" "https://clojure.org"))
(p "Here is the Clojure code:")
(code (println "Hello, world"))
(p "Also you can write Clojure code like this:")
(code "(defn my-fn\n [x]\n (println x))")
(p "You wanna show some " (hi "Ruby") " code?")
(code "ruby" "puts 'Hello, world!'")
(p "Or some " (hi "python") "?")
(code "python" "def printme( str ):\n print str\n return;"))
code
supports 9+ programming languages
clojure (default) |
ruby |
clike (c/c++, java etc.) |
haskell |
javascript |
python |
scheme |
commonlisp |
erlang |
Also code
treats str function in a same way with initial-code
so you can
split your long formatted codes into smaller strings.
Ordering
Ordering your component is an important thing to pay attention, I’ll show you how to not make critical mistakes.
Let’s assume you have course structure like this:
(course my-manifest
(chapter 'ch-intro ...)
(chapter 'ch-examples ...))
And you want to re-order your chapters(you can do for all components as well) take a look at the following code(which is totally okay):
;;re-ordered, that's fine
(course my-manifest
(chapter 'ch-examples ...)
(chapter 'ch-intro ...))
But let’s say you wanted to change name identifier of ch-intro to ch-basics and have something like this:
(course my-manifest
(chapter 'ch-examples ...)
(chapter 'ch-basics ...))
Important
|
This is the critical part if you change name identifier of any component that(ch-basics) component will be deactivated(new component will be created ch-basics) in course and if you have existing course and some users enrolled that course they won’t be able to see that chapter so the thing is you can change name identifiers of components that’s fine but before doing that take those outcomes into account but if you are just in development stage that’s fine. |
Note
|
Like we said if you change name identifier that component will be deactivated(not deleted) so you can re-change name identifier to old name it will show up again(no data loss). |
Predefined Functions
Predefined functions are built-in functions of Clojurecademy that we use to validate user’s code in is
components.
Let’s use predefined functions on following code that provided by a user:
(ns clojure-intro)
(println "Hello, world")
(defn my-add
[& args]
(apply + args))
(- 3 2 1)
(defn my-fn
[]
(do
(println "I'm Here!")))
(defn throw-runtime-ex
[]
(throw (RuntimeException. "Damn!")))
(+ 1 2 3)
Usage:
(all-forms) ;returns user's code as a data structure except ns form
(all-forms true) ;returns user's code as a data structure including ns form
(is (= '(println "Hello, world") (first (all-forms))))
(form-used? <form>) ;checks that form used at top level in code
(is (form-used? (println "Hello, world")))
(form-used-nes? <form>) ;checks that form used anywhere(nested structure) in code
(is (form-used-nes? (println "I'm Here!")))
(ns-form) ;returns ns form of the code
(is (= 'clojure-intro (second (ns-form))))
(first-form) ;returns first form of the code(ns excluded)
(is (= '(println "Hello, world") (first-form)))
(second-form) ;returns second form of the code(ns excluded)
(is (= '(defn my-add [& args] (apply + args)) (second-form)))
(nth-form) ;returns nth form of the code(ns excluded)
(is (= '(- 3 2 1) (nth-form 2)))
(eval-ds <form>) ;evaluates the given form and returns the result
(is (= 6 (eval-ds (last (all-forms)))))
(throws? ...) ;checks the given code that throws an exception or not
(is (throws? RuntimeException (throw-runtime-ex)))
Helper Functions
As an author, you can define your own functions to validate user’s input and it reduces the duplicate code for validation.
First of all, let’s create helper-fns.clj in the same directory with core.clj and write the following code:
;;checks that str/join function declared in user'scode
(defn str-join-used?
[all-forms]
((complement not-any?) (fn [form]
(and (= 'str/join (first form))
(vector? (second form))
(= 2 (count (second form)))))
(filter list? all-forms)))
Also, you need to specify your helper namespace in project.clj to be able to use it:
:helper-fns-ns clj.helper-fns
;now you can use your helper fn in your is component
(is (str-join-used? (all-forms)))
Plugin Commands
There are a couple of commands that you can benefit from when writing a course.
lein clojurecademy [options…]
test |
validates course |
test [debug or -d] |
shows stages while validating course |
test [debug or -d] [advanced or -a] |
shows stages and creates all data structure while validating course |
autotest |
detects file changes and runs test, it’s infinite process |
autotest [debug or -d] |
like autotest, shows stages |
autotest [debug or -d] [advanced or -a] |
like autotest, shows stages and creates all data structure |
deploy |
deploys course to Clojurecademy site |
blacklist |
shows blacklisted namespaces, packages, symbols and objects |
blacklist [namespace or -n] |
shows blacklisted namespaces only |
blacklist [package or -p] |
shows blacklisted packages only |
blacklist [symbol or -s] |
shows blacklisted symbols only |
blacklist [object or -o] |
shows blacklisted objects only |
course |
shows routes and defcoursetests of course |
course [route or -r] |
shows routes of course only |
course [test or -t] |
shows defcoursetests of course only |
Deployment
When you are done with your course it’s time to deploy to Clojurecademy and the procedure is very easy, only thing you need to do is that type the following command:
lein clojurecademy deploy
Then you will be asked to provide your Clojurecademy username/email and password, after providing your credentials course going to be deployed/committed to the site.
Note
|
If you don’t want to provide your credentials every time you deploy it you can add your credentials to your ~/.lein/profiles.clj file: |
:clojurecademy {:username-or-email "your-username-or-email"
:password "your-password"}
Now you deployed your course to Clojurecademy but your course won’t be released until you release it in the UI so
you need to go to Clojurecademy site and follow these steps: Log In → Learn → Created Courses →
Your Course → Click Release Button → Release It!
Note
|
Now users will be able to see your course, if course existed before they will get new changes. |
Important
|
When you commit(deploy) your course you don’t have to Release It you will always get the latest version of your course because you are the author so please do release when your course is ready for users. |
Source Code
Sample Course is open source and can be found on GitHub.
License
MIT License
Copyright 2017 Ertuğrul Çetin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.