├── .busted ├── .gitignore ├── Dockerfile ├── bin └── restia.lua ├── config.ld ├── containerize ├── contributors.lua ├── guides ├── controllers.md ├── getting-started.md └── views.md ├── license.md ├── logo.svg ├── readme.md ├── restia-dev-2.rockspec ├── restia ├── accessors.lua ├── bin │ ├── commands.lua │ ├── init.lua │ └── manpage.lua ├── colors.lua ├── controller.lua ├── handler.lua ├── init.lua ├── logbuffer.lua ├── markdown.lua ├── negotiator.lua ├── request.lua ├── scaffold │ ├── app.lua │ ├── blog.lua │ ├── init.lua │ └── static.lua ├── secret.lua ├── template.lua └── utils.lua └── spec ├── accessors_spec.moon ├── busted_spec.moon ├── config_spec.moon ├── contributors_spec.moon ├── ctemplate.moonhtml.lua ├── indent_spec.moon ├── markdown_spec.moon ├── negotiator_spec.lua ├── template.md ├── template.moonhtml ├── template_spec.moon └── utils_spec.moon /.busted: -------------------------------------------------------------------------------- 1 | -- vim: set filetype=lua :miv -- 2 | return { 3 | _all = { 4 | lpath = './?.lua;./?/init.lua'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | from alpine 2 | 3 | run apk add curl openssl openssh git linux-headers perl pcre 4 | run apk add pcre-dev openssl-dev make gcc libzip-dev libaio-dev musl-dev 5 | 6 | # OpenResty 7 | workdir /tmp 8 | copy https://openresty.org/download/openresty-1.15.8.2.tar.gz openresty-1.15.8.2.tar.gz 9 | run tar -xzf openresty-*.tar.gz 10 | workdir openresty-1.15.8.2 11 | run ./configure \ 12 | --with-pcre-jit \ 13 | --with-http_v2_module \ 14 | --with-http_ssl_module \ 15 | --with-mail \ 16 | --with-stream \ 17 | --with-threads \ 18 | --with-file-aio \ 19 | --with-http_realip_module \ 20 | --with-stream_ssl_module \ 21 | --with-stream \ 22 | --with-stream_ssl_module \ 23 | --with-stream \ 24 | --with-stream_ssl_module \ 25 | --with-http_stub_status_module \ 26 | && make -j $(nproc) && make install \ 27 | && ln -s /usr/local/openresty/nginx/sbin/nginx /usr/local/bin 28 | # cleanup 29 | run rm -rf /tmp/* 30 | 31 | # Make sure git always uses HTTPS instead of SSH 32 | run git config --global url."https://github.com/".insteadOf "git@github.com:"; \ 33 | git config --global url."https://github.com/".insteadOf "git://github.com/" 34 | 35 | # LuaJIT 36 | workdir /tmp 37 | copy http://luajit.org/download/LuaJIT-2.0.4.tar.gz LuaJIT-2.0.4.tar.gz 38 | run tar -xzf LuaJIT-*.tar.gz; rm *.tar.gz; mv LuaJIT-* luajit 39 | workdir luajit 40 | run make -j $(nproc) && make install 41 | # cleanup 42 | run rm -rf /tmp/* 43 | 44 | # Luarocks 45 | workdir /tmp 46 | run apk add unzip 47 | copy http://luarocks.github.io/luarocks/releases/luarocks-3.2.1.tar.gz luarocks-3.2.1.tar.gz 48 | run tar -xzf luarocks-*.tar.gz; rm *.tar.gz; mv luarocks-* luarocks 49 | workdir luarocks 50 | run ./configure && make bootstrap 51 | # cleanup 52 | run rm -rf /tmp/* 53 | 54 | # Restia 55 | workdir /tmp 56 | run apk add yaml-dev 57 | run luarocks install restia --dev 58 | 59 | # # Build a minimal restia image 60 | # from alpine 61 | # # Necessary requirements 62 | # run apk add curl openssh git linux-headers perl pcre libgcc openssl yaml 63 | # # Pull openresty, luarocks, restia, etc. from the restia image 64 | # copy --from=restia /usr/local /usr/local 65 | # # Copy the restia application 66 | # copy application /etc/application 67 | # workdir /etc/application 68 | # cmd restia run 69 | -------------------------------------------------------------------------------- /bin/restia.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | ---------- 3 | -- ## Restia on the command line. 4 | -- Generates scaffolding for new projects. 5 | -- Call `restia help` to get a detailed list of commands. 6 | -- @author DarkWiiPlayer 7 | -- @license Unlicense 8 | -- @script restia 9 | 10 | math.randomseed(os.time()) 11 | 12 | local restia = require 'restia' 13 | local arrr = require 'arrr' 14 | 15 | local commands = restia.bin.commands 16 | local c = restia.colors 17 | 18 | local function uid() 19 | local id_u = io.popen("id -u") 20 | local id = tonumber(id_u:read()) 21 | id_u:close() return id 22 | end 23 | 24 | local builtin_scaffolds = { 25 | app = "Generic web-application using Openresty"; 26 | static = "A simple SSG setup that preserves the directory structure"; 27 | blog = "A simple blog-like SSG setup"; 28 | } 29 | 30 | local help = [[ 31 | Restia Commandline Utility 32 | -------------------------- 33 | 34 | Available commands: 35 | ]] 36 | 37 | local openresty = [[openresty -p . -c openresty.conf -g 'daemon off;' ]] 38 | 39 | commands:add('new ', [[ 40 | Generates a builtin scaffold 41 | (shortcut for restia scaffold restia.scaffold.) 42 | ]], function(name, ...) 43 | if name and builtin_scaffolds[name] then 44 | commands.scaffold("restia.scaffold."..tostring(name), ...) 45 | else 46 | print("Builtin Scaffolds:") 47 | for scaffold, description in pairs(builtin_scaffolds) do 48 | print("", c.yellow(scaffold) .. ": " .. description) 49 | end 50 | end 51 | end) 52 | 53 | commands:add('test ', [[ 54 | Runs several tests: 55 | - 'openresty -t' to check the openresty configuration 56 | - 'luacheck' for static analisys of the projects Lua files 57 | - 'busted' to run the projects tests 58 | is the lua version to run busted with. default is 'luajit'. 59 | defaults to 'openresty.conf'. 60 | ]], function(lua, configuration) 61 | lua = lua or 'luajit' 62 | configuration = configuration or 'openresty.conf' 63 | os.exit( 64 | os.execute(openresty:gsub('openresty.conf', configuration)..'-t') 65 | and os.execute('luacheck --exclude-files lua_modules/* --exclude-files .luarocks/* -q .') 66 | and os.execute('busted --lua '..lua..' .') 67 | or 1 68 | ) 69 | end) 70 | 71 | commands:add('run ', [[ 72 | Runs an application in the current directory. 73 | Default for is 'openresty.conf'. 74 | ]], function(config) 75 | config = config or 'openresty.conf' 76 | os.execute(openresty:gsub('openresty.conf', config)..' -q') 77 | end) 78 | 79 | commands:add('reload ', [[ 80 | Reload the configuration of a running server. 81 | Default for is 'openresty.conf'. 82 | ]], function(config) 83 | config = config or 'openresty.conf' 84 | os.execute(openresty:gsub('openresty.conf', config)..'-s reload') 85 | end) 86 | 87 | commands:add('compile ', [[ 88 | Compiles an resource (most commonly a template). 89 | Config path to the resource 90 | The output file to save the rendered resource to. 91 | --root The config root to bind to 92 | --arguments Config path to an argument to pass to the resource 93 | The default argument is the config root. 94 | THe default config root is the current directory. 95 | ]], function(...) 96 | local options = arrr { 97 | { "Binds to another root directory", "-root", "-r", "root" }; 98 | { "Passes this config entry as argument to the resource", "--arguments", "-a", "path" }; 99 | } { ... } 100 | local config = restia.config.bind(options.root or ".") 101 | local outfile = options[2] or options[1]:match("[^%.]+$") 102 | local resource = restia.utils.deepindex(config, options[1]) 103 | if not resource then 104 | error("Could not find resource: "..options[1]) 105 | end 106 | local arguments = options.arguments and restia.utils.deepindex(config, options.arguments) or config 107 | local result = resource(arguments) 108 | if type(result)=="table" then 109 | result = restia.utils.deepconcat(result) 110 | elseif type(result)=="function" then 111 | result = string.dump(result) 112 | end 113 | restia.utils.builddir { [outfile] = result } 114 | end) 115 | 116 | commands:add('manpage ', [[ 117 | Installs restias manpage. 118 | Where to install the manpage. 119 | Defaults to: 120 | - /usr/local/man when executed as root 121 | - ~/.local/share/man 122 | (Remember to run mandb afterwards to update the database) 123 | ]], function(directory) 124 | if not directory then 125 | if uid() == 0 then 126 | directory = '/usr/local/man' 127 | else 128 | directory = os.getenv("HOME") .. '/.local/share/man' 129 | end 130 | end 131 | 132 | if directory == "-" then 133 | print(restia.bin.manpage) 134 | else 135 | local filename = directory:gsub("/$", ""):gsub("$", "/man1/restia.1") 136 | local output = assert(io.open(filename, "w")) 137 | print("Installing manpage as " .. filename) 138 | output:write(restia.bin.manpage) 139 | output:close() 140 | end 141 | end) 142 | 143 | commands:add('scaffold ', [[ 144 | Generates a new scaffold from a module. 145 | ]], function(name, ...) 146 | if not name then 147 | return commands.help("scaffold") 148 | end 149 | local tree = require(name){...} 150 | local scaffold = require 'scaffold' 151 | scaffold.builddir(".", tree) 152 | end) 153 | 154 | commands:add('help ', [[ 155 | Prints help and exits. 156 | ]], function(name) 157 | if name then 158 | local found = false 159 | for idx, command in ipairs(commands) do 160 | if command.name:find(name) then 161 | print((c('%{green}'..command.name):gsub('<.->', c.blue):gsub('%-%-%a+', c.yellow))) 162 | print((command.description:gsub('<.->', c.blue):gsub('%-%-%a+', c.yellow))) 163 | found = true 164 | end 165 | end 166 | if not found then 167 | print("No restia command matching your query: "..c.yellow(name)) 168 | end 169 | else 170 | print(help) 171 | for idx, command in ipairs(commands) do 172 | print((c('%{green}'..command.name):gsub('<.->', c.blue):gsub('%-%-%a+', c.yellow))) 173 | print((command.description:gsub('<.->', c.blue):gsub('%-%-%a+', c.yellow))) 174 | end 175 | end 176 | end) 177 | 178 | local command = commands[...] 179 | 180 | if command then 181 | command(select(2, ...)) 182 | else 183 | if (...) then 184 | print('Unknown command: '..c.red(...)) 185 | else 186 | commands.help() 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /config.ld: -------------------------------------------------------------------------------- 1 | -- vim: set filetype=lua :miv -- 2 | title = "Restia" 3 | project = "Restia" 4 | format = 'discount' 5 | topics = { 6 | 'readme.md', 7 | 'license.md', 8 | 'guides/getting-started.md', 9 | 'guides/controllers.md', 10 | 'guides/views.md', 11 | } 12 | file = { 13 | 'restia', 'bin'; 14 | } 15 | all = true 16 | -------------------------------------------------------------------------------- /containerize: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | podman build "$@" . -t restia \ 3 | && (notify-send --icon=info 'Container build completed :)' && true) \ 4 | || (notify-send --icon=error 'Container build failed :(' && false) 5 | -------------------------------------------------------------------------------- /contributors.lua: -------------------------------------------------------------------------------- 1 | -- A list of devs who have contributed substantial improvements to the project. 2 | -- Feel free to add custom fields to your entry, but keep it reasonable ;) 3 | -- Or leave some entries as nil if you don't want / need them. 4 | -- Also, no side effects. 5 | -- print("Only I am allowed to do that ;P") 6 | 7 | -- By setting the field "license" to true, you confirm that you have 8 | -- read and understood the license and agree to make your contributions available 9 | -- under those same terms. This field is required for a contribution to be accepted. 10 | 11 | -- Required fields: 12 | -- name : your preferred name 13 | -- email : the email used in your commits 14 | -- license : see above 15 | 16 | return { 17 | { 18 | name = "DarkWiiPlayer"; 19 | email = "darkwiiplayer@hotmail.com"; 20 | url = "darkwiiplayer.com"; 21 | features = { 22 | "master" -- etc. 23 | }; 24 | message = "Lua is awesome ♥"; 25 | license = true; 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /guides/controllers.md: -------------------------------------------------------------------------------- 1 | # Writing Controllers (or Handlers) 2 | 3 | Restia does not assume a MVC-workflow, but supports it when needed. 4 | 5 | ## Single-Function Request Handlers 6 | 7 | A controller module can return a single function that will handle a specific 8 | request, usually mapped to a single route. This is often enough for simple cases 9 | and special pages that don't fit neatly into an MVC design, like landing pages, 10 | legal notices, etc. 11 | 12 | When `restia.handler.serve` is called without an action argument, it will 13 | default to this behaviour. 14 | 15 | A typical location could look like this: 16 | 17 | -- locations/landing 18 | location = / { 19 | content_by_lua_block { 20 | restia.handler.serve("controllers.landing", "error") 21 | } 22 | } 23 | 24 | with a corresponding controller module: 25 | 26 | -- controllers/landing.lua 27 | return function(req) 28 | return ngx.say("Welcome to my website!") 29 | end 30 | 31 | Note that `restia.handler.serve` automatically passes `restia.request` as the 32 | first argument to the handler. This happens regardless of whether an action is 33 | specified. 34 | 35 | ## Multi-Action Controllers 36 | 37 | A controller module may also return a table containing several actions. To pick 38 | what action to call, an additional string argument must be passed to the 39 | `restia.handler.serve` function, which describes the table path to the 40 | request handler. 41 | 42 | Note that nested tables are possible, as `restia.utils.deepindex` is used to 43 | interpret this action path. 44 | 45 | This will, in most cases, be set up in the location block, which might look as 46 | follows: 47 | 48 | -- locations/user 49 | location = /user { 50 | content_by_lua_block { 51 | restia.handler.serve("controllers.landing", "error", "list") 52 | } 53 | } 54 | location ~ ^/user/(\d+)/(profile|posts)$ { 55 | content_by_lua_block { 56 | restia.handler.serve("controllers.landing", "error", ngx.var[2], ngx.var[1]) 57 | } 58 | } 59 | 60 | And the controller module might look somewhat like this: 61 | 62 | -- controllers/user.lua 63 | local user = {} 64 | 65 | function user.list(req) 66 | -- List all users 67 | end 68 | 69 | function user.profile(req, id) 70 | -- Render a users profile 71 | end 72 | 73 | -- ... 74 | 75 | Note that the `ngx.var[1]` gets passed through to the event handler after the 76 | request object. While the request handler could get this data itself through the 77 | `ngx` module, this way makes its interface more explicit and reusable. 78 | 79 | ## Stateful Controllers 80 | 81 | Most MVC frameworks implement controllers as classes: every new request gets a 82 | new controller object to encapsulate its state. Restia provides a simple wrapper 83 | to achieve this with the `restia.handler.controller` function, which takes the 84 | name of a module containing the controller class, the name of a module 85 | containing an error handler, an action name and additional arguments to be 86 | passed to the action. 87 | 88 | Since Lua has no proper classes or objects out of the box, Restia assumes the 89 | controller module will return a plain function or callable object that it will 90 | call without any arguments to return a new controller instance. It will then 91 | index the object with the method name and call the returned function, passing 92 | the method as the first argument. 93 | 94 | -- controllers/user.lua 95 | local class = {} 96 | 97 | function class:set_user() 98 | local id = restia.request.params.user_id 99 | self.unser = db.get_user(id) 100 | end 101 | 102 | function class:list() 103 | -- List all users 104 | end 105 | 106 | function class:profile() 107 | self:set_user() 108 | -- Render a users profile 109 | end 110 | 111 | local function new() 112 | return setmetatable({}, class) 113 | end 114 | 115 | return new 116 | 117 | Note that this function does *not* pass `restia.request` as an argument to the 118 | method. The controller object is expected to access the request module on its 119 | own, either directly or through metaprogramming. 120 | -------------------------------------------------------------------------------- /guides/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Restia 2 | 3 | ## Requirements 4 | 5 | This tutorial will assume you have 6 | 7 | - A working installation of any Linux distro 8 | - A working installation of [Openresty][openresty] 9 | - Openrestys version of nginx in the PATH as `openresty` 10 | - A working installation of Luarocks 11 | - The `curl` commandline tool 12 | 13 | ## Creating a project 14 | 15 | First of all, you will need to install Restia: 16 | 17 | $ luarocks install restia --dev --lua-version 5.1 18 | 19 | Some of restias dependancies may require additional libraries to be present on 20 | your system. 21 | How to install these libraryes will depend on what operating system you are 22 | using; 23 | for ubuntu-based systems you might want to use `apt install`. 24 | 25 | After that, you can run `restia new` to create a new project: 26 | 27 | $ mkdir application 28 | $ cd application 29 | $ restia new app 30 | 31 | Now you can take a moment to inspect the directory structure. 32 | This is also a good moment to create a git repository 33 | and commit everything in the directory. 34 | There's already a bunch of `.gitignore` files 35 | so you don't commit anything unnecessary. 36 | 37 | $ git init 38 | $ git add --all 39 | $ git commit -m 'Initialize project 🎉' 40 | 41 | At this point you can already run your application: 42 | 43 | $ restia run 44 | 45 | And open `http://localhost:8080/` in the browser 46 | to be greeted with a plain but working website. 47 | 48 | ## Adding a Page 49 | 50 | The scaffolding application generated by `restia new app` assumes a 51 | Model-View-Controler structure. If necessary, this can be changed, of course. 52 | 53 | **Let's add a new page at /hi, which will say hi to the user.** 54 | 55 | ### Add a Controller 56 | 57 | First, we will create the `controllers/hi.lua` file 58 | with the following content: 59 | 60 | local views = require "views" 61 | 62 | return function(req) 63 | return ngx.say(views.hi { name = "Restia" }) 64 | end 65 | 66 |
67 | Restia looks for controllers in /controllers 68 | 69 | The default location block found in locations/root sets nginx up so 70 | for any requested route foo/bar it looks for a controller in 71 | controllers/foo/bar.lua unless any other (more specific) location 72 | block matches first. 73 | 74 | Since / is the least specific location possible, 75 | this will always be tried last and gets overridden by anything that matches 76 | the route in question. 77 |
78 |
79 | 80 | ### Add a View 81 | 82 | With the controller set up to handle the application logic, 83 | next we need a view to render some output to the user. 84 | 85 |
86 | Templates in Restia are all just functions that accept a table of 87 | parameters and return some output as a string. 88 | 89 | To examplify this; one could even write a template as a plain Lua file. 90 | for example, open a file `views/hi.lua` and add the following code: 91 | 92 | return function(parameters) -- This function is our "template" 93 | return "

Hello from " .. parameters.name .. "!

" 94 | end 95 | 96 | to achieve the same result as what's explained below. 97 | However, writing templates this way is cumbersome, 98 | which is why this would rarely be done in a real project. 99 | 100 | Hint: If you tried this out, don't forget to delete `views/hi.lua` before 101 | continuing the guide, otherwise it will override the templates you will create 102 | next ;) 103 |
104 |
105 | 106 | Let's start with some simple HTML. 107 | First, create a new cosmo template by opening a file `views/hi.cosmo` 108 | and adding the following: 109 | 110 |

Hello World!

111 | 112 | Restia will now find this file whenever we access `require("views").hi` 113 | and return a function that returns the final output. 114 | 115 | You can start restia and confirm the result if you want. 116 | 117 | However, in most cases we'd want to add some dynamic content to our templates, 118 | otherwise we'd just be serving static files over plain nginx. 119 | Let's do that by changing the template to: 120 | 121 |

Greetings from $name!

122 | 123 | The `$name` in the template gets replaced with "Restia" because of the `{name = 124 | "Restia"}` we pass through in the controller. 125 | 126 | Now, you can start restia again and look at the result in the browser. 127 | This time though, let's just keep restia running in the background. 128 | 129 | $ restia run & 130 | 131 | ### Making the template *Multistage* 132 | 133 | For small snippets, writing HTML is quite acceptable. 134 | But when you're working on a large application, 135 | all those opening and closing tags can get very cumbersome to type. 136 | 137 | For this reason, restia has an additional templating engine called MoonXML. 138 | It's very flexible because "templates" are really just *code* that runs in a 139 | special environments where missing functions are generated on the fly to print 140 | HTML tags. 141 | This also makes moonhtml templates considerably slower than a more simple 142 | templating engine like cosmo, so Restia lets you combine the two into a 143 | *multistage template*. 144 | 145 | Let's first rename the hi template to `views/hi.cosmo.moonhtml` and 146 | change the content to 147 | 148 | h1 "Greetings from $name!" 149 | 150 | Restia will now load it as a moonhtml-cosmo multistage template; that is, 151 | the first time you *access* the `views.hi` template in your code, 152 | it will load the file as a **moonhtml** template and render it right then and 153 | there. 154 | 155 | However, the resulting string is not displayed yet; instead, it is loaded 156 | as a cosmo template and saved into the `views` table. From now on, every 157 | time you access `views.hi`, you will get the pre-rendered cosmo template. 158 | 159 | What this means, is that you can insert dynamic content at both stages, which 160 | can be a bit confusing at first. 161 | To illustrate this, 162 | let's add two timestamps to our template in `views/hi.cosmo.moonhtml`: 163 | 164 | h1 "Greetings from $name!" 165 | p "Rendered: $time" 166 | p "Pre-rendered: "..os.date() 167 | 168 | and modify the controller at `controllers/hi.lua` 169 | to pass an additional parameter: 170 | 171 | return ngx.say(views.hi { name = "Restia", time = os.date() }) 172 | 173 | Now open the page in the browser again. 174 | 175 | The first time you open the page, the two paragraphs should (almost) the same 176 | time. 177 | However, if you wait a few seconds and reload the page, 178 | the first timestamp should have changed, 179 | but the second one should remain the same. 180 | 181 | Summarizing this behavior as a rule of thumb: 182 | 183 | **Cosmo parameters change on every render.** 184 | They can be identified by the `$` and the fact that they appear within strings. 185 | 186 | **MoonHTML expressions get evaluated only once.** 187 | They appear as normal variables, function calls or other expressions in the 188 | MoonHTML template. 189 | 190 | You can make use of this by rendering certain content in the MoonXML stage if it 191 | onle relies on information that doesn't change once the server is running, like 192 | a navigation menu, generated URLs for resources, etc. 193 | 194 | Also keep in mind that there are also plain, single-stage MoonHTML templates 195 | that get rehdered every time. 196 | They can be identified by the single `.moonhtml` file extension. 197 | 198 | Most importantly though: The **controller** shouldn't care about the type of 199 | *template* it's dealing with. 200 | The controller just renders a parametrized **view**, 201 | which, by convention is a function as described above. 202 | 203 | ## Making it robot-friendly 204 | 205 | We already have a nice HTML page, but what about computers? They don't like 206 | looking at HTML pages nearly as much as human users do. Instead, let's offer 207 | them the same content in a more machine-readable format. 208 | 209 | To achieve this, first replace the line that renders the view with: 210 | 211 | req:offer { 212 | {"text/html", function(req) 213 | return views.greeter { name = "Restia", time = os.date() } 214 | end}; 215 | } 216 | 217 | This matches the users `accept` header against a list of available content-types 218 | and renders the best match. When two content-types are equally prefered by the 219 | client, it will just take the first one, so it is always a good idea to put the 220 | computationally cheaper content-types at the top. 221 | 222 | Notice the missing `ngx.say`: `req:offer` takes care of that automatically. 223 | 224 | Now we can add another content type to the list: 225 | 226 | {"application/json", function(req) 227 | return json.encode { name = "Restia", time = os.date() } 228 | end}; 229 | 230 | and, of course, actually define `json` as some module that can encode JSON data. 231 | Luckily, OpenResty comes bundled with one, so you can just add `local json = 232 | require 'cjson'` at the top of the file. 233 | 234 | As you may have noticed, there's some duplication there: the table that gets 235 | sent to the template looks identical to the one that gets encoded as JSON. 236 | Let's extract that table into a variable. 237 | 238 | The final controller should look something like this: 239 | 240 | -- Require some modules 241 | return function(req) 242 | local data = { name = "Restia", time = os.date() } 243 | req:offer { 244 | {"application/json", function(req) 245 | return json.encode(data) 246 | end}; 247 | {"text/html", function(req) 248 | return views.greeter(data) 249 | end}; 250 | } 251 | end 252 | 253 | You can confirm this by calling in your terminal 254 | 255 | curl localhost:8080/hi -H 'accept: application/json' 256 | curl localhost:8080/hi -H 'accept: text/html' 257 | curl localhost:8080/hi -H 'accept: application/json;q=.3,text/html;q=.5' 258 | 259 | And in case the server has no acceptable content-type for the client, it will 260 | return an error instead: 261 | 262 | curl localhost:8080/hi -H 'accept: application/yaml' 263 | 264 | [openresty]: http://openresty.org/en/ 'OpenResty is a dynamic web platform based on NGINX and LuaJIT.' 265 | -------------------------------------------------------------------------------- /guides/views.md: -------------------------------------------------------------------------------- 1 | # Writing Views (or templates) 2 | 3 | Templating—more precisely, HTML templating—is a core requirement for most web 4 | applications. Different frameworks and templating engines have different 5 | aproaches to this: 6 | 7 | Some stick very close to the target syntax and look like a 8 | more powerful version of string interpolation. Examples of this are Embedded 9 | Ruby (ERB), PHP but also many more modern technologies such as Svelte or Vue. 10 | 11 | Another aproach is to build a completely new domain-specific language to build 12 | an HTML document without writing any HTML directly. Examples for this are Rubys 13 | HAML or Lapis' HTML builder. 14 | 15 | ----- 16 | 17 | By default, Restia supports three (plus one) types of templates: 18 | 19 | **Cosmo** is a very powerful yet safe templating language that lets you embed 20 | data directly in HTML (or any other text format) without allowing any direct 21 | code execution. Inserting values, looping over arrays and even calling 22 | predefined functions is supported. 23 | 24 |

User Listing:

25 |
    26 | $users[[ 27 |
  • $name <$mail> (profile)
  • 28 | ]] 29 |
30 | 31 | **MoonHTML** is a DSL based on Moonscript that allows generating HTML completely 32 | programatically. It is very similar to HAML in many aspects, instead that you're 33 | writing code by default instead of having to escape it with special syntax. 34 | 35 | h4 "User Listing:" 36 | ul -> 37 | for user in *users 38 | li -> 39 | print escape "#{user.name} <#{mail}> (" 40 | a href: "/users/#{user.id}", "profile" 41 | print ")" 42 | 43 | These two can also be combined into **Multistage Templates**, which are MoonHTML 44 | templates that get rendered immediately when they are loaded, and then compiled 45 | as cosmo templates. 46 | 47 | h4 "User Listing:" 48 | ul -> 49 | print "$users[[" 50 | print escape "$name <$mail> (" 51 | a href: "/users/$id", "profile" 52 | print ")" 53 | print "]]" 54 | 55 | **Skooma** is a more functional aproach to HTML generation. It exposes pure 56 | functions that return HTML nodes 57 | 58 | local function render_user(user) 59 | return li( 60 | user.name, 61 | ' <', user.mail, '>', 62 | ' (', a{href = "/users/" .. user.id, "profile"}, ')' 63 | ) 64 | end 65 | 66 | return function(users) 67 | return { 68 | h4 "User Listing:", 69 | ul(map(render_user, users)) 70 | } 71 | end 72 | 73 | Since skooma "templates" are just Lua code, you can also write them in any 74 | language that compiles to Lua, like for example moonscript: 75 | 76 | render_user ==> 77 | li @name, ' <', @mail, '> (', a("profile", href: "/users/#{@id}"), ")" 78 | 79 | (users) -> { h4 "User Listing:", ul map render_user, users } 80 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | image/svg+xml 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Restia](logo.svg) [![License: Unlicense](https://img.shields.io/github/license/darkwiiplayer/restia)](https://unlicense.org/) 2 | ================================================================================ 3 | 4 | [![Built with ♥](https://forthebadge.com/images/badges/built-with-love.svg)](https://forthebadge.com) 5 | [![Uses Badges](https://forthebadge.com/images/badges/uses-badges.svg)](https://forthebadge.com) 6 | 7 | **The** *(Too bold?)* **new public domain web framework for Lua** 8 | 9 | ### What is Restia? 10 | 11 | A framework that aims to make developing web-applications in Lua easier. 12 | It has been designed to be used as just a library to better fit into 13 | existing applications. Restia runs inside Openresty to achieve top 14 | performance, but can easily be adapted to use different host APIs. 15 | 16 | ### What makes it different? 17 | 18 | * **Simplicity**: Restia has a small codebase and clear structure. 19 | * **Modularity**: You can use the whole thing—or just a single function. 20 | * **No Elves**: Things don't "just happen" unless you tell them to. 21 | * **Performance**: Low overhead means short code paths means fast code. 22 | 23 | ### What makes it *special*? 24 | 25 |
26 | Powerful Templating 27 | 28 | The MoonXML templating engine, inspired by the Lapis web framework, 29 | takes away much of the complexity of writing HTML templates by hand. 30 | 31 | [Cosmo][cosmo] templates, on the other hand, are blazing fast. 32 | 33 | You can even chain them as "multistage templates": 34 | Restia will load a MoonXML template once and interpret the result 35 | as a cosmo template that you can then render repeatedly. 36 | Convenient *and* performant! 37 | 38 | For even more modularity, Skooma ditches the separation between code and 39 | templates entirely. A document tree is constructed through pure functions and 40 | later rendered into actual HTML text. This two-stage process allows building 41 | transformation pipelines (read: "middleware") without having to parse HTML. 42 |
43 |
44 | 45 |
46 | Flexible Configuration 47 | 48 | In Lua, structured Data is represented in tables. 49 | 50 | Restia extends this pattern to your configuration data: 51 | Bind a directory and get a table that abstracts your complex file hierarchy 52 | and many different file types into one uniform structure that you already 53 | know how to use. 54 | 55 | Even templates can be accessed this way, and with only a few lines of code, 56 | custom config loaders can be added for new file formats. 57 |
58 |
59 | 60 |
61 | No magical state transfer 62 | 63 | Restia doesn't take care of the plumbing behind the scenes. 64 | Data should be passed around explicitly as function arguments to avoid 65 | over-sharing—both for security and predictability. 66 | 67 | It's up to the developer to selectively override this and introduce global state 68 | where necessary. 69 |
70 |
71 | 72 | ### Cool! How do I start? 73 | 74 | Assuming you have [openresty][openresty] installed: 75 | 76 | luarocks install restia --dev --lua-version 5.1 77 | restia new app 78 | restia run & 79 | 80 | That's it. 81 | You can now open it in your browser at `localhost:8080` 82 | or start hacking the code right away :) 83 | 84 | Work in Progress 85 | -------------------------------------------------------------------------------- 86 | 87 | In its current state, it should be obvious to anyone that Restia is still in 88 | development and nowhere near production ready. 89 | 90 | If you want to use Restia for your own project, 91 | be warned that API changes are likely to happen unannounced during the 92 | zero-versions (`0.x.y`). 93 | 94 | How do I build cool stuff? 95 | -------------------------------------------------------------------------------- 96 | 97 | - Run your application in the background with `restia run &` as you code. 98 | - Check out `getting-started.md` in this repo for a detailed explanation. 99 | - Just inspect the source tree, specially `controllers`, `views` and `config`. 100 | - Read the documentation for detailed descriptions of what everything does. 101 | - Wear sunglasses. 😎 102 | 103 | Some Examples 104 | -------------------------------------------------------------------------------- 105 | 106 | ### Hello, World! 107 | 108 | For a simple hello-world page one would usually set up a plain nginx route that 109 | just prints a string. 110 | 111 | location = /hello { content_by_lua_block { ngx.say "Hello, World!" } } 112 | 113 | However, for the sake of making a better example, here's how this could be done 114 | using a few more Restia features: 115 | 116 | In a controller at `controllers/hello.lua` 117 | 118 | ```lua 119 | local views = require("views") 120 | 121 | return function(req) 122 | return ngx.say(views.hello{ name = "World" }) 123 | end 124 | ``` 125 | 126 | This controller simply renders a template called `hello` with an additional 127 | argument. 128 | 129 | Then, in a view at `views/hello.cosmo.moonhtml` 130 | 131 | h1 "Hello, $name!" 132 | 133 | This moonhtml will be rendered and turned into a cosmo template once when it is 134 | first accessed, that is, the `config` module runs the moonhtml-cosmo-loader. 135 | The resulting cosmo template will look like this: 136 | 137 |

Hello, $name!

138 | 139 | When a user accesses the route, the cosmo template gets rendered and the 140 | variable `$name` is replaced with "World", giving us "Hello, World!". 141 | 142 | This behavior is not mandatory: one can also write a `hello.moonhtml` template 143 | that will get rendered anew for every request. This will decrease performance 144 | though, since the MoonXML renderer is much more expensive. 145 | 146 | ### Simple Content Negotiation 147 | 148 | Say you have some data like `{ name = "bob", hobby = "web-dev" }` and want to 149 | present that information in different content types depending on the clients 150 | `accept` header. 151 | 152 | Assuming there's a "data" template in the vews directory, 153 | the controller could look this: 154 | 155 | ```lua 156 | local views = require 'views' 157 | local json = require 'cjson' 158 | 159 | local data = { name = "bob", hobby = "web-dev" } 160 | 161 | return function(req) 162 | req:offer { 163 | {"application/json", function(req) 164 | return json.encode(data) 165 | end}; 166 | {"text/html", function(req) 167 | return views.data(data) 168 | end}; 169 | } 170 | end 171 | ``` 172 | 173 | Scaffolding / Generators 174 | -------------------------------------------------------------------------------- 175 | 176 | Restia has a simple scaffolding mechanism which essentially wraps the 177 | `restia.utils.builddir` function within the `restia` executable. 178 | 179 | The concept is simple: A scaffold generator is a Lua module that returns a 180 | function. This function, when called, should return a table representing the 181 | changes to be applied to the current directory. 182 | 183 | The `restia scaffold` command accepts a module name and a list of extra 184 | arguments to be passed to the generator script as a table, to be evaluated 185 | internally. 186 | 187 | More advanced scaffolds may choose to provide their own help flag. 188 | 189 | The `restia new` command is a shortcut to builtin Restia generators that 190 | internally calls `restia scaffold` prepending `'restia.scaffold.'` to the module 191 | name. 192 | 193 | Modules 194 | -------------------------------------------------------------------------------- 195 | 196 | These are the most important modules that get most of the work done: 197 | 198 | ### Request 199 | 200 | Restia provides some convenience for request-handling in the `restia.request` 201 | module. This module is passed automatically to every request handler wrapped 202 | in `restia.controller.xpcall` as its first argument. 203 | 204 | It wraps openresty functions to access request data and adds functionality to 205 | many of them, allowing the user to e.g. easily read and set cookies or access 206 | request parameters in a variety of formats. 207 | 208 | ### Config 209 | 210 | The `restia.config` module takes care of everything configuration-related. 211 | Its main function is `bind`, which binds a directory to a table. 212 | Indexing the returned table will iterate through a series of loader functions, 213 | which each attempt to load the config entry in a certain way, like loading a 214 | yaml file or binding a subdirectory. 215 | 216 | See the documentation of the `restia.config` module for more information. 217 | 218 | ### Template 219 | 220 | The `restia.template` module wraps the `moonxml` templating library and adds a 221 | series of convenient functions for common HTML structures. 222 | 223 | ### Controller 224 | 225 | The `restia.controller` module provides helper functions to find and call 226 | request handlers. It supports both a *one handler per file* approach, where the 227 | controller module returns a function, or a more rails-like system where each 228 | controller has several actions (that is, the module returns a table). 229 | 230 | The module also takes care of catching errors and running a handler that can 231 | take care of reporting the error in a suitable manner. 232 | 233 | ### Secret 234 | 235 | The `restia.secret` module is a config table bound to the `.secret` directory 236 | with some additional functions for encryption/decryption. It assumes a special 237 | `key` file to exist in `.secret`, which should contain the servers secret key. 238 | 239 | Building the Documentation 240 | -------------------------------------------------------------------------------- 241 | 242 | Restia doesn't install its documentation with luarocks, so it has to be built 243 | manually or read [online][doc]. To build it, simply install [ldoc][ldoc], clone 244 | the restia repository and run `ldoc .` in its top level directory. The docs will 245 | be generated in the `doc` folder by default. 246 | 247 | Docker 248 | -------------------------------------------------------------------------------- 249 | 250 | The repository includes a dockerfile and a convenient script `containerize` that 251 | generates a docker image based on alpine linux. 252 | 253 | A simple dockerfile to turn a restia application into a docker image could look 254 | like this: 255 | 256 | # Build a minimal restia image 257 | from alpine 258 | # Necessary requirements 259 | run apk add curl openssh git linux-headers perl pcre libgcc openssl yaml 260 | # Pull openresty, luarocks, restia, etc. from the restia image 261 | copy --from=restia /usr/local /usr/local 262 | # Copy the restia application 263 | copy application /etc/application 264 | workdir /etc/application 265 | cmd restia run 266 | 267 | Assuming that the application is in the `application` folder. 268 | 269 | Note that the `containerize` script uses podman instead of docker; but it should 270 | be possible to just replace it with `docker` and run the script. 271 | 272 | Manpage 273 | -------------------------------------------------------------------------------- 274 | 275 | For Linux\* users, there's a command to generate and install a manpage. 276 | 277 | Simply run `restia manpage` as root to install system-wide or as another user to 278 | installit locally in `$HOME/.local/share/man`. 279 | 280 | \* Yes, Linux. There's also non-GNU linuxes out there using musl+busybox and 281 | other GNU-alternatives. 282 | 283 | Known Issues 284 | -------------------------------------------------------------------------------- 285 | 286 | > attempt to yield across C-call boundary 287 | 288 | This error occurs under certain conditions: 289 | 290 | 1. The code being run is being (directly or indirectly) `require`d 291 | 2. The code is running inside an openresty event that has replaced LuaJITs 292 | builtin `coroutine` module with openrestys custom versions of those 293 | functions. 294 | 3. Somewhere in the code a coroutine yields, no matter where it yields to (it 295 | doesn't have to yield outside the `require` call, which would understandably 296 | not work, but anywhere within the code being executed by `require`) 297 | 298 | Note that this problem not only happens with `require`, but also custom message 299 | handlers passed to `xpcall` when an error happens, but this is less likely to 300 | happen, as error handlers usually shouldn't have any complex code that could 301 | lead to more errors and thus shouldn't be running coroutines in the first place. 302 | 303 | This problem isn't a bug in restia; it can be reproduced in vanilla openresty. 304 | 305 | Typical situations when this happens: 306 | 307 | - Moonscripts compiler makes use of coroutines, thus compiling moonscript code 308 | (for example, precompiling a cosmo-moonhtml template) in a module that gets 309 | `require`d. 310 | 311 | Typical workarounds: 312 | 313 | - Wrap code that uses coroutines in an init function and call `require 314 | 'mymodule'.init()` (Sadly, this unavoidably leads to very ugly APIs) 315 | - Preload cosmo-moonhtml templates in `init_by_lua`, which runs before 2. 316 | happens 317 | - Precompile cosmo-moonscript templates so they don't need to be compiled when 318 | `require`ing a module 319 | 320 | OpenResty issue: https://github.com/openresty/lua-nginx-module/issues/1292 321 | 322 | Planned features 323 | -------------------------------------------------------------------------------- 324 | 325 | - More MoonXML utility methods (lists, tables, etc.) 326 | - Some High-Level functionality (Security-related, etc.) 327 | - Portability (Currently only nginx is supported) 328 | 329 | Contributing 330 | -------------------------------------------------------------------------------- 331 | 332 | In general, all meaningful contributions are welcome 333 | under the following conditions: 334 | 335 | - All commit messages MUST be meaningful and [properlt formatted](https://chris.beams.io/posts/git-commit/). 336 | - Commits SHOULD NOT group unrelated features. 337 | - Exported functions MUST be documented in [LDoc][ldoc] style. 338 | - Local functions SHOULD be documented as well. 339 | - Contributors MUST appear in `contributors.lua`. 340 | - Contributors MAY add custom fields to their `contributors.lua` entry. 341 | - Commits modifying `contributors.lua` MUST be signed. 342 | 343 | See [RFC2119][rfc2119] if you're wondering about the weird capitalization. 344 | 345 | Changelog 346 | -------------------------------------------------------------------------------- 347 | 348 | ### Development 349 | 350 | ### Version 0.1.0 351 | 352 | - Add 'static' scaffold 353 | - Add 'blog' scaffold 354 | - Add 'app' scaffold 355 | - Add `restia.controller` class 356 | - Add skooma loader for functional templating 357 | - Add simple scaffolding/generators system 358 | - Add `restia.utils.tree` submodule 359 | - Add `restia.request` module 360 | - Add `restia.accessors` module 361 | - Add `restia.callsign` module (Name subject to future change) 362 | - Add `restia.negotiator` module 363 | - Add `restia.template.require` function 364 | - Add `restia.handler` module (formerly `restia.controller`) 365 | - Add `restia.secret` module 366 | - Add support for moonhtml+cosmo multistage templates 367 | - Add support for cosmo templates 368 | - Integrate templates with `config` 369 | - Add `restia.config` module 370 | - Rewrite template loading to use a table and render on demand ;) 371 | - Rewrite template loading to use buffer and render instantly 372 | - Add executable for scaffolding, running tests, starting a server, etc. 373 | - Switch to moonxml initializers 374 | - Add `restia.template` module 375 | - Add `ttable` function for more complex tables 376 | - Add `vtable` function for vertical tables 377 | - Add `olist` function for ordered lists 378 | - Add `ulist` function for unordered lists 379 | - Add `html5` function for html 5 doctype 380 | 381 | ---- 382 | 383 | License: [The Unlicense][unlicense] 384 | 385 | [cosmo]: http://www.cosmo.luaforge.net/ "Cosmo Templating Engine" 386 | [doc]: https://darkwiiplayer.github.io/restia "Restia Documentation" 387 | [ldoc]: https://github.com/stevedonovan/LDoc "LDoc - A Lua Documentation Tool" 388 | [lunamark]: https://github.com/jgm/lunamark "Lunamark" 389 | [moonxml]: https://github.com/darkwiiplayer/moonxml "MoonXML" 390 | [openresty]: http://openresty.org/en/ "OpenResty" 391 | [unlicense]: https://unlicense.org "The Unlicense" 392 | [rfc2119]: https://tools.ietf.org/html/rfc2119 "Request for Comment 2119" 393 | -------------------------------------------------------------------------------- /restia-dev-2.rockspec: -------------------------------------------------------------------------------- 1 | rockspec_format = "3.0" 2 | package = "restia" 3 | version = "dev-2" 4 | source = { 5 | url = "git+https://github.com/DarkWiiPlayer/restia.git"; 6 | } 7 | description = { 8 | summary = "Auxiliary library for dynamic web content in openresty"; 9 | homepage = "https://darkwiiplayer.github.io/restia/"; 10 | license = "Unlicense"; 11 | labels = { 12 | "html"; 13 | "openresty"; 14 | } 15 | } 16 | dependencies = { 17 | "arrr ~> 2.2"; 18 | "cosmo"; 19 | "glass ~> 1.3"; 20 | "lua ~> 5"; 21 | "lua-cjson ~> 2.1"; 22 | "lua-resty-cookie"; 23 | "luafilesystem ~> 1.8"; 24 | "luaossl"; 25 | "lunamark ~> 0.5"; 26 | "lyaml ~> 6.2"; 27 | "multipart ~> 0.5"; 28 | "protomixin ~> 1"; 29 | "scaffold ~> 1.1"; 30 | "xhmoon"; 31 | } 32 | build = { 33 | type = "builtin", 34 | modules = { 35 | ['restia'] = 'restia/init.lua'; 36 | ['restia.accessors'] = 'restia/accessors.lua'; 37 | ['restia.bin'] = 'restia/bin/init.lua'; 38 | ['restia.bin.commands'] = 'restia/bin/commands.lua'; 39 | ['restia.bin.manpage'] = 'restia/bin/manpage.lua'; 40 | ['restia.colors'] = 'restia/colors.lua'; 41 | ['restia.contributors'] = 'contributors.lua'; 42 | ['restia.controller'] = 'restia/controller.lua'; 43 | ['restia.handler'] = 'restia/handler.lua'; 44 | ['restia.logbuffer'] = 'restia/logbuffer.lua'; 45 | ['restia.markdown'] = 'restia/markdown.lua'; 46 | ['restia.negotiator'] = 'restia/negotiator.lua'; 47 | ['restia.request'] = 'restia/request.lua'; 48 | ['restia.scaffold.app'] = 'restia/scaffold/app.lua'; 49 | ['restia.scaffold.init'] = 'restia/scaffold/init.lua'; 50 | ['restia.scaffold.blog'] = 'restia/scaffold/blog.lua'; 51 | ['restia.scaffold.static'] = 'restia/scaffold/static.lua'; 52 | ['restia.secret'] = 'restia/secret.lua'; 53 | ['restia.template'] = 'restia/template.lua'; 54 | ['restia.utils'] = 'restia/utils.lua'; 55 | }; 56 | install = { 57 | bin = { 58 | restia = 'bin/restia.lua'; 59 | }; 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /restia/accessors.lua: -------------------------------------------------------------------------------- 1 | --- Implements OO-like "attributes" using metatables. 2 | -- @module restia.attributes 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | local module = {} 6 | 7 | --- Returns a pair of accessor tables and the object 8 | -- @tparam table new The object to be proxied. If missing, a new table is created. 9 | -- @treturn table get Table of getter methods 10 | -- @treturn table set Table of setter methods 11 | -- @treturn table new The new proxied object 12 | -- @usage 13 | -- local get, set, obj = restia.attributes.new { foo = 1 } 14 | -- function set:bar(value) self.foo=value end 15 | -- function get:bar() return self.foo end 16 | -- obj.bar = 20 -- calls the setter method 17 | -- print(obj.bar) -- calls the getter method, prints 20 18 | -- print(obj.foo) -- accesses the field directly, also prints 20 19 | -- print(obj.baz) -- raises an unknown property error 20 | function module.new(new) 21 | if type(new)~="table" then new={} end 22 | local get, set = {}, {} 23 | local proxy = setmetatable(new, { 24 | __index = function(self, index) 25 | if get[index] then 26 | return get[index](self) 27 | else 28 | error("Attempting to get unknown property: '"..index.."'", 2) 29 | end 30 | end; 31 | __newindex = function(self, index, value) 32 | if set[index] then 33 | return set[index](self, value) 34 | else 35 | error("Attempting to set unknown property: '"..index.."'", 2) 36 | end 37 | end; 38 | }) 39 | return get, set, proxy 40 | end 41 | 42 | return module 43 | -------------------------------------------------------------------------------- /restia/bin/commands.lua: -------------------------------------------------------------------------------- 1 | --- Glorified table to save CLI commands 2 | -- @module restia.commands 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | 6 | local commands = {} 7 | 8 | function commands:add(name, description, fn) 9 | self[name:match('^[^ ]+')] = fn 10 | table.insert(self, {name=name, description=description}) 11 | end 12 | 13 | return commands 14 | -------------------------------------------------------------------------------- /restia/bin/init.lua: -------------------------------------------------------------------------------- 1 | -- Bin module. 2 | -- This is essentially just an autoloader for the submodules. 3 | -- @module restia.bin 4 | -- @author DarkWiiPlayer 5 | -- @license Unlicense 6 | 7 | return require('restia.utils').deepmodule(...) 8 | -------------------------------------------------------------------------------- /restia/bin/manpage.lua: -------------------------------------------------------------------------------- 1 | --- Renders a manpage for restia 2 | -- @module restia.bin.manpage 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | 6 | local restia = require 'restia' 7 | local cosmo = require 'cosmo' 8 | 9 | local manpage = cosmo.f [===[ 10 | .TH restia 1 "" "" "Restia web framework" 11 | 12 | .SH NAME 13 | .\" #### 14 | 15 | \fBrestia\fR - commandline utility for the restia framework 16 | 17 | .SH SYNOPSIS 18 | .\" ######## 19 | 20 | \fBrestia\fR \fBnew\fR [\fIdirectory\fR] 21 | .br 22 | \fBrestia\fR \fBtest\fR [\fIlua\fR [\fIconfiguration\fR]] 23 | .br 24 | \fBrestia\fR \fBrun\fR [\fIconfiguration\fR] 25 | .br 26 | \fBrestia\fR \fBreload\fR [\fIconfiguration\fR] 27 | .br 28 | \fBrestia\fR \fBmanpage\fR [\fIdirectory\fR] 29 | .br 30 | \fBrestia\fR \fBhelp\fR 31 | 32 | .SH DESCRIPTION 33 | .\" ########### 34 | 35 | Commandline utility for the restia framework 36 | 37 | .SH COMMANDS 38 | .\" ######## 39 | 40 | \fBnew\fR [\fIdirectory\fR] 41 | .br 42 | Creates a new project in the directory \fIdirectory\fR 43 | The default directory is \fBapplication\fR. 44 | 45 | \fBtest\fR [\fIlua\fR [\fIconfiguration\fR]] 46 | .br 47 | Runs a list of tests on the web application in the current directory. 48 | 49 | \fBrun\fR [\fIconfiguration\fR] 50 | .br 51 | Runs a restia application in the current directory. 52 | 53 | \fBreload\fR [\fIconfiguration\fR] 54 | .br 55 | Reloads an already running restia application. 56 | 57 | The default \fIconfiguration\fR for the three commands above is \fBopenresty.conf\fR 58 | 59 | \fBrestia\fR \fBmanpage\fR [\fIdirectory\fR] 60 | .br 61 | Installs restias manpage in \fIdirectory\fR. 62 | .br 63 | The default directory is \fB/usr/local/man\fR when executed as root and \fB~/.local/share/man\fR otherwise. 64 | 65 | \fBhelp\fR 66 | .br 67 | Displays help for the different commands and exit. 68 | 69 | \fBrestia\fR 70 | 71 | .SH AUTHORS 72 | .\" ####### 73 | 74 | $contributors[[$name <$email>]] 75 | 76 | .SH SEE ALSO 77 | .\" ######## 78 | 79 | \fBnginx\fR(1) 80 | ]===] (restia) 81 | 82 | return manpage 83 | -------------------------------------------------------------------------------- /restia/colors.lua: -------------------------------------------------------------------------------- 1 | -- ansicolors.lua v1.0.2 (2012-08) 2 | 3 | -- Copyright (c) 2009 Rob Hoelz 4 | -- Copyright (c) 2011 Enrique García Cota 5 | -- 6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy 7 | -- of this software and associated documentation files (the "Software"), to deal 8 | -- in the Software without restriction, including without limitation the rights 9 | -- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | -- copies of the Software, and to permit persons to whom the Software is 11 | -- furnished to do so, subject to the following conditions: 12 | -- 13 | -- The above copyright notice and this permission notice shall be included in 14 | -- all copies or substantial portions of the Software. 15 | -- 16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | -- THE SOFTWARE. 23 | -- 24 | ------------------- 25 | -- 26 | -- Modified by DarkWiiPlayer for easier usage. This file should only be part 27 | -- of restia until these changes are accepted into the ansicolors rock. 28 | 29 | 30 | -- support detection 31 | local function isWindows() 32 | return type(package) == 'table' and type(package.config) == 'string' and package.config:sub(1,1) == '\\' 33 | end 34 | 35 | local supported = not isWindows() 36 | if isWindows() then supported = os.getenv("ANSICON") end 37 | 38 | local keys = { 39 | -- reset 40 | reset = 0, 41 | 42 | -- misc 43 | bright = 1, 44 | dim = 2, 45 | underline = 4, 46 | blink = 5, 47 | reverse = 7, 48 | hidden = 8, 49 | 50 | -- foreground colors 51 | black = 30, 52 | red = 31, 53 | green = 32, 54 | yellow = 33, 55 | blue = 34, 56 | magenta = 35, 57 | cyan = 36, 58 | white = 37, 59 | 60 | -- background colors 61 | blackbg = 40, 62 | redbg = 41, 63 | greenbg = 42, 64 | yellowbg = 43, 65 | bluebg = 44, 66 | magentabg = 45, 67 | cyanbg = 46, 68 | whitebg = 47 69 | } 70 | 71 | local escapeString = string.char(27) .. '[%dm' 72 | local function escapeNumber(number) 73 | return escapeString:format(number) 74 | end 75 | 76 | local function escapeKeys(str) 77 | 78 | if not supported then return "" end 79 | 80 | local buffer = {} 81 | local number 82 | for word in str:gmatch("%w+") do 83 | number = keys[word] 84 | assert(number, "Unknown key: " .. word) 85 | table.insert(buffer, escapeNumber(number) ) 86 | end 87 | 88 | return table.concat(buffer) 89 | end 90 | 91 | local function replaceCodes(str) 92 | str = string.gsub(str,"(%%{(.-)})", function(_, str) return escapeKeys(str) end ) 93 | return str 94 | end 95 | 96 | -- public 97 | 98 | local function ansicolors( str ) 99 | str = tostring(str or '') 100 | 101 | return replaceCodes('%{reset}' .. str .. '%{reset}') 102 | end 103 | 104 | return setmetatable({noReset = replaceCodes}, { 105 | __call = function (_, str) return ansicolors (str) end, 106 | __index = function (self, name) 107 | local fn = function(input) 108 | return ansicolors("%{"..name.."}"..input) 109 | end 110 | self[name] = fn 111 | return fn 112 | end 113 | }) 114 | -------------------------------------------------------------------------------- /restia/controller.lua: -------------------------------------------------------------------------------- 1 | --- A base class for stateful controllers à la Rails 2 | -- @classmod restia.controller 3 | -- @usage 4 | -- local users = controller:new() 5 | -- function users:get_user(id) 6 | -- -- get a user from somewhere 7 | -- end 8 | -- function users:show(req) 9 | -- get_user(req.params.id) 10 | -- -- do stuff here 11 | -- end 12 | -- return users 13 | 14 | local request = require 'restia.request' 15 | local protomixin = require 'restia.protomixin' 16 | 17 | --- Creates a new (empty) controller 18 | -- @function new 19 | 20 | local controller = protomixin.new(request, {new=protomixin.new}) 21 | 22 | return controller 23 | -------------------------------------------------------------------------------- /restia/handler.lua: -------------------------------------------------------------------------------- 1 | --- Loads configurations from files on demand. 2 | -- @module restia.handler 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | 6 | local restia = require 'restia' 7 | 8 | local handler = {} 9 | 10 | --- Handles the result of xpcall. Exits on error. 11 | -- @param success Whether the function ran successfully. 12 | -- @param ... A list of return values to be passed through. 13 | local function exit_on_failure(success, ...) 14 | if success then 15 | return ... 16 | else 17 | return ngx.exit(tonumber(...) or ngx.status) 18 | end 19 | end 20 | 21 | --- Similar to Luas `xpcall`, but exits the nginx request in case of error. 22 | -- A custom message handler takes care of logging and rendering an error message. 23 | -- This function is still very much work in progress and its behavior may change. 24 | -- @tparam function action The code that should run in "protected" mode to handle the request (a module name). 25 | -- @tparam function handler The error handler, which may return a HTTP error code. 26 | -- @return The return value of the action function. 27 | function handler.xpcall(action, handler, ...) 28 | return exit_on_failure(xpcall(action, handler, ...)) 29 | end 30 | 31 | function handler.assert(handler, ...) 32 | if ... then 33 | return ... 34 | else 35 | handler(select(2, ...)) 36 | return ngx.exit(tonumber(...) or ngx.status) 37 | end 38 | end 39 | 40 | --- Serves a handler. This is a higher-level wrapper to `handler.xpcall` that requires a module or loads a file. 41 | -- If no `action` is given, the module is assumed to return a handler function directly. 42 | -- If the `action` is given, the module is treated as a table and deep-indexed with `action` to get the handler function. 43 | -- @tparam string handlermodule Either a module name to `require` or filename to `loadfile` to get the handler. 44 | -- @tparam[opt="error"] string errormodule The module name of the error handler. 45 | -- @tparam[opt] string action Path to deep-index the handler module with to get handler function. 46 | -- @param ... Additional arguments to be passed to the handler. 47 | function handler.serve(handlermodule, errormodule, action, ...) 48 | local fn 49 | if handlermodule:find("%.lua$") then 50 | fn = handler.assert(require(errormodule or 'error'), dofile(handlermodule)) 51 | else 52 | fn = handler.xpcall(function() 53 | return require(handlermodule) 54 | end, require(errormodule or 'error')) 55 | end 56 | if action then 57 | fn = restia.utils.deepindex(fn, action) 58 | end 59 | handler.xpcall(fn, require(errormodule or 'error'), restia.request, ...) 60 | end 61 | 62 | --- Serves an action from a controller. This is a higher-level wrapper to `handler.xpcall` that requires or loads a class. 63 | -- The class module is assumed to return a constructor function that will be called without any arguments to get a new instance. 64 | -- @tparam string classmodule Either a module name to `require` or a filename to `loadfile` to get the controller class. 65 | -- @tparam[opt="index"] string action Method name to call on the controller class. 66 | -- @tparam[opt="error"] string errormodule The module name of the error handler. 67 | -- @param ... Additional arguments to be passed to the action 68 | function handler.controller(controllermodule, action, errormodule, ...) 69 | local class 70 | if controllermodule:find("%.lua$") then 71 | class = handler.assert(require(errormodule or 'error'), dofile(controllermodule)) 72 | else 73 | class = handler.xpcall(function() 74 | return require(controllermodule) 75 | end, require(errormodule or 'error')) 76 | end 77 | local instance = class() 78 | handler.xpcall(instance[action], require(errormodule or 'error'), instance, ...) 79 | end 80 | 81 | return handler 82 | -------------------------------------------------------------------------------- /restia/init.lua: -------------------------------------------------------------------------------- 1 | -- Main module. 2 | -- This is essentially just an autoloader for the submodules. 3 | -- @module restia 4 | -- @author DarkWiiPlayer 5 | -- @license Unlicense 6 | -- @usage 7 | -- local restia = require 'restia' 8 | -- assert(restia.template == require 'restia.template') 9 | 10 | local utils = require 'restia.utils' 11 | return utils.deepmodule(...) 12 | -------------------------------------------------------------------------------- /restia/logbuffer.lua: -------------------------------------------------------------------------------- 1 | --- A very simple helper function. 2 | -- @module restia.logbuffer 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | -- @usage 6 | -- local msg = restia.logbuffer () 7 | -- 8 | -- msg:error 'You have made a mistake' 9 | -- msg:info 'No actions have been committed' 10 | -- 11 | -- for _, m in pairs(msg) do 12 | -- print(m.class, m.message) 13 | -- end 14 | 15 | local meta = {} 16 | 17 | function meta:__index(key) 18 | if type(key) == "string" then 19 | self[key] = function(self, message) 20 | table.insert(self, { class=key, message=message }) 21 | end 22 | return self[key] 23 | end 24 | end 25 | 26 | return function() 27 | return setmetatable({}, meta) 28 | end 29 | -------------------------------------------------------------------------------- /restia/markdown.lua: -------------------------------------------------------------------------------- 1 | --- Markdown auxiliary module. 2 | -- Allows rendering markdown files directly into the document. 3 | -- @module restia.markdown 4 | -- @author DarkWiiPlayer 5 | -- @license Unlicense 6 | 7 | local markdown = {} 8 | 9 | local lunamark = require "lunamark" 10 | 11 | --- Parses markdown into HTML 12 | -- @function parsemd 13 | -- @local 14 | -- @tparam string markdown 15 | -- @treturn string 16 | -- @usage parsemd 'some *fancy* text' 17 | do local writer = lunamark.writer.html5.new{containers=true} 18 | markdown.parse = lunamark.reader.markdown.new(writer, {smart = true}) 19 | end 20 | 21 | --- Parses a markdown file. 22 | -- @tparam string path The markdown file to read 23 | -- @treturn string 24 | -- @usage parsemdfile 'documents/markdown/article.md' 25 | local function parsemdfile(path) 26 | local file, err = io.open(path) 27 | if file then 28 | return markdown.parse(file:read("*a")) 29 | else 30 | return file, err 31 | end 32 | end 33 | 34 | local markdown_cache = {} 35 | 36 | --- Renders a markdown file. 37 | -- @fixme Do caching properly 38 | -- @tparam string document Markdown file (without extension) to load 39 | -- @tparam[opt=false] boolean cache Whether to cache the template 40 | -- @usage 41 | -- restia.markdown('content') 42 | function markdown.load(document, cache) 43 | document = document .. '.md' 44 | if cache then 45 | markdown_cache[document] = markdown_cache[document] or parsemdfile(document) 46 | return markdown_cache[document] 47 | else 48 | return parsemdfile(document) 49 | end 50 | end 51 | 52 | return markdown 53 | -------------------------------------------------------------------------------- /restia/negotiator.lua: -------------------------------------------------------------------------------- 1 | --- Handles content negotiation with the client. 2 | -- @module restia.negotiator 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | 6 | local unpack = unpack or table.unpack or error("No unpack function found!") 7 | 8 | local negotiator = {} 9 | 10 | --- Parses an "accept" header and returns its entries. 11 | -- Values are returned as: `{q = , s = , type = }` 12 | -- where specificity can be 1 for `*/*`, 2 for `/*` or 3 for `/` 13 | -- @tparam string accept The full HTTP Accept header 14 | function negotiator.parse(accept) 15 | local accepted = {} 16 | for param in accept:gmatch('[^, ]+') do 17 | local m, n = param:match("([^/]+)/([^;]+)") 18 | local s = m=='*' and 1 or n=='*' and 2 or 3 19 | local q = tonumber(param:match(';q=([%d.]+)')) or 1 20 | table.insert(accepted, {q=q, s=s, type=m..'/'..n}) 21 | end 22 | 23 | table.sort(accepted, function(a, b) 24 | return a.q>b.q 25 | or a.q==b.q and a.s>b.s 26 | or a.q==b.q and a.s==b.s and a.type < b.type 27 | end) 28 | return accepted 29 | end 30 | 31 | --- Escapes all the special pattern characters in a string 32 | -- @tparam string pattern A string to escape 33 | local function escape(pattern) 34 | return pattern:gsub('[%^%$%(%)%%%.%[%]%+%-%?%*]', function(char) return '%'..char end) 35 | end 36 | 37 | --- Takes a content type string and turns the string into patterns to match said type(s) 38 | -- @tparam string accept A single content-type 39 | local function pattern(accept) 40 | local s, t = accept.s, accept.type 41 | if s == 1 then 42 | return '.+/.+' 43 | elseif s == 2 then 44 | return "^"..escape(t:match('^[^/]+/'))..".+" 45 | elseif s == 3 then 46 | return "^"..escape(t).."$" 47 | else 48 | error("Specificity must be between 1 and 3, got "..tostring(s)) 49 | end 50 | end 51 | 52 | --- Works like `negotiator.parse` but adds a `pattern` field to them. 53 | function negotiator.patterns(accept) 54 | local accepted = negotiator.parse(accept) 55 | for k,value in ipairs(accepted) do 56 | accepted[k].pattern = pattern(value) 57 | end 58 | return accepted 59 | end 60 | 61 | --- Picks a value from a content-type -> value map respecting an accept header. 62 | -- When handlers are given as a sequence of tuples or strings, it checks them in 63 | -- order and prefers lower indices when more than one element matches. This is 64 | -- to allow prioritizing computationally cheaper content representations when 65 | -- clients can accept both. @tparam string accept A full HTTP Accept header 66 | -- @tparam table available A table of content types 67 | -- @return type value 68 | -- @usage 69 | -- -- Check in order and use first match 70 | -- type, content = restia.negotiator.pick(headers.accept, { 71 | -- {'text/plain', "Hello!"}, 72 | -- {'text/html', "

Hello!

"} 73 | -- }) 74 | -- -- Check out of order and use first match 75 | -- type, content = restia.negotiator.pick(headers.accept, { 76 | -- ['text/plain'] = "Hello!" 77 | -- ['text/html'] = "

Hello!

", 78 | -- }) 79 | -- -- Return a fitting content type 80 | -- type = restia.negotiator.pick(headers.accept, { 81 | -- 'appliaction/json', 'text/html', 'application/yaml' 82 | -- }) 83 | -- -- Returns a fallback 84 | -- type, content = 85 | -- restia.negotiator.pick(headers.accept, {}, "text/plain", "Hello!") 86 | function negotiator.pick(accept, available, ...) 87 | for i, entry in ipairs(negotiator.patterns(accept)) do 88 | if type(available[1])=="table" then 89 | for j, pair in ipairs(available) do 90 | local name, value = unpack(pair) 91 | if name:find(entry.pattern) then 92 | return name, value 93 | end 94 | end 95 | elseif type(available[1])=="string" then 96 | for j, name in ipairs(available) do 97 | if name:find(entry.pattern) then 98 | return name 99 | end 100 | end 101 | else 102 | for name, value in pairs(available) do 103 | if name:find(entry.pattern) then 104 | return name, value 105 | end 106 | end 107 | end 108 | end 109 | return ... 110 | end 111 | 112 | return negotiator 113 | -------------------------------------------------------------------------------- /restia/request.lua: -------------------------------------------------------------------------------- 1 | --- A helper "object" that provides common request-related information as 2 | -- attributes. Most functions are either reasonably fast or memoized, so the 3 | -- user does not need to take care of caching values outside of very critical 4 | -- code. 5 | -- @module restia.request 6 | -- @author DarkWiiPlayer 7 | -- @license Unlicense 8 | -- @usage 9 | -- local req = restia.request 10 | -- -- restia.controller.xpcall passes this as its first argument automatically 11 | -- if req.method == "GET" then 12 | -- ngx.say(json.encode({message="Greetings from "..params.host.."!"})) 13 | -- else 14 | -- ngx.say(json.encode(req.params)) 15 | -- end 16 | 17 | local restia = require 'restia' 18 | local cookie = require 'resty.cookie' 19 | local multipart = require 'multipart' 20 | 21 | local request = {} 22 | 23 | --- "Offers" a set of content types during content negotiation. 24 | -- Given a set of possible content types, it tries figuring out what the client 25 | -- wants and picks the most fitting content handler. Automatically runs the 26 | -- handler and sends the result to the client. 27 | -- Alternatively, when given a list of strings as arguments, it will pick a 28 | -- suitable content type from them and return it. 29 | -- For the most part, this is a wrapper around `restia.negotiator.pick` and 30 | -- follows the same semantics in its "available" argument. 31 | -- When no content-type matches, an error is raised. 32 | -- @tparam table available A map from content-types to handlers. Either as a plain key-value map or as a sequence of key-value pairs in the form of two-element sequences. 33 | -- @param ... Additional arguments to be passed to the content handlers 34 | function request:offer(available, ...) 35 | assert(self.headers, "Request object has no headers!") 36 | 37 | if type(available) == "table" then 38 | local content_type, handler = 39 | restia.negotiator.pick(self.headers.accept, available) 40 | ngx.header["content-type"] = content_type 41 | if handler then 42 | local result = handler(self, ...) 43 | return ngx.say(tostring(result)) 44 | else 45 | error("No suitable request handler found", 2) 46 | end 47 | else 48 | return restia.negotiator.pick(self.headers.accept, {available, ...}, nil, "No suitable content type supported") 49 | end 50 | end 51 | 52 | local get, set = restia.accessors.new(request) 53 | 54 | --- Getters 55 | -- @section getters 56 | 57 | --- Returns the request parameters. 58 | -- @function params 59 | -- @treturn table A table containing the request parameters. 60 | function get:params() 61 | if not ngx.ctx.params then 62 | if self.method == "GET" then 63 | ngx.ctx.params = restia.utils.deepen(ngx.req.get_uri_args()) 64 | elseif self.method == "POST" then 65 | ngx.req.read_body() 66 | if self.type == "application/json" then 67 | local json = require 'cjson' 68 | ngx.ctx.params = json.decode(ngx.req.get_body_data()) 69 | elseif self.type == "application/x-www-form-urlencoded" then 70 | ngx.ctx.params = restia.utils.deepen(ngx.req.get_post_args()) 71 | elseif self.type == "multipart/form-data" then 72 | local body_file = ngx.req.get_body_file() 73 | local data 74 | if body_file then 75 | local file = io.open(body_file) 76 | data = file:read("a") 77 | file:close() 78 | else 79 | data = ngx.req.get_body_data() 80 | end 81 | return multipart(data, self.headers.content_type):get_all() 82 | else 83 | error("Don't know how to handle type: "..self.type, 2) 84 | end 85 | end 86 | end 87 | 88 | return ngx.ctx.params 89 | end 90 | 91 | --- Returns the HTTP method of the current request. 92 | -- @function method 93 | -- @treturn string Method The request method 94 | function get:method() 95 | return ngx.req.get_method() 96 | end 97 | 98 | local __headers = { 99 | __index = function(self, index) 100 | if type(index)=="string" and index:find("_") then 101 | return self[index:gsub("_", "-")] 102 | end 103 | end; 104 | } 105 | --- Returns a table containing all headers. 106 | -- For missing headers, it tries replacing underscores with dashes. 107 | -- @function headers 108 | -- @treturn table Headers 109 | function get:headers() 110 | if not ngx.ctx.headers then 111 | ngx.ctx.headers = setmetatable(ngx.req.get_headers(), __headers) 112 | end 113 | 114 | return ngx.ctx.headers 115 | end 116 | 117 | --- An alias for headers.content_type. 118 | -- @function type 119 | -- @treturn string Content type header 120 | function get:type() 121 | return (self.headers.content_type:match('^[^;]+')) -- Only up to the first comma :D 122 | end 123 | 124 | --- Returns the current hostname or address. 125 | -- @function host 126 | -- @treturn string Hostname or Address 127 | function get:host() 128 | return ngx.var.host 129 | end 130 | 131 | --- Returns the path part of the current request URI 132 | -- @function path 133 | -- @treturn string Path part of the URI 134 | function get:path() 135 | return ngx.var.uri 136 | end 137 | 138 | --- Wraps the lua-resty-cookie module and returns a cookie object for the current request. 139 | -- @function cookie 140 | -- @return Cookie object 141 | function get:cookie() 142 | if not ngx.ctx.cookie then 143 | ngx.ctx.cookie = assert(cookie:new()) 144 | end 145 | 146 | return ngx.ctx.cookie 147 | end 148 | 149 | return request 150 | -------------------------------------------------------------------------------- /restia/scaffold/app.lua: -------------------------------------------------------------------------------- 1 | local restia = require 'restia' 2 | local I = restia.utils.unpipe 3 | 4 | return function() 5 | local dir = { 6 | ['.gitignore'] = 7 | I[============[ 8 | |*_temp 9 | |*.pid 10 | ]============]; 11 | ['.secret'] = { 12 | key = restia.utils.randomhex(64); 13 | [".gitignore"] = "*\n!.gitignore"; 14 | }; 15 | ['openresty.conf'] = 16 | I[===========[ 17 | |error_log logs/error.log info; 18 | |pid openresty.pid; 19 | | 20 | |worker_processes auto; 21 | |events { 22 | | worker_connections 1024; 23 | |} 24 | | 25 | |http { 26 | | lua_code_cache off; # Change this for production 27 | | 28 | | lua_package_path 'lib/?.lua;lib/?/init.lua;lua_modules/share/lua/5.1/?.lua;lua_modules/share/lua/5.1/?/init.lua;;'; 29 | | lua_package_cpath 'lib/?.so;lib/?/init.so;lua_modules/lib/lua/5.1/?.so;lua_modules/lib/lua/5.1/?/init.so;;'; 30 | | 31 | | log_format main 32 | | '$remote_addr - $remote_user [$time_local] "$request" ' 33 | | '$status $body_bytes_sent "$http_referer" ' 34 | | '"$http_user_agent" "$http_x_forwarded_for"'; 35 | | 36 | | access_log logs/access.log main; 37 | | keepalive_timeout 65; 38 | | 39 | | default_type text/html; 40 | | charset utf-8; 41 | | 42 | | init_by_lua_block { 43 | | -- Preload modules 44 | | restia = require 'restia' 45 | | 46 | | require 'config' 47 | | require 'views' 48 | | 49 | | restia.template.require 'template.cosmo' 50 | | 51 | | -- Pre-require some stuff to work around openresty bug 52 | | -- (Openresty bug related to coroutines) 53 | | local _ = require('views').error, restia.handler 54 | | } 55 | | 56 | | server { 57 | | listen 8080; 58 | | 59 | | include config/*.conf; 60 | | include locations/*; 61 | | } 62 | |} 63 | ]===========]; 64 | locations = { 65 | root = I[[ 66 | |location = / { 67 | | content_by_lua_block { restia.handler.serve("controller.front", "error") } 68 | |} 69 | |location / { 70 | | if (-f controller$uri.lua) { content_by_lua_block { restia.handler.serve("controller/"..ngx.var.uri..".lua", "error") } } 71 | | 72 | | root static; 73 | | try_files $uri =404; 74 | |} 75 | ]]; 76 | }; 77 | static = { [".gitignore"] = "" }; 78 | controller = { 79 | ['front.lua'] = I[[ 80 | |local json = require 'cjson' 81 | |local views = require("views"); 82 | | 83 | |return function(req); 84 | | local data = { domain = req.host } 85 | | return req:offer { 86 | | {'application/json', function(req) 87 | | return json.encode(data) 88 | | end}; 89 | | {'text/html', function(req) 90 | | return views.front(data) 91 | | end}; 92 | | } 93 | |end; 94 | ]]; 95 | }; 96 | views = { 97 | ['front.html.skooma'] = I[[ 98 | |local strings = require('config').i18n[ngx.var.lang] 99 | |local params = ... 100 | |return html { 101 | | h1(strings.title); 102 | | h2(params.domain); 103 | |} 104 | ]]; 105 | ['error.html.skooma'] = I[[ 106 | |local params = ... 107 | |return html { 108 | | body { 109 | | h1("ERROR ", params.code); 110 | | h2(params.message); 111 | | p(pre(params.description)); 112 | | } 113 | |} 114 | ]]; 115 | }; 116 | models = {}; 117 | ['readme.md'] = I[[ 118 | |Restia Application 119 | |================================================================================ 120 | | 121 | | 122 | ]]; 123 | ['license.md'] = I[[ 124 | |All rights reserved 125 | | 126 | ]]; 127 | ['config.ld'] = I[[ 128 | |title = "Restia Application" 129 | |project = "Restia Application" 130 | |format = 'discount' 131 | |topics = { 132 | | 'readme.md', 133 | | 'license.md', 134 | |} 135 | |file = { 136 | | 'lib'; 137 | |} 138 | |all = true 139 | ]]; 140 | lib = { 141 | ['error.lua'] = 142 | I[===========[ 143 | |local json = require 'cjson' 144 | |local views = require 'views' 145 | |local restia = require 'restia' 146 | | 147 | |return function(message) 148 | | if not message then 149 | | message = '(No error message given)' 150 | | end 151 | | ngx.log(ngx.ERR, debug.traceback(message, 4)) 152 | | if ngx.status < 300 then 153 | | ngx.status = 500 154 | | end 155 | | 156 | | local err if ngx.var.dev=="true" then 157 | | err = { 158 | | code = ngx.status; 159 | | message = message:match('^[^\n]+'); 160 | | description = debug.traceback(message, 3); 161 | | } 162 | | else 163 | | err = { 164 | | code = ngx.status; 165 | | message = "There has been an error"; 166 | | description = "Please contact a site administrator if this error persists"; 167 | | } 168 | | end 169 | | 170 | | local content_type = ngx.header['content-type'] 171 | | if content_type == 'application/json' then 172 | | ngx.say(json.encode(err)) 173 | | elseif content_type == 'text/plain' then 174 | | ngx.say("Error ",err.code,"\n---------\n",err.description) 175 | | else 176 | | if views.error then 177 | | err.message = restia.utils.htmlescape(err.message) 178 | | err.description = restia.utils.htmlescape(err.description) 179 | | ngx.say(tostring(views.error(err))) 180 | | else 181 | | ngx.say('error '..tostring(ngx.status)) 182 | | end 183 | | end 184 | | return ngx.HTTP_INTERNAL_SERVER_ERROR 185 | |end 186 | ]===========]; 187 | ['views.lua'] = I[[ 188 | |local glass = require "glass" 189 | |local views = glass.bind("views", { 190 | | require 'glass.skooma.html', 191 | |}) 192 | |return views 193 | ]]; 194 | ['config.lua'] = I[[ 195 | |local glass = require "glass" 196 | |local config = glass.bind("config", { 197 | | require 'glass.yaml', 198 | | -- Add more loaders here 199 | |}) 200 | |return config 201 | ]]; 202 | template = { 203 | ['cosmo.lua'] = I[==========[ 204 | |function each(name, inner) 205 | | print(name.."[[") 206 | | inner() 207 | | print("]]") 208 | |end 209 | ]==========] 210 | }; 211 | }; 212 | spec = { 213 | views = { 214 | ['load_spec.moon'] = 215 | I[===========[ 216 | |restia = require 'restia' 217 | |utils = require 'restia.utils' 218 | | 219 | |describe 'View', -> 220 | | before_each -> 221 | | _G.ngx = {print: ->} 222 | | for file in utils.files 'views' 223 | | describe file, -> 224 | | it 'should load', -> 225 | | assert.truthy require("restia.config").bind('views')[file\gsub('^views/', '')\gsub('%..-$', '')] 226 | ]===========]; 227 | }; 228 | i18n = { 229 | ['locale_spec.moon'] = 230 | I[===========[ 231 | |default = 'en' 232 | |additional = { 'de', 'es' } 233 | | 234 | |i18n = require('restia').config.bind("config/i18n") 235 | | 236 | |describe "Default locale", -> 237 | | it "should exist", -> 238 | | assert i18n[default] 239 | | 240 | |rsub = (subset, superset={}, prefix='') -> 241 | | for key in pairs(subset) 242 | | switch type(subset[key]) 243 | | when "string" 244 | | it "Should contain the key "..prefix..tostring(key), -> 245 | | assert.is.string superset[key] 246 | | when "table" 247 | | rsub subset[key], superset[key], prefix..tostring(key)..'.' 248 | | 249 | | 250 | |describe "Additional locale", -> 251 | | for name in *additional 252 | | describe '"'..name..'"', -> 253 | | locale = i18n[name] 254 | | it "should exist", -> assert.is.table locale 255 | | rsub(i18n[default], locale) 256 | ]===========]; 257 | }; 258 | }; 259 | config = { 260 | i18n = { 261 | ['en.yaml'] = 'title: My Website'; 262 | ['de.yaml'] = 'title: Meine Webseite'; 263 | ['es.yaml'] = 'title: Mi Pagina Web'; 264 | }; 265 | ['settings.conf'] = "set $lang en;\nset $dev true;"; 266 | ['types.conf'] = I[===[ 267 | |types { # Or just include nginx' default types file :D 268 | | text/html html; 269 | | text/css css; 270 | | application/javascript js; 271 | |} 272 | ]===] 273 | }; 274 | ['.busted'] = 275 | I[==========[ 276 | |return { 277 | | _all = { 278 | | lpath = 'lua_modules/share/lua/5.1/?.lua;lua_modules/share/lua/5.1/?/init.lua;lib/?.lua;lib/?/init.lua'; 279 | | cpath = 'lua_modules/lib/lua/5.1/?.lua;lua_modules/lib/lua/5.1/?/init.lua'; 280 | | }; 281 | |} 282 | ]==========]; 283 | ['.luacheckrc'] = [[std = 'ngx_lua']]; 284 | 285 | logs = { ['.gitignore'] = "*\n!.gitignore" }; 286 | 287 | -- Create local rock tree 288 | ['.luarocks'] = {['default-lua-version.lua'] = 'return "5.1"'}; 289 | lua_modules = {['.gitignore'] = '*\n!.gitignore'}; 290 | 291 | -- Shell script to install dependancies 292 | ['application-dev-1.rockspec'] = I[[ 293 | |package = "application" 294 | |version = "dev-1" 295 | |source = { 296 | | url = "" 297 | |} 298 | |description = { 299 | | homepage = "", 300 | | license = "" 301 | |} 302 | |dependencies = { 303 | | "lua ~> 5.1", 304 | | "restia" 305 | |} 306 | |build = { 307 | | type = "builtin", 308 | | modules = {} 309 | |} 310 | ]]; 311 | } 312 | return dir 313 | end 314 | -------------------------------------------------------------------------------- /restia/scaffold/blog.lua: -------------------------------------------------------------------------------- 1 | local restia = require 'restia' 2 | local I = restia.utils.unpipe 3 | 4 | return function() 5 | return { 6 | ['example.post'] = I[[ 7 | |--- 8 | |title: Example 9 | |date: 1970-01-01 10 | |--- 11 | |This is an example post. 12 | ]]; 13 | ['build'] = I[[ 14 | |#!/bin/sh 15 | |lua build.lua --delete --copy css --copy javascript 16 | ]]; 17 | ['templates'] = { 18 | ['main.skooma'] = I[=====[ 19 | |local function map(tab, fn) 20 | | local new = {} 21 | | for key, value in ipairs(tab) do 22 | | new[key] = fn(value) 23 | | end 24 | | return new 25 | |end 26 | | 27 | |return function(content, attributes) 28 | | local route = {} 29 | | for segment in attributes.uri:gmatch("[^/]+") do 30 | | table.insert(route, segment) 31 | | end 32 | | 33 | | return render.html(html{ 34 | | head { 35 | | title(attributes.title); 36 | | link{rel="stylesheet", href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.58/dist/themes/light.css"}; 37 | | script{type="module", src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.58/dist/shoelace.js"}; 38 | | script { type="module", [[ 39 | | document.querySelector("#navigation-button").addEventListener("click", event => { 40 | | document.querySelector("#menu-drawer").open = true 41 | | }) 42 | | ]]}; 43 | | }; 44 | | body { 45 | | style = "padding: 2em max(calc(50vw - 40em), 2em)"; 46 | | slDrawer { 47 | | placement = "start"; 48 | | id = "menu-drawer"; 49 | | ul(map(require("posts"), function(post) 50 | | return li(a{href=post.head.uri, post.head.title}); 51 | | end)); 52 | | }; 53 | | div { 54 | | style="display: flex; flex-flow: row; gap: 1em; align-items: center;"; 55 | | slButton { slIcon {name = "list", slot="prefix"}, "Menu", id="navigation-button" }; 56 | | slBreadcrumb { 57 | | map(route, slBreadcrumbItem); 58 | | }; 59 | | }; 60 | | article { 61 | | h1(attributes.title); 62 | | content; 63 | | } 64 | | }; 65 | | }) 66 | |end 67 | ]=====]; 68 | }; 69 | ['build.lua'] = I[[ 70 | |local arrr = require 'arrr' 71 | |local restia = require 'restia' 72 | |local shapeshift = require 'shapeshift' 73 | | 74 | |local params do 75 | | local is = shapeshift.is 76 | | local parse = arrr { 77 | | { "Output directory", "--output", "-o", 'directory' }; 78 | | { "Input directory", "--input", "-i", 'directory' }; 79 | | { "Copy directory", "--copy", "-c", 'directory', 'repeatable' }; 80 | | { "Delete everything first", "--delete", "-d" }; 81 | | } 82 | | local validate = shapeshift.table { 83 | | output = shapeshift.default("out", is.string); 84 | | input = shapeshift.default(".", is.string); 85 | | copy = shapeshift.default({}, shapeshift.all{ 86 | | is.table, 87 | | shapeshift.each(is.string) 88 | | }); 89 | | delete = shapeshift.default(false, shapeshift.is.boolean); 90 | | } 91 | | params = select(2, assert(validate(parse{...}))) 92 | |end 93 | | 94 | |local config = restia.config.bind('config', { 95 | | (require 'restia.config.readfile'); 96 | | (require 'restia.config.lua'); 97 | | (require 'restia.config.yaml'); 98 | |}) 99 | |package.loaded.config = config 100 | | 101 | |local templates = restia.config.bind('templates', { 102 | | (require 'restia.config.skooma'); 103 | |}) 104 | |package.loaded.templates = templates 105 | | 106 | |local function render_post(file) 107 | | local post = restia.config.post(from) 108 | | 109 | | restia.utils.mkdir(to:gsub("[^/]+$", "")) 110 | | local outfile = assert(io.open(to, 'wb')) 111 | | outfile:write(body) 112 | | outfile:close() 113 | |end 114 | | 115 | |local posts = {} 116 | |package.loaded.posts = posts 117 | | 118 | |local tree = {} 119 | | 120 | |for i, path in ipairs(params.copy) do 121 | | restia.utils.deepinsert(tree, restia.utils.fs2tab(path), restia.utils.readdir(path)) 122 | |end 123 | | 124 | |local validate_head do 125 | | local is = shapeshift.is 126 | | validate_head = shapeshift.table { 127 | | __extra = 'keep'; 128 | | title = is.string; 129 | | date = shapeshift.matches("%d%d%d%d%-%d%d%-%d%d"); 130 | | file = is.string; 131 | | } 132 | |end 133 | | 134 | |local function parsedate(date) 135 | | local year, month, day = date:match("(%d+)%-(%d+)%-(%d+)") 136 | | return os.time { 137 | | year = tonumber(year); 138 | | month = tonumber(month); 139 | | day = tonumber(day); 140 | | } 141 | |end 142 | | 143 | |-- Load Posts 144 | |for file in restia.utils.files(params.input, "%.post$") do 145 | | local post = restia.config.post(file:gsub("%.post$", "")) 146 | | post.head.file = file 147 | | 148 | | assert(validate_head(post.head)) 149 | | 150 | | post.head.timestamp = parsedate(post.head.date) 151 | | 152 | | post.head.slug = post.head.title 153 | | :gsub(' ', '_') 154 | | :lower() 155 | | :gsub('[^a-z0-9-_]', '') 156 | | 157 | | post.head.uri = string.format("/%s/%s.html", post.head.date:gsub("%-", "/"), post.head.slug) 158 | | post.path = restia.utils.fs2tab(post.head.uri) 159 | | 160 | | table.insert(posts, post) 161 | |end 162 | | 163 | |table.sort(posts, function(a, b) 164 | | return a.head.timestamp > b.head.timestamp 165 | |end) 166 | | 167 | |-- Render Posts 168 | |for idx, post in ipairs(posts) do 169 | | local template if post.head.template then 170 | | template = templates[post.head.template] 171 | | elseif templates.main then 172 | | template = templates.main 173 | | end 174 | | 175 | | local body if template then 176 | | body = restia.utils.deepconcat(template(post.body, post.head)) 177 | | else 178 | | body = post.body 179 | | end 180 | | 181 | | restia.utils.deepinsert(tree, post.path, body) 182 | |end 183 | | 184 | |if params.delete then 185 | | restia.utils.delete(params.output) 186 | |end 187 | | 188 | |restia.utils.builddir(params.output, tree) 189 | ]]; 190 | } 191 | end 192 | -------------------------------------------------------------------------------- /restia/scaffold/init.lua: -------------------------------------------------------------------------------- 1 | -- Autoloader for scaffolds. 2 | -- This is essentially just an autoloader for the submodules. 3 | -- @module restia.scaffold 4 | -- @author DarkWiiPlayer 5 | -- @license Unlicense 6 | 7 | return require('restia.utils').deepmodule(...) 8 | -------------------------------------------------------------------------------- /restia/scaffold/static.lua: -------------------------------------------------------------------------------- 1 | local restia = require 'restia' 2 | local I = restia.utils.unpipe 3 | 4 | return function() 5 | return { 6 | build = I[[ 7 | |#!/bin/sh 8 | |lua build.lua --delete --copy css --copy javascript 9 | ]]; 10 | ["build.lua"] = I[[ 11 | |local arrr = require 'arrr' 12 | |local restia = require 'restia' 13 | |local shapeshift = require 'shapeshift' 14 | | 15 | |local params do 16 | | local is = shapeshift.is 17 | | local parse = arrr { 18 | | { "Output directory", "--output", "-o", 'directory' }; 19 | | { "Input directory", "--input", "-i", 'directory' }; 20 | | { "Copy directory", "--copy", "-c", 'directory', 'repeatable' }; 21 | | { "Delete everything first", "--delete", "-d" }; 22 | | } 23 | | local validate = shapeshift.table { 24 | | output = shapeshift.default("out", is.string); 25 | | input = shapeshift.default(".", is.string); 26 | | copy = shapeshift.default({}, shapeshift.all{ 27 | | is.table, 28 | | shapeshift.each(is.string) 29 | | }); 30 | | delete = shapeshift.default(false, shapeshift.is.boolean); 31 | | } 32 | | params = select(2, assert(validate(parse{...}))) 33 | |end 34 | | 35 | |local config = restia.config.bind('config', { 36 | | (require 'restia.config.readfile'); 37 | | (require 'restia.config.lua'); 38 | | (require 'restia.config.yaml'); 39 | |}) 40 | |package.loaded.config = config 41 | | 42 | |local layouts = restia.config.bind('layouts', { 43 | | (require 'restia.config.skooma'); 44 | |}) 45 | |package.loaded.layouts = layouts 46 | | 47 | |local tree = {} 48 | | 49 | |-- Render skooma files in pages/ into html files 50 | |-- preserving directory structure 51 | |for file in restia.utils.files('pages', "%.skooma$") do 52 | | local template = restia.config.skooma(file:gsub("%.skooma$", '')) 53 | | restia.utils.deepinsert( 54 | | tree, 55 | | restia.utils.fs2tab( 56 | | file 57 | | :gsub("^pages/", "") 58 | | :gsub("%.skooma$", ".html") 59 | | ), 60 | | layouts.main(template()) 61 | | ) 62 | |end 63 | | 64 | |for i, path in ipairs(params.copy) do 65 | | restia.utils.deepinsert(tree, restia.utils.fs2tab(path), restia.utils.readdir(path)) 66 | |end 67 | | 68 | |if params.delete then 69 | | restia.utils.delete(params.output) 70 | |end 71 | | 72 | |restia.utils.builddir(params.output, tree) 73 | ]]; 74 | config = { 75 | ["page.yaml"] = I[[ 76 | |title: Test Website 77 | ]]; 78 | }; 79 | layouts = { 80 | ["main.skooma"] = I[[ 81 | |local config = require 'config' 82 | | 83 | |return function(content) 84 | | return render.html( 85 | | html( 86 | | head( 87 | | title(config.page.title) 88 | | ), 89 | | body ( 90 | | content 91 | | ) 92 | | ) 93 | | ) 94 | |end 95 | ]]; 96 | }; 97 | pages = { 98 | ["index.skooma"] = I[[ 99 | |return function() 100 | | return p 'Hello, World!' 101 | |end 102 | ]]; 103 | }; 104 | } 105 | end 106 | -------------------------------------------------------------------------------- /restia/secret.lua: -------------------------------------------------------------------------------- 1 | --- Handles secret information 2 | -- @module restia.secret 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | 6 | local config = require 'restia.config' 7 | local cipher = require 'openssl.cipher' 8 | local digest = require 'openssl.digest' 9 | local json = require 'cjson' 10 | 11 | local secret = config.bind('.secret', { require 'restia.config.readfile' }) 12 | local aes = cipher.new 'AES-256-CBC' 13 | local key = digest.new('sha256'):final(assert(secret.key, "couldn't find `secret.key` config entry!")) 14 | 15 | --- Encrypts a string containing binary data. 16 | -- Uses the servers secret as an encryption key. 17 | -- @tparam string input 18 | -- @treturn string Binary string containing encrypted data 19 | function secret:string_encrypt(plain) 20 | if not self and (self.key)=='inputing' then 21 | return nil, 'Could not load .config.key field' 22 | end 23 | 24 | return aes:encrypt(key, key:sub(1,16)):final(plain) 25 | end 26 | 27 | --- Decrypts a string containing binary data. 28 | -- Uses the servers secret as an encryption key. 29 | -- @tparam string input 30 | -- @treturn string String containing decrypted data 31 | function secret:string_decrypt(encrypted) 32 | if not self and (self.key)=='string' then 33 | return nil, 'Could not load .config.key field' 34 | end 35 | local res, err 36 | res, err = digest.new('sha256'):final(self.key); if not res then return nil, err end 37 | return aes:decrypt(key, key:sub(1,16)):final(encrypted) 38 | end 39 | 40 | --- Encrypts a Lua object with the server secret. 41 | -- @param object A Lua object to encrypt 42 | -- @treturn string encrypted String containing encrypted binary data in base-64 representation 43 | function secret:encrypt(object) 44 | local res, err 45 | res, err = json.encode(object); if not res then return nil, err end 46 | res, err = self:string_encrypt(res); if not res then return nil, err end 47 | return ngx.encode_base64(res) 48 | end 49 | 50 | --- Decrypts a Lua object with the server secret. 51 | -- @tparam string encrypted A Base-64 encoded string returned by `server:encrypt()` 52 | -- @return decrypted A (deep) copy of the object passed into `secret:encrypt()` 53 | function secret:decrypt(encrypted) 54 | local res, err 55 | res, err = ngx.decode_base64(encrypted); if not res then return nil, err end 56 | res, err = self:string_decrypt(res); if not res then return nil, err end 57 | return json.decode(res) 58 | end 59 | 60 | return secret 61 | -------------------------------------------------------------------------------- /restia/template.lua: -------------------------------------------------------------------------------- 1 | --- Template module. 2 | -- Sets up an xhMoon environment and adds the utility functions. 3 | -- @module restia.template 4 | -- @author DarkWiiPlayer 5 | -- @license Unlicense 6 | 7 | local template = {} 8 | 9 | local moonxml = require "moonxml" 10 | 11 | local restia_html 12 | 13 | template.metatable = { 14 | __index = template; 15 | __call=function(self, ...) 16 | return self:render(...) 17 | end; 18 | } 19 | 20 | --- Allows injecting code directly into the language environment. 21 | -- This should only be used for very short snippets; 22 | -- using `template.require` is preferred. 23 | -- @tparam function fn A function that gets called with the language environment. 24 | function template.inject(fn) 25 | fn(restia_html.environment) 26 | end 27 | 28 | --- Stores required modules just like `package.loaded` does for normal Lua modules. 29 | template.loaded = {} 30 | 31 | --- Requires a module in a similar way to Luas `require` function, 32 | -- but evaluates the code in the MoonXML language environment. 33 | -- This allows writing specialized MoonHTML macros to avoid 34 | -- code duplication in views. As with `requier`, `package.path` is 35 | -- used to look for Lua modules. 36 | -- @tparam string modname The name of the module. 37 | -- @return module The loaded module. In other words, the return value of the evaluated Lua file. 38 | function template.require(modname) 39 | if not template.loaded[modname] then 40 | local filename = assert(package.searchpath(modname, package.path)) 41 | local module = assert(restia_html:loadluafile(filename)) 42 | template.loaded[modname] = module() 43 | end 44 | return template.loaded[modname] 45 | end 46 | 47 | --- Loads a template from lua code. 48 | -- The code may be compiled bytecode. 49 | function template.loadlua(code, filename) 50 | local temp, err = restia_html:loadlua(code, filename) 51 | if temp then 52 | return setmetatable({raw=temp, name=filename}, template.metatable) 53 | else 54 | return nil, err 55 | end 56 | end 57 | 58 | --- Loads a template from moonscript code. 59 | function template.loadmoon(code, filename) 60 | local temp, err = restia_html:loadmoon(code, filename) 61 | if temp then 62 | return setmetatable({raw=temp, name=filename}, template.metatable) 63 | else 64 | return nil, err 65 | end 66 | end 67 | 68 | --- Renders the template to a buffer table 69 | function template:render(...) 70 | local buff = {} 71 | local _print = restia_html.environment.print 72 | local before = os.clock() 73 | 74 | restia_html.environment.print = function(...) 75 | for i=1,select('#', ...) do 76 | table.insert(buff, (select(i, ...))) 77 | end 78 | end 79 | local res = self.raw(...) 80 | if res then table.insert(buff, res) end 81 | restia_html.environment.print = _print 82 | 83 | local after = os.clock() 84 | -- print(string.format("Template <%s> rendered in: %.6f seconds", self.name or "nameless", after-before)) 85 | 86 | return buff 87 | end 88 | 89 | --- HTML Builder Environment. 90 | -- Automatically has access to the Restia library in the global variable 'restia'. 91 | -- @section moonxml 92 | 93 | restia_html = moonxml.html:derive(function(_ENV) 94 | function print(...) 95 | ngx.print(...) 96 | end 97 | 98 | --- Embeds a stylesheet into the document. 99 | -- @param uri The URI to the stylesheet or a sequence of URIs 100 | -- @tparam boolean async Load the stylesheet asynchronously and apply it once it's loaded 101 | -- @function stylesheet 102 | -- @usage 103 | -- stylesheet 'styles/site.css' 104 | -- stylesheet 'styles/form.css', true 105 | stylesheet = function(uri, async) 106 | if type(uri)=="table" then 107 | for i, _uri in ipairs(uri) do 108 | stylesheet(_uri, async) 109 | end 110 | return 111 | end 112 | if async then 113 | link({rel='stylesheet', href=uri, type='text/css', media='print', onload='this.media=`all`'}) 114 | else 115 | --link({rel='preload', href=uri, type='text/css', onload='this.rel="stylesheet"'}) 116 | link({rel='stylesheet', href=uri, type='text/css'}) 117 | end 118 | end 119 | 120 | --- Renders an unordered list. 121 | -- List elements can be any valid MoonHTML data object, 122 | -- including functions and tables. 123 | -- They get passed directly to the `li` function call. 124 | -- @tparam table list A sequence containing the list elements 125 | -- @function ulist 126 | -- @usage 127 | -- ulist { 128 | -- 'Hello' 129 | -- 'World' 130 | -- -> 131 | -- br 'foo' 132 | -- print 'bar' 133 | -- 'That was a list' 134 | -- } 135 | function ulist(list) 136 | ul(function() 137 | for index, item in ipairs(list) do 138 | li(item) 139 | end 140 | end) 141 | end 142 | 143 | --- Renders an ordered list. Works like ulist. 144 | -- @tparam table list A sequence containing the list elements 145 | -- @function olist 146 | -- @see ulist 147 | function olist(list) 148 | ol(function() 149 | for index, item in ipairs(list) do 150 | li(item) 151 | end 152 | end) 153 | end 154 | 155 | --- Renders a table (vertical). 156 | -- @param ... A list of rows (header rows can be marked by setting the `header` key) 157 | -- @function vtable 158 | -- @usage 159 | -- vtable( 160 | -- {'Name', 'Balance', header = true}, 161 | -- {'John', '500 €'} 162 | -- ) 163 | function vtable(...) 164 | local rows = {...} 165 | node('table', function() 166 | for rownum, row in ipairs(rows) do 167 | fun = row.header and th or td 168 | tr(function() 169 | for colnum, cell in ipairs(row) do 170 | fun(cell) 171 | end 172 | end) 173 | end 174 | end) 175 | end 176 | 177 | --- Renders a table. Expects a sequence of keys as its first argument. 178 | -- Additional options can also be passed into the first table. 179 | -- Following arguments will be interpreted as key/value maps. 180 | -- @tparam table opt A sequence containing the keys to be rendered. 181 | -- @tparam table rows A sequence of tables that represent the table rows 182 | -- @function ttable 183 | -- @usage 184 | -- ttable({'name', 'age', 'address', number: true, header: true, caption: -> h1 'People'}, { 185 | -- {name: "John Doe", age: -> i 'unknown', address: -> i 'unknown'} 186 | -- }) 187 | function ttable(opt, rows) 188 | -- Header defaults to true 189 | if opt.header==nil then opt.header=true end 190 | 191 | node('table', function() 192 | if opt.caption then 193 | caption(opt.caption) 194 | end 195 | 196 | if opt.header then 197 | tr(function() 198 | if opt.number then th '#' end 199 | for idx,header in ipairs(opt) do 200 | th(tostring(header)) 201 | end 202 | end) 203 | end 204 | 205 | for i,row in ipairs(rows) do 206 | tr(function() 207 | if opt.number then td(i) end 208 | for idx,key in ipairs(opt) do 209 | td(row[key]) 210 | end 211 | end) 212 | end 213 | end) 214 | end 215 | 216 | --- Renders a script tag from Lua code to be used by fengari. 217 | -- @tparam string code The content of the script tag (Lua code). 218 | -- @function lua 219 | -- @usage 220 | -- lua [[ 221 | -- print "Hello, World!" 222 | -- ]] 223 | function lua(code) 224 | script(function() print(code) end, {type = 'application/lua'}) 225 | end 226 | 227 | --- Renders a script tag from Moonscript code to be used by fengari. 228 | -- The code is first stripped of additional indentation and converted to Lua. 229 | -- @tparam string code The content of the script tag (Moonscript code). 230 | -- @function moon 231 | -- @usage 232 | -- lua [[ 233 | -- print "Hello, World!" 234 | -- ]] 235 | function moon(code) 236 | local utils = require 'restia.utils' 237 | local moonscript = require 'moonscript.base' 238 | lua(assert(moonscript.to_lua(utils.normalizeindent(code)))) 239 | end 240 | end) 241 | 242 | return template 243 | -------------------------------------------------------------------------------- /restia/utils.lua: -------------------------------------------------------------------------------- 1 | --- Utility functions for Restia 2 | -- @module restia.utils 3 | -- @author DarkWiiPlayer 4 | -- @license Unlicense 5 | 6 | local utils = {} 7 | 8 | local lfs = require 'lfs' 9 | local colors = require 'restia.colors' 10 | 11 | local htmlescapes = { 12 | ['&'] = '&', 13 | ['<'] = '<', 14 | ['>'] = '>', 15 | ['"'] = '"', 16 | ["'"] = ''', 17 | } 18 | 19 | do local buf = {} 20 | for char in pairs(htmlescapes) do 21 | table.insert(buf, char) 22 | end 23 | htmlescapes.pattern = "["..table.concat(buf).."]" 24 | end 25 | --- Escapes special HTML characters in a string 26 | function utils.htmlescape(str) 27 | return (tostring(str):gsub(htmlescapes.pattern, htmlescapes)) 28 | end 29 | 30 | --- Makes a table look up missing keys with `require` 31 | function utils.deepmodule(prefix) 32 | return setmetatable({}, { 33 | __index = function(self, name) 34 | return require(prefix .. "." .. name) 35 | end 36 | }) 37 | end 38 | 39 | --- Mixes several tables into another and returns it. 40 | function utils.mixin(first, second, ...) 41 | if type(second)=="table" then 42 | for key, value in pairs(second) do 43 | if type(value) == "table" then 44 | if type(first[key]) ~= "table" then 45 | first[key] = {} 46 | end 47 | utils.mixin(first[key], value) 48 | else 49 | first[key] = value 50 | end 51 | end 52 | return utils.mixin(first, ...) 53 | else 54 | return first 55 | end 56 | end 57 | 58 | --- Removes excessive indentation from a block of text 59 | function utils.normalizeindent(block) 60 | local indent = '^'..(block:match("^%s+") or '') 61 | return (block:gsub('[^\n]+', function(line) 62 | return line:gsub(indent, ''):gsub('[\t ]+$', ''):gsub("^%s*$", '') 63 | end)) 64 | end 65 | 66 | --- Removes leading whitespace up to and including a pipe character. 67 | -- This is used to trim off unwanted whitespace at the beginning of a line. 68 | -- This is hopefully a bit faster and more versatile than the normalizeindent function. 69 | function utils.unpipe(block) 70 | return block:gsub('[^\n]+', function(line) 71 | return line:gsub('^%s*|', ''):gsub('^%s+$', '') 72 | end) 73 | end 74 | 75 | --- Indexes tables recursively with a chain of string keys 76 | function utils.deepindex(tab, path) 77 | if type(path)~="string" then 78 | return nil, "path is not a string" 79 | end 80 | local index, rest = path:match("^%.?([%a%d]+)(.*)") 81 | if not index then 82 | index, rest = path:match("^%[(%d+)%](.*)") 83 | index = tonumber(index) 84 | end 85 | if index then 86 | if #rest>0 then 87 | if tab[index] then 88 | return utils.deepindex(tab[index], rest) 89 | else 90 | return nil, "full path not present in table" 91 | end 92 | else 93 | return tab[index] 94 | end 95 | else 96 | return nil, "malformed index-path string" 97 | end 98 | end 99 | 100 | --- Inserts a table into a nested table following a path. 101 | -- The path string mimics normal chained indexing in normal Lua. 102 | -- Nil-elements along the path will be created as tables. 103 | -- Non-nil elements will be indexed and error accordingly if this fails. 104 | -- @tparam table tab A table or indexable object to recursively insert into 105 | -- @tparam table path A string describing the path to iterate 106 | -- @param value The value that will be inserted 107 | -- @usage 108 | -- utils.deepinsert(some_table, 'foo.bar.baz', value) 109 | function utils.deepinsert(tab, path, value) 110 | if type(path) == "table" then 111 | local current = tab 112 | for i=1,math.huge do 113 | local key = path[i] 114 | if path[i+1] then 115 | if not current[key] then 116 | current[key] = {} 117 | end 118 | current = current[key] 119 | else 120 | current[key] = value 121 | break 122 | end 123 | end 124 | return value or true 125 | elseif type(path) == "string" then 126 | local index, rest = path:match("^%.?([^%[%.]+)(.*)") 127 | if not index then 128 | index, rest = path:match("^%[(%d+)%](.*)") 129 | index = tonumber(index) 130 | end 131 | if index then 132 | if #rest>0 then 133 | local current 134 | if tab[index] then 135 | current = tab[index] 136 | else 137 | current = {} 138 | tab[index] = current 139 | end 140 | return utils.deepinsert(current, rest, value) 141 | else 142 | tab[index] = value 143 | return value or true 144 | end 145 | else 146 | return nil, "malformed index-path string: " .. path 147 | end 148 | else 149 | return nil, "path is neither string nor table: " .. type(path) 150 | end 151 | end 152 | 153 | --- Turns a flat table and turns it into a nested table. 154 | -- @usage 155 | -- local deep = restia.utils.deep { 156 | -- ['foo.bar.baz'] = "hello"; 157 | -- ['foo[1]'] = "first"; 158 | -- ['foo[2]'] = "second"; 159 | -- } 160 | -- -- Is equal to 161 | -- local deep = { 162 | -- foo = { 163 | -- "first", "second"; 164 | -- bar = { baz = "hello" }; 165 | -- } 166 | -- } 167 | function utils.deepen(tab) 168 | local deep = {} 169 | for path, value in pairs(tab) do 170 | if not utils.deepinsert(deep, path, value) then 171 | deep[path] = value 172 | end 173 | end 174 | return deep 175 | end 176 | 177 | utils.tree = {} 178 | 179 | --- Inserts a value into a tree. 180 | -- Every node in the tree, not only leaves, can hold a value. 181 | -- The special index __value is used for this and should not appear in the route. 182 | -- @tparam table head The tree to insert the value into. 183 | -- @tparam table route A list of values to recursively index the tree with. 184 | -- @param value Any Lua value to be inserted into the tree. 185 | -- @treturn table The head node of the tree. 186 | -- @see tree.get 187 | -- @usage 188 | -- local insert = restia.utils.tree.insert 189 | -- local tree = {} 190 | -- insert(tree, {"foo"}, "value 1") 191 | -- -- Nodes can have values and children at once 192 | -- insert(tree, {"foo", "bar"}, "value 2") 193 | -- -- Keys can be anything 194 | -- insert(tree, {function() end, {}}, "value 2") 195 | -- @function tree.insert 196 | function utils.tree.insert(head, route, value) 197 | local tail = head 198 | for i, key in ipairs(route) do 199 | local next = tail[key] 200 | if not next then 201 | next = {} 202 | tail[key] = next 203 | end 204 | tail = next 205 | end 206 | tail.__value = value 207 | return head 208 | end 209 | 210 | --- Gets a value from a tree. 211 | -- @tparam table head The tree to retreive the value from. 212 | -- @tparam table route A list of values to recursively index the tree with. 213 | -- @return The value at the described node in the tree. 214 | -- @see tree.insert 215 | -- @usage 216 | -- local tree = { foo = { bar = { __value = "Some value" }, __value = "Unused value" } } 217 | -- restia.utils.tree.get(tree, {"foo", "bar"}) 218 | -- @function tree.get 219 | function utils.tree.get(head, route) 220 | for i, key in ipairs(route) do 221 | head = head[key] 222 | if not head then 223 | return nil 224 | end 225 | end 226 | return head.__value 227 | end 228 | 229 | local function files(dir, func) 230 | for path in lfs.dir(dir) do 231 | if path:sub(1, 1) ~= '.' then 232 | local name = dir .. '/' .. path 233 | local mode = lfs.attributes(name, 'mode') 234 | if mode == 'directory' then 235 | files(name, func) 236 | elseif mode == 'file' then 237 | func(name) 238 | end 239 | end 240 | end 241 | end 242 | 243 | local function random(n) 244 | if n > 0 then 245 | return math.random(256)-1, random(n-1) 246 | end 247 | end 248 | 249 | --- Recursively concatenates a table 250 | function utils.deepconcat(tab, separator) 251 | for key, value in ipairs(tab) do 252 | if type(value)=="table" then 253 | tab[key]=utils.deepconcat(value, separator) 254 | else 255 | tab[key]=tostring(value) 256 | end 257 | end 258 | return table.concat(tab, separator) 259 | end 260 | 261 | --- Returns a list containing the result of `debug.getinfo` for every level in 262 | -- the current call stack. The table also contains its length at index `n`. 263 | function utils.stack(level) 264 | local stack = {} 265 | for i=level+1, math.huge do 266 | local info = debug.getinfo(i) 267 | if info then 268 | table.insert(stack, info) 269 | else 270 | stack.n = i 271 | break 272 | end 273 | end 274 | return stack 275 | end 276 | 277 | --- Returns a random hexadecimal string with N bytes 278 | function utils.randomhex(n) 279 | return string.format(string.rep("%03x", n), random(n)) 280 | end 281 | 282 | --- Returns an iterator over all the files in a directory and subdirectories 283 | -- @tparam string dir The directory to look in 284 | -- @tparam[opt] string filter A string to match filenames against for filtering 285 | -- @treturn function Iterator over the file names 286 | -- @usage 287 | -- for file in utils.files 'views' do 288 | -- print('found view: ', file) 289 | -- end 290 | -- 291 | -- for image in utils.files(".", "%.png$") do 292 | -- print('found image: ', image) 293 | -- end 294 | function utils.files(dir, filter) 295 | if type(filter)=="string" then 296 | return coroutine.wrap(files), dir, function(name) 297 | if name:find(filter) then 298 | coroutine.yield(name) 299 | end 300 | end 301 | else 302 | return coroutine.wrap(files), dir, coroutine.yield 303 | end 304 | end 305 | 306 | --- Deletes a file or directory recursively 307 | -- @tparam string path The path to the file or directory to delete 308 | function utils.delete(path) 309 | path = path:gsub('/+$', '') 310 | local mode = lfs.attributes(path, 'mode') 311 | if mode=='directory' then 312 | for entry in lfs.dir(path) do 313 | if not entry:match("^%.%.?$") then 314 | utils.delete(path..'/'..entry) 315 | end 316 | end 317 | end 318 | os.remove(path) 319 | end 320 | 321 | --- Copies a directory recursively 322 | function utils.copy(from, to) 323 | local mode = lfs.attributes(from, 'mode') 324 | if mode == 'directory' then 325 | lfs.mkdir(to) 326 | for path in lfs.dir(from) do 327 | if path:sub(1, 1) ~= '.' then 328 | utils.copy(from.."/"..path, to.."/"..path) 329 | end 330 | end 331 | elseif mode == 'file' then 332 | local of, err = io.open(to, 'wb') 333 | if not of then 334 | error(err) 335 | end 336 | of:write(io.open(from, 'rb'):read('a')) 337 | of:close() 338 | end 339 | end 340 | 341 | function utils.mkdir(path) 342 | local slash = 0 343 | while slash do 344 | slash = path:find("/", slash+1) 345 | lfs.mkdir(path:sub(1, slash)) 346 | end 347 | end 348 | 349 | --- Writes an arbitrarily nested sequence of strings to a file 350 | -- @tparam table buffer A sequence containing strings or nested sequences 351 | -- @tparam file file A file to write to 352 | -- @treturn number The number of bytes that were written 353 | function utils.writebuffer(buffer, file) 354 | local bytes = 0 355 | local close = false 356 | if type(file) == "string" then 357 | file = io.open(file, "wb") 358 | close = true 359 | end 360 | if type(buffer) == "string" then 361 | file:write(buffer) 362 | return #buffer 363 | else 364 | for i, chunk in ipairs(buffer) do 365 | bytes = bytes + utils.writebuffer(chunk, file) 366 | end 367 | end 368 | if close then 369 | file:close() 370 | end 371 | return bytes 372 | end 373 | 374 | function utils.frontmatter(text) 375 | local a, b = text:find('^%-%-%-+\n') 376 | local c, d = text:find('\n%-%-%-+\n', b) 377 | if b and c then 378 | return text:sub(b+1, c-1), text:sub(d+1, -1) 379 | else 380 | return nil, text 381 | end 382 | end 383 | 384 | return utils 385 | -------------------------------------------------------------------------------- /spec/accessors_spec.moon: -------------------------------------------------------------------------------- 1 | accessors = require 'restia.accessors' 2 | 3 | describe "The accessor generator", -> 4 | it "should return three tables", -> 5 | get, set, tab = accessors.new! 6 | assert.is.table get 7 | assert.is.table set 8 | assert.is.table tab 9 | 10 | -- TODO: Split this up nicely 11 | it "should work", -> 12 | get, set, tab = accessors.new { false } 13 | get.foo = stub.new! 14 | set.foo = stub.new! 15 | dummy = tab.foo 16 | tab.foo = 20 17 | assert.stub(get.foo).was_called.with(tab) 18 | assert.stub(set.foo).was_called.with(tab, 20) 19 | 20 | it "Should use a provided table", -> 21 | object = {} 22 | get, set, tab = accessors.new object 23 | assert.equal object, tab 24 | -------------------------------------------------------------------------------- /spec/busted_spec.moon: -------------------------------------------------------------------------------- 1 | describe 'package.path', -> 2 | it 'should be set correctly', -> 3 | path = './?.lua;./?/init.lua' 4 | assert.equal path, package.path\sub(1, #path) 5 | -------------------------------------------------------------------------------- /spec/config_spec.moon: -------------------------------------------------------------------------------- 1 | -- vim: set noexpandtab :miv -- 2 | 3 | config = require 'restia.config' 4 | 5 | pending 'config', -> 6 | -------------------------------------------------------------------------------- /spec/contributors_spec.moon: -------------------------------------------------------------------------------- 1 | contributors = require 'contributors' 2 | 3 | for contrib in *contributors do 4 | contributors[contrib.email] = contrib 5 | 6 | process = io.popen("git shortlog --summary --email") 7 | 8 | emails = [ line\match("%b<>")\sub(2,-2) for line in process\lines() ] 9 | 10 | for email in *emails do 11 | contributor = contributors[email] 12 | it "#{contributor.name} <#{email}> should agree to the license", -> 13 | assert.true contributor.license 14 | -------------------------------------------------------------------------------- /spec/ctemplate.moonhtml.lua: -------------------------------------------------------------------------------- 1 | return html5() 2 | -------------------------------------------------------------------------------- /spec/indent_spec.moon: -------------------------------------------------------------------------------- 1 | tabbed = (path) -> 2 | file = assert io.open path 3 | for line in file\lines! do 4 | if line\match('^%s*')\find(" ") 5 | return false 6 | true 7 | 8 | files = {} 9 | for file in assert(io.popen("git ls-files '*.lua' '*.moon'"))\lines! 10 | table.insert(files, file) 11 | 12 | describe 'Source file', -> 13 | for file in *files 14 | describe file, -> 15 | it "Should not use spaces for indentation", -> 16 | assert.truthy tabbed(file) 17 | -------------------------------------------------------------------------------- /spec/markdown_spec.moon: -------------------------------------------------------------------------------- 1 | restia = require 'restia' 2 | 3 | describe 'markdown module', -> 4 | setup -> 5 | _G.ngx = { print: stub.new! } 6 | -- Normal globals doesn't work, since busted sanboxes those and restia already has a reference to the real global env 7 | it 'should parse strings', -> 8 | assert.is.truthy restia.markdown.parse('## foo')\match '

foo

' 9 | it 'should load files', -> 10 | assert.is.string restia.markdown.load 'spec/template' 11 | -------------------------------------------------------------------------------- /spec/negotiator_spec.lua: -------------------------------------------------------------------------------- 1 | local negotiator = require 'restia.negotiator' 2 | 3 | describe("Content negotiator", function() 4 | it("Should parse headers correctly", function() 5 | local accepted = negotiator.parse 'text/html' [1] 6 | assert.same({q=1, s=3, type='text/html'}, accepted) 7 | end) 8 | 9 | it("Should order types alphabetically", function() 10 | -- To make the ordering of headers with equal Q-value more deterministic 11 | local accepted = negotiator.parse 'c/*, b/*, a/*' 12 | for k,v in ipairs(accepted) do 13 | accepted[k] = v.type 14 | end 15 | assert.same({'a/*', 'b/*', 'c/*'}, accepted) 16 | end) 17 | 18 | it("Should respect Q-values", function() 19 | local accepted = negotiator.parse 'text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8' 20 | for k,v in ipairs(accepted) do 21 | accepted[k] = v.type 22 | end 23 | assert.same({ 24 | 'application/xhtml+xml', 25 | 'image/webp', 26 | 'text/html', 27 | 'application/xml', 28 | '*/*' 29 | }, accepted) 30 | end) 31 | 32 | it("Should return valid patterns", function() 33 | local patterns = negotiator.patterns '*/*, application/*, text/html, hack/%s+$' 34 | for k,v in ipairs(patterns) do 35 | patterns[k] = v.pattern 36 | end 37 | assert.same({ 38 | '^hack/%%s%+%$$', 39 | '^text/html$', 40 | '^application/.+', 41 | '.+/.+' 42 | }, patterns) 43 | end) 44 | 45 | it("Should pick the prefered option", function() 46 | assert.same({"text/html", "FOO"}, {negotiator.pick('text/*', { 47 | ['application/js'] = "BAR"; 48 | ['text/html'] = "FOO"; 49 | ['image/png'] = "BAZ"; 50 | })}) 51 | end) 52 | end) 53 | -------------------------------------------------------------------------------- /spec/template.md: -------------------------------------------------------------------------------- 1 | Hello World 2 | ================================================================================ 3 | -------------------------------------------------------------------------------- /spec/template.moonhtml: -------------------------------------------------------------------------------- 1 | html5! 2 | -------------------------------------------------------------------------------- /spec/template_spec.moon: -------------------------------------------------------------------------------- 1 | restia = require 'restia' 2 | 3 | readfile = => 4 | file = io.open(@) 5 | content = file\read "*a" 6 | file\close! 7 | content 8 | 9 | describe 'Restia', -> 10 | describe 'uncompiled templates', -> 11 | it 'should not error for simple cases', -> 12 | assert.has_no_errors -> restia.template.loadmoon(readfile('spec/template.moonhtml'), 'foo')() 13 | it 'Should return a table', -> 14 | assert.is.table restia.template.loadmoon(readfile('spec/template.moonhtml'), '')() 15 | 16 | describe 'compiled templates', -> 17 | it 'should not error for simple cases', -> 18 | assert.has_no_errors -> restia.template.loadlua(readfile('spec/ctemplate.moonhtml.lua'), 'foo')() 19 | it 'Should return a table', -> 20 | assert.is.table restia.template.loadlua(readfile('spec/ctemplate.moonhtml.lua'), '')() 21 | -------------------------------------------------------------------------------- /spec/utils_spec.moon: -------------------------------------------------------------------------------- 1 | -- vim: set filetype=moon :miv -- 2 | 3 | restia = require 'restia' 4 | 5 | describe "utils.deepinsert", -> 6 | it "should sound wrong", -> 7 | assert.truthy "It sure does" 8 | 9 | it "should work for non-recursive string cases", -> 10 | tab = {} 11 | restia.utils.deepinsert(tab, "foo", "bar") 12 | assert.equal "bar", tab.foo 13 | restia.utils.deepinsert(tab, "[1]", "bar") 14 | assert.equal "bar", tab[1] 15 | 16 | it "should work for recursive string cases", -> 17 | tab = {} 18 | assert restia.utils.deepinsert(tab, "foo.bar.baz", "hello") 19 | assert.equal "hello", tab.foo.bar.baz 20 | assert restia.utils.deepinsert(tab, "[1][2][3]", "world") 21 | assert.equal "world", tab[1][2][3] 22 | 23 | it "should work for table paths", -> 24 | tab = {} 25 | assert restia.utils.deepinsert(tab, {"foo", "bar", "baz"}, "hello") 26 | assert.equal "hello", tab.foo.bar.baz 27 | 28 | it "should error for incorrect path types", -> 29 | assert.nil restia.utils.deepinsert({}, (->), true) 30 | assert.nil restia.utils.deepinsert({}, (20), true) 31 | 32 | describe "utils.deepindex", -> 33 | it "should work for non-recursive cases", -> 34 | assert.equal "yes", assert restia.utils.deepindex({foo: "yes"}, "foo") 35 | assert.equal "first", assert restia.utils.deepindex({"first"}, "[1]") 36 | 37 | it "should work for recursive cases", -> 38 | assert.equal "yes", assert restia.utils.deepindex({foo: {bar: {baz: "yes"}}}, "foo.bar.baz") 39 | assert.equal "third", assert restia.utils.deepindex({{{"third"}}}, "[1][1][1]") 40 | 41 | describe "utils.deepen", -> 42 | it "should flat-clone a normal table", -> 43 | tab = { 44 | "first", 45 | "second", 46 | [(a)->a]: 'function', 47 | string: 'string' 48 | } 49 | assert.same(tab, restia.utils.deepen(tab)) 50 | 51 | it "should spread string keys with dots", -> 52 | assert.same({foo: {bar: 'baz'}}, restia.utils.deepen{['foo.bar']: 'baz'}) 53 | 54 | it "should treat numbers as strings when using dots", -> 55 | assert.same({foo: {['1']: 'baz'}}, restia.utils.deepen{['foo.1']: 'baz'}) 56 | 57 | it "should convert numeric indices", -> 58 | assert.same({foo: {'baz'}}, restia.utils.deepen{['foo[1]']: 'baz'}) 59 | 60 | describe "utils.escape", -> 61 | it "should do nothing to normal strings", -> 62 | assert.equal "hello", restia.utils.escape("hello") 63 | it "should escape ampersands", -> 64 | assert.equal "&hello", restia.utils.escape("&hello") 65 | it "should escape angle brackets", -> 66 | assert.equal "<hello>", restia.utils.escape("") 67 | it "should escape quotation marks", -> 68 | assert.equal ""G'day"", restia.utils.escape([["G'day"]]) 69 | 70 | describe "utils.tree", -> 71 | describe "insert", -> 72 | it "should insert a node into a tree", -> 73 | assert.same { foo: { bar: { __value: "test" } } }, restia.utils.tree.insert({}, {"foo", "bar"}, "test") 74 | describe "get", -> 75 | it "should retreive a key from a tree", -> 76 | assert.equal "test", restia.utils.tree.get({ foo: { bar: { __value: "test" } } }, { "foo", "bar" }) 77 | 78 | describe "utils.mixin", -> 79 | it "should return the first table", -> 80 | first = {} 81 | assert.equal first, restia.utils.mixin first, { foo: "foo" } 82 | it "should mix in flat values", -> 83 | assert.same { foo: "foo", bar: "bar" }, restia.utils.mixin { foo: "foo" }, { bar: "bar" } 84 | it "should mix in recursively", -> 85 | assert.same { foo: { bar: "bar", baz: "baz" } }, 86 | restia.utils.mixin { foo: { bar: "bar" } }, { foo: { baz: "baz" } } 87 | it "should mix tables over non-tables", -> 88 | assert.same { foo: { "bar" } }, 89 | restia.utils.mixin { foo: 20 }, { foo: { "bar" } } 90 | --------------------------------------------------------------------------------