local RssChannel = {} RssChannel.new = function(title, link, description, settings) local settings = settings or {} local channel = { title=title, link=link, description=description, } channel.language = settings.language channel.copyright = settings.copyright channel.managingEditor = settings.managingEditor channel.webMaster = settings.webMaster channel.lastBuildDate = settings.lastBuildDate channel.category = settings.category channel.generator = 'argent v0.1.0' channel.docs = 'https://www.rssboard.org/rss-specification' channel.items = {} setmetatable( channel, { __index=RssChannel, __tostring=RssChannel.tostring, } ) return channel end RssChannel.addItem = function(self, tbl) local to_rfc822 = function(datestring, zone) if not string.match(datestring, '^%d%d%d%d%-%d%d%-%d%d$') then error(string.format('%q is not a valid date string', datestring)) end local year = string.match(datestring, '^%d%d%d%d') local month = string.match(datestring, '-(%d%d)-') local day = string.match(datestring, '%d%d$') local month_names = {'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'} month = month_names[tonumber(month)] return string.format('%s %s %s 00:00:00 %s', day, month, year, zone) end if not (tbl.title or tbl.description) then error('items must specify either a title or description!') end if not tbl.pubDate then error('items must specify a pubDate!') else tbl.pubDate = to_rfc822(tbl.pubDate, 'Z') end table.insert(self.items, tbl) end local tag_builder = function(source_table, indent_level) local str = '' local indent = string.rep(' ', indent_level) local add_tag = function(tag) if source_table[tag] then str = str .. string.format('%s<%s>%s\n', indent, tag, source_table[tag], tag) end end local content = function() return str end return add_tag, content end local render_item = function(item_tbl) local add_tag, content = tag_builder(item_tbl, 2) add_tag('title') add_tag('link') add_tag('description') add_tag('author') add_tag('category') add_tag('comments') add_tag('guid') add_tag('pubDate') local indent = string.rep(' ', 1) return string.format('%s\n%s%s', indent, content(), indent) end RssChannel.tostring = function(self) local add_tag, content = tag_builder(self, 1) add_tag('title') add_tag('link') add_tag('description') add_tag('language') add_tag('copyright') add_tag('managingEditor') add_tag('webMaster') add_tag('pubDate') add_tag('lastBuildDate') add_tag('category') add_tag('generator') add_tag('docs') items_str = '' for _, item in ipairs(self.items) do items_str = items_str .. render_item(item) .. '\n' end return string.format( '\n\n%s\n%s\n', content(), items_str) end RssChannel.save = function(self, filename) file = io.open(filename, 'w') file:write(self:tostring()) file:close() end return function(config) local fmt = string.format -------------------------------- -- -- basics -- -------------------------------- function Set(array, transform) if type(array) ~= 'table' then return nil end local transform = transform or function(x) return x end local set = {} for _, element in ipairs(array) do set[transform(element)] = true end return set end function set_tostring(set) local str = '[empty]' if not set then return str end for element in pairs(set) do if str == '[empty]' then str = tostring(element) else str = str .. '; '.. tostring(element) end end return str end function add_end_slash(str) if type(str) ~= 'string' then return nil end if string.match(str, '/$') then return str end return str .. '/' end function strip_end_slash(str) if string.match(str, '/$') then return string.sub(str, 1, #str-1) end return str end function load_layouts(directory, parent, layouts) local directory = add_end_slash(directory) or '' local parent = parent or '' local layouts = layouts or {} local fullpath = parent..directory local dirs, files = argent.scanDirectory(argent.config.layout_directory..fullpath) for _, file in ipairs(files) do if string.match(file, '%.lua$') then local basename = string.gsub(file, '%.lua$', '') local id = string.gsub(fullpath..basename, '/', '.') argent.log('debug', fmt('loading layout %q', id)) success, result = pcall(loadfile(argent.config.layout_directory..fullpath..file), 0, 1) if not success then argent.log('error', result) argent.log('warn', fmt('layout %q will not be available', id)) else layouts[id] = result end end end for _, dir in ipairs(dirs) do if dir ~= '.' and dir ~= '..' then argent.log('debug', fmt('loading layouts from %q', parent..directory)) load_layouts(dir, parent..directory, layouts) end end return layouts end function setup(config) argent.log('debug', 'begin setup') if not config.site_name then error('site_name MUST be set!') end argent.config = { site_name = config.site_name, site_address = add_end_slash(config.site_address) or nil, site_description = config.site_description or '', site_language = config.site_language, site_copyright = config.site_copyright, site_directory = add_end_slash(config.site_directory) or 'site/', output_directory = add_end_slash(config.output_directory) or 'public/', layout_directory = add_end_slash(config.layout_directory) or nil, plugin_directory = add_end_slash(config.plugin_directory) or nil, exclude = Set(config.exclude, strip_end_slash) or {}, include = Set(config.include, strip_end_slash) or {}, keep = Set(config.keep, strip_end_slash) or {}, noprocess = Set(config.noprocess, strip_end_slash) or {}, rss_include = Set(config.rss_include, strip_end_slash) or nil, webmaster_email = config.webmaster_email, } argent.log('info', fmt('site directory: %s', argent.config.site_directory)) argent.log('info', fmt('output directory: %s', argent.config.output_directory)) argent.log('info', fmt('layout directory: %s', tostring(argent.config.layout_directory))) argent.log('info', fmt('plugin directory: %s', tostring(argent.config.plugin_directory))) argent.log('info', 'exclusions: '..set_tostring(argent.config.exclude)) argent.log('info', 'inclusions: '..set_tostring(argent.config.include)) argent.log('info', 'files to keep: '..set_tostring(argent.config.keep)) argent.log('info', 'noprocess: '..set_tostring(argent.config.noprocess)) argent.log('info', 'rss files: '..set_tostring(argent.config.rss_include)) if argent.config.layout_directory then argent.layouts = load_layouts() else argent.layouts = {} end argent.log('info', fmt('available layouts: %s', set_tostring(argent.layouts))) if argent.config.plugin_directory then package.path = add_end_slash(argent.currentWorkingDirectory()) ..argent.config.plugin_directory..'?.lua;'..package.path end if argent.config.rss_include then if not argent.config.site_address then error('rss_include is set, but site_address is not!') end argent.rss_channel = RssChannel.new( argent.config.site_name, argent.config.site_address, argent.config.site_description, { language = argent.config.site_language, copyright = argent.config.site_copyright, webMaster = argent.config.webmaster_email, } ) end argent.log('debug', 'end setup') end function is_dotfile(filename) if string.match(filename, '^%.') then return true end return false end function directory_exists(directory, parent) local directory = directory or '' local parent = parent or '' local dirs = argent.scanDirectory(argent.config.output_directory..parent) for _, dir in ipairs(dirs) do if strip_end_slash(directory) == dir then return true end end return false end function output_directory_exists() local dirs = argent.scanDirectory('./') for _, dir in ipairs(dirs) do if add_end_slash(dir) == argent.config.output_directory then return true end end return false end -------------------------------- -- -- obliterating -- -------------------------------- function obliterate_file(file, parent) if argent.config.keep[file] then argent.log('debug', fmt('retaining file %q', parent..file)) return true end argent.log('debug', fmt('removing file %q', parent..file)) os.remove(argent.config.output_directory..parent..file) return false end function obliterate_dir(dir, parent) if argent.config.keep[strip_end_slash(dir)] then argent.log('debug', fmt('retaining directory %q', parent..dir)) return true end argent.log('debug', fmt('obliterating files in %q', parent..dir)) return obliterate(dir, parent) end function obliterate(dirname, parent) local dirname = add_end_slash(dirname) or '' local parent = parent or '' if argent.config.keep[dirname] then return true end local keep_self = (dirname == '') local dirs, files = argent.scanDirectory(argent.config.output_directory..parent..dirname) for _, file in ipairs(files) do keep_self = obliterate_file(file, parent..dirname) or keep_self end for _, dir in ipairs(dirs) do if dir ~= '.' and dir ~= '..' then keep_self = obliterate_dir(dir, parent..dirname) or keep_self end end if not keep_self then os.remove(argent.config.output_directory..parent..dirname) return false else return true end end -------------------------------- -- -- processing -- -------------------------------- function should_include(filename, parent) for pattern in pairs(argent.config.include) do if string.match(filename, pattern) or string.match(parent..filename, pattern) then return true end end return false end function should_ignore(filename, parent) if is_dotfile(filename) and not should_include(filename, parent) then return false end for pattern in pairs(argent.config.exclude) do if string.match(filename, pattern) or string.match(parent..filename, pattern) then return true end end return false end function should_process(filename, parent) if not string.match(filename, '%.lua$') then return false end if should_ignore(filename, parent) then return false end for pattern in pairs(argent.config.noprocess) do if string.match(filename, pattern) or string.match(parent..filename, pattern) then return false end end return true end function process_file(filename, parent) if should_ignore(filename, parent) then argent.log('debug', fmt('will not process file %q', parent..filename)) return end if should_process(filename, parent) then argent.log('debug', fmt('processing %q as lua file', parent..filename)) process_lua_file(filename, parent) else argent.log('debug', fmt('processing %q as regular file', parent..filename)) argent.copyFile( argent.config.site_directory..parent..filename, argent.config.output_directory..parent..filename ) end end function process_lua_file(filename, parent) local success, result = pcall(loadfile(argent.config.site_directory..parent..filename)) if not success then argent.log('error', result) argent.log('warn', fmt('%q will not be processed for output!', parent..filename)) return end if not type(result) == 'table' then argent.log('error', fmt('%q returned %q instead of %q', filename, type(result), 'table')) argent.log('warn', fmt('%q will not result in file output!', filename)) return end local html if result.html then html = result.html elseif result.markdown then html = argent.markdown(result.markdown) else argent.log('error', fmt('%q did not specify any content!', filename)) argent.log('warn', fmt('%q will not result in file output!', filename)) return end if not result.title then result.title = string.gsub(filename, '%.lua$', '') argent.log('warn', fmt('%q did not specify a title; using default title of %q', filename, result.title)) end local layout = function(html, tbl) return html end if result.layout then if not argent.layouts[result.layout] then argent.log('error', fmt('%q requested nonexistent layout %q', filename, result.layout)) argent.log('warn', fmt('%q will not result in file output!', filename)) return else layout = argent.layouts[result.layout] end else -- no layout specified argent.log('warn', fmt( '%q did not specify a layout; output will be naked content!', filename)) end local output_data = layout(html, result) local output_name = string.gsub(filename, '%.lua$', '.html') local output_file = io.open(argent.config.output_directory..parent..output_name, 'w') output_file:write(output_data) output_file:close() if argent.rss_channel then for pattern in pairs(argent.config.rss_include) do if string.match(filename, pattern) or string.match(parent..filename, pattern) then -- add to the RSS feed! if not result.date then argent.log( 'warn', fmt( '%q did not specify a date; it will not be included in rss.xml!', parent..filename ) ) return end argent.rss_channel:addItem{ title=result.title, link=argent.config.site_address..parent..output_name, description = result.description, pubDate = result.date, } end end end end function process_dir(directory, parent) if should_ignore(directory, parent) then argent.log('debug', fmt('will not process directory %q', parent..directory)) return end if not directory_exists(directory, parent) then argent.createDirectory(argent.config.output_directory..parent..directory) end process(directory, parent) end function process(directory, parent) local directory = add_end_slash(directory) or '' local parent = add_end_slash(parent) or '' local dirs, files = argent.scanDirectory(argent.config.site_directory..parent..directory) for _, file in ipairs(files) do process_file(file, parent..directory) end for _, dir in ipairs(dirs) do if dir ~= '.' and dir ~= '..' then process_dir(dir, parent..directory) end end end -------------------------------- -- -- putting it all together -- -------------------------------- setup(config) if not output_directory_exists() then argent.createDirectory(argent.config.output_directory) else obliterate() end process() if argent.rss_channel then argent.rss_channel:save(argent.config.output_directory..'rss.xml') end end