refactoring website docgen
M CMakeLists.txt +20 -9
@@ 453,20 453,31 @@ message(STATUS "Core Library ${CMAKE_SOU
 
 configure_file(corelib.h.in "${CMAKE_CURRENT_BINARY_DIR}/headers/config/corelib.h")
 
+function(KDCC_SUBCOMMAND subcmd)
+	set(subtarget "kdcc_${subcmd}")
+	string(TOUPPER ${subtarget} subdef)
+
+	add_executable(${subtarget} src/website/kdcc.cpp ${ARGN})
+
+#	target_link_libraries(${subtarget} package_manager grammar_kronos)
+
+	set_target_properties( ${subtarget} PROPERTIES FOLDER website COMPILE_DEFINITIONS "${subdef}")
+#	target_link_libraries( ${subtarget} ksubrepl )
+endfunction()
+
 if(NOT EMSCRIPTEN)
 	if (KRONOS_WEBSITE)
 		message(STATUS "Building Website from ${KRONOS_WEBSITE}")
-		add_executable(kdcc 
-			src/website/kdcc.cpp
-			src/website/makehtml.cpp
-			src/website/md2json.cpp 
-			src/website/mod2json.cpp
-			src/website/subrepl.cpp)
+		kdcc_subcommand(module src/website/mod2json.cpp)
+		kdcc_subcommand(page src/website/md2json.cpp src/website/subrepl.cpp src/website/attachment.cpp)
+		kdcc_subcommand(link src/website/makehtml.cpp src/website/attachment.cpp)
+		kdcc_subcommand(upload src/website/makehtml.cpp src/website/attachment.cpp)
 
-		target_link_libraries(kdcc package_manager grammar_kronos)
+		target_link_libraries(kdcc_module package_manager grammar_kronos)
+		target_link_libraries(kdcc_page grammar_kronos ksubrepl)
+		target_link_libraries(kdcc_link package_manager)
+		target_link_libraries(kdcc_upload package_manager)
 
-		set_target_properties( kdcc PROPERTIES FOLDER apps)
-		target_link_libraries( kdcc ksubrepl )
 		add_subdirectory(${KRONOS_WEBSITE} web)
 	endif()
 	# check for o2

          
M src/backends/BinaryenEmitter.h +4 -0
@@ 226,7 226,11 @@ namespace K3 {
 
 			BinaryenExpressionRef UndefConst(BinaryenType ty) {
 				BinaryenLiteral lt;
+#ifdef NDEBUG
+				memset(&lt, 0, sizeof(lt));
+#else 
 				memset(&lt, 0xcd, sizeof(lt));
+#endif
 				lt.type = ty;
 				return BinaryenConst(M, lt);
 			}

          
M src/backends/CodeGenCompiler.cpp +6 -2
@@ 97,9 97,13 @@ namespace K3 {
 					} else if (node->GetReactivity()) {
 						auto tmp(Qxx::FromGraph(node->GetReactivity())
 								 .OfType<Reactive::DriverNode>()
-								 .Select([](const Reactive::DriverNode* dn) {return dn->GetID(); }).ToVector());
+								 .Select([](const Reactive::DriverNode* dn) 
+										 { return dn->GetID(); })
+								 .ToVector());
 						return CollectionToMask(Qxx::From(tmp), allDrivers);
-					} else return Ref<ActivityMaskVector>::Cons(); // no reactivity data: statically active
+					} else {
+						return Ref<ActivityMaskVector>::Cons(); // no reactivity data: statically active
+					}
 				}
 			}
 			KRONOS_UNREACHABLE;

          
A => src/website/attachment.cpp +117 -0
@@ 0,0 1,117 @@ 
+#include <fstream>
+#include "attachment.h"
+
+static const char* B64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+static const int B64index[256] =
+{
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,
+	0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  62, 63, 62, 62, 63,
+	52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 0,  0,  0,  0,  0,  0,
+	0,  0,  1,  2,  3,  4,  5,  6,  7,  8,  9,  10, 11, 12, 13, 14,
+	15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 0,  0,  0,  0,  63,
+	0,  26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
+	41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51
+};
+
+const std::vector<unsigned char> b64decode(const void* data, const size_t& len) {
+	if (len == 0) return {};
+
+	unsigned char* p = (unsigned char*)data;
+	size_t j = 0,
+		pad1 = len % 4 || p[len - 1] == '=',
+		pad2 = pad1 && (len % 4 > 2 || p[len - 2] != '=');
+	const size_t last = (len - pad1) / 4 << 2;
+	std::vector<unsigned char> result(last / 4 * 3 + pad1 + pad2, '\0');
+	unsigned char* str = (unsigned char*)result.data();
+
+	for (size_t i = 0; i < last; i += 4) {
+		int n = B64index[p[i]] << 18 | B64index[p[i + 1]] << 12 | B64index[p[i + 2]] << 6 | B64index[p[i + 3]];
+		str[j++] = n >> 16;
+		str[j++] = n >> 8 & 0xFF;
+		str[j++] = n & 0xFF;
+	}
+	if (pad1) {
+		int n = B64index[p[last]] << 18 | B64index[p[last + 1]] << 12;
+		str[j++] = n >> 16;
+		if (pad2) {
+			n |= B64index[p[last + 2]] << 6;
+			str[j++] = n >> 8 & 0xFF;
+		}
+	}
+	return result;
+}
+
+std::vector<unsigned char> b64decode(const std::string& str64) {
+	return b64decode(str64.c_str(), str64.size());
+}
+
+const std::string b64encode(const void* data, const size_t& len) {
+	std::string str((len + 2) / 3 * 4, '=');
+	unsigned char* p = (unsigned char*)data;
+	size_t j = 0, pad = len % 3;
+	const size_t last = len - pad;
+
+	for (size_t i = 0; i < last; i += 3) {
+		int n = int(p[i]) << 16 | int(p[i + 1]) << 8 | p[i + 2];
+		str[j++] = B64chars[n >> 18];
+		str[j++] = B64chars[n >> 12 & 0x3F];
+		str[j++] = B64chars[n >> 6 & 0x3F];
+		str[j++] = B64chars[n & 0x3F];
+	}
+	if (pad)  /// set padding
+	{
+		int n = --pad ? int(p[last]) << 8 | p[last + 1] : p[last];
+		str[j++] = B64chars[pad ? n >> 10 & 0x3F : n >> 2];
+		str[j++] = B64chars[pad ? n >> 4 & 0x03F : n << 4 & 0x3F];
+		str[j++] = pad ? B64chars[n << 2 & 0x3F] : '=';
+	}
+#ifndef NDEBUG
+	auto test = b64decode(str);
+	assert(test.size() == len);
+	for (int i = 0; i < len; ++i) {
+		assert(test[i] == p[i]);
+	}
+#endif
+	return str;
+}
+
+std::string b64encode(const std::string& str) {
+	return b64encode(str.c_str(), str.size());
+}
+
+std::string attach(picojson::value& link, const std::string& path, const std::string& ext, const std::string& mime) {
+	std::ifstream file{ path, std::ios_base::binary | std::ios_base::ate };
+	if (file.is_open() == false) throw std::runtime_error("Could not load '" + path + "'");
+	std::vector<char> data((size_t)file.tellg());
+	file.seekg(0, file.beg);
+	file.read(data.data(), data.size());
+
+	auto b64 = b64encode(data.data(), data.size());
+	auto uid = Packages::fnv1a(b64.data(), b64.size()).to_str() + ext;
+
+	if (mime.find("VIP") != mime.npos) {
+		uid = "vip-" + uid;
+	}
+
+	if (!link.contains("~attachments")) {
+		link.get<picojson::object>()["~attachments"] = picojson::object{};
+	}
+	picojson::object& attachments{
+		link.get<picojson::object>()["~attachments"].get<picojson::object>()
+	};
+
+	auto fileName = path;
+	if (auto folderPos = fileName.find_last_of("/\\")) {
+		fileName = fileName.substr(folderPos + 1);
+	}
+
+	attachments[uid] = picojson::object{
+		{ "data", b64 },
+		{ "mime", mime },
+		{ "name", fileName }
+	};
+
+	return uid;
+}

          
A => src/website/attachment.h +10 -0
@@ 0,0 1,10 @@ 
+#pragma once
+
+#include "driver/picojson.h"
+#include "driver/package.h"
+
+std::string attach(picojson::value& link, const std::string& path, const std::string& ext, const std::string& mime);
+const std::vector<unsigned char> b64decode(const void* data, const size_t& len);
+std::vector<unsigned char> b64decode(const std::string& str64);
+const std::string b64encode(const void* data, const size_t& len);
+std::string b64encode(const std::string& str);
  No newline at end of file

          
M src/website/kdcc.cpp +43 -15
@@ 1,31 1,59 @@ 
 #include <string.h>
+#include <sys/stat.h>
 
 int compile_module(const char*, const char*);
 int compile_page(const char*, const char*);
 int link_page(const char**, int, const char* db = nullptr, const char *auth = nullptr);
 
+struct stat exeStat;
+
+#if !(defined(KDCC_MODULE) || defined(KDCC_PAGE) || defined(KDCC_LINK) || defined(KDCC_UPLOAD))
+#define SUBCOMMAND(CMD) strcmpi(argv[1], CMD) == 0
+#define KDCC_MODULE SUBCOMMAND("module")
+#define KDCC_PAGE SUBCOMMAND("build")
+#define KDCC_LINK SUBCOMMAND("link")
+#define KDCC_UPLOAD SUBCOMMAND("upload")
+#define FIRST_ARG 2
+#else 
+#define FIRST_ARG 1
+#endif
+
 int main(int argn, const char *argv[]) {
-	if (argn < 2) return -1;
-	if (!strcmp(argv[1], "module")) {
-		if (argn < 4) return -1;
-		return compile_module(argv[2], argv[3]);
+	stat(argv[0], &exeStat);
+	if (argn < FIRST_ARG) return -1;
+
+#ifdef KDCC_MODULE
+	for (;KDCC_MODULE;) {
+		if (argn < FIRST_ARG + 2) return -1;
+		return compile_module(argv[FIRST_ARG], argv[FIRST_ARG + 1]);
 	}
-	if (!strcmp(argv[1], "build")) {
-		if (argn < 3) {
+#endif
+
+#ifdef KDCC_PAGE
+	for (; KDCC_PAGE;) {
+		if (argn < FIRST_ARG + 1) {
 			return -1;
-		} if (argn < 4) {
-			return compile_page(argv[2], "-");
+		} if (argn < FIRST_ARG + 2) {
+			return compile_page(argv[FIRST_ARG], "-");
 		} else {
-			return compile_page(argv[2], argv[3]);
+			return compile_page(argv[FIRST_ARG], argv[FIRST_ARG + 1]);
 		}
 	}
-	if (!strcmp(argv[1], "link")) {
-		if (argn < 4) return -1;
-		return link_page(argv + 2, argn - 2);
+#endif
+
+#ifdef KDCC_LINK
+	for (; KDCC_LINK;) {
+		if (argn < FIRST_ARG + 1) return -1;
+		return link_page(argv + FIRST_ARG, argn - FIRST_ARG);
 	}
-	if (!strcmp(argv[1], "upload")) {
-		if (argn < 5) return -1;
-		return link_page(argv + 4, argn - 4, argv[2], argv[3]);
+#endif
+
+#ifdef KDCC_UPLOAD
+	for (; KDCC_UPLOAD;) {
+		if (argn < FIRST_ARG + 3) return -1;
+		return link_page(argv + FIRST_ARG + 2, argn - FIRST_ARG - 2, argv[FIRST_ARG], argv[FIRST_ARG + 1]);
 	}
+#endif
+
 	return -1;
 }

          
M src/website/makehtml.cpp +31 -67
@@ 6,8 6,12 @@ 
 #include <list>
 #include <sstream>
 #include <sys/stat.h>
+
 #include "driver/picojson.h"
 #include "driver/package.h"
+#include "attachment.h"
+
+std::string attach(picojson::value& link, const std::string& path, const std::string& ext, const std::string& mime);
 
 std::string canonicalize_name(std::string name) {
 	std::regex sorting_prefix{"^[0-9+]-"};

          
@@ 19,9 23,9 @@ picojson::value merge(const picojson::va
 picojson::object merge(picojson::object lhs, const picojson::object& rhs) {
 	for (auto& kv : rhs) {
 		auto f = lhs.find(kv.first);
-		if (f != lhs.end()) {
+		if (f != lhs.end() && f->second.evaluate_as_boolean()) {
 			f->second = merge(f->second, kv.second);
-		} else {
+		} else if (kv.second.evaluate_as_boolean()) {
 			lhs.emplace(kv);
 		}
 	}

          
@@ 39,6 43,9 @@ picojson::array merge(picojson::array lh
 }
 
 picojson::value merge(const picojson::value &lhs, const picojson::value& rhs) {
+	if (lhs.is<picojson::null>()) return rhs;
+	if (rhs.is<picojson::null>()) return lhs;
+
 	if (lhs.is<picojson::object>()) {
 		if (rhs.is<picojson::object>()) {
 			return merge(lhs.get<picojson::object>(), rhs.get<picojson::object>());

          
@@ 189,38 196,6 @@ std::list<std::string> uses = { ":" };
 
 void render_element(std::ostream& os, const picojson::value& data);
 
-template <typename ITER> extern void base64_encode(std::ostream& out, ITER begin, ITER end);
-
-static std::string attach(const std::string& path, const std::string& mime) {
-	std::ifstream file{ path, std::ios_base::binary };
-	if (file.is_open() == false) throw std::runtime_error("Could not load '" + path + "'");
-	file.seekg(0, file.end);
-	std::vector<char> data((size_t)file.tellg());
-	file.seekg(0, file.beg);
-	file.read(data.data(), data.size());
-
-	std::stringstream b64;
-	base64_encode(b64, data.begin(), data.end());
-
-	auto uid = Packages::fnv1a(data.data(), data.size()).to_str();
-
-	if (mime.find("VIP") != mime.npos) {
-		uid = "vip-" + uid;
-	}
-
-	if (!link.contains("~attachments")) {
-		link.get<picojson::object>()["~attachments"] = picojson::object{};
-	}
-	picojson::object & attachments{ link.get<picojson::object>()["~attachments"].get<picojson::object>() };
-
-	attachments[uid] = picojson::object{
-		{"data", b64.str()},
-		{"mime", mime}
-	};
-	return uid;
-}
-
-
 void render_link(std::ostream& os, std::string domid, std::string content, std::string url) {
 	os << "<a " << domid << " href='" << url << "'>" << htmlenc(content) << "</a>";
 }

          
@@ 231,6 206,10 @@ void render_link(std::ostream& os, std::
 	os << "</a>";
 }
 
+std::unordered_map<std::string, std::string> mimeTypes{
+	{ ".svg", "image/svg+xml" }
+};
+
 std::unordered_map<std::string, std::function<void(std::ostream& os, const picojson::array& element, std::string domid)>> special_tags = {
 	{
 		"siblink",

          
@@ 256,13 235,13 @@ std::unordered_map<std::string, std::fun
 				if (m == '/') m = '_';
 			}
 
-			auto uid = attach(path + "/" + url, mime);
+			auto uid = attach(link, path + "/" + url, "", mime);
 			os << "<div class='asset'><a download='" << url << "' " << domid << " href='/static/asset/" << uid << "'><img src='/static/mime_" << mime_underscore
 				<< ".png' alt='" << mime << "'>" << htmlenc(url) << "</a></div>";
 		}
 	},
 	{
-		"raw-html",
+		"html",
 		[](std::ostream& os, const picojson::array& element, std::string domid) {
 			for (int i = 1; i < element.size(); ++i) os << element[i].to_str();
 		}

          
@@ 300,7 279,13 @@ std::unordered_map<std::string, std::fun
 
 			if (url.find("//") == url.npos) {
 				auto ext = url.substr(url.find_last_of('.'));
-				auto uid = attach(path + "/" + url, "image/" + ext);
+
+				std::string mime;
+				
+				if (mimeTypes.count(ext)) mime = mimeTypes[ext];
+				else mime = "image/" + ext.substr(1);
+
+				auto uid = attach(link, path + "/" + url, ext, mime);
 
 				os << "<img class='asset' alt='" << element[1].to_str() << "' src='/static/asset/" << uid << "'>";
 			} else {

          
@@ 522,35 507,15 @@ void copy_attachments() {
 	};
 
 	if (link.contains("~attachments")) {
-		for (auto &a : link.get("~attachments").get<picojson::object>()) {
+ 		for (auto &a : link.get("~attachments").get<picojson::object>()) {
 			struct stat st;
 			if (stat(("assets/" + a.first).c_str(), &st)) {
 				Packages::MakeMultiLevelPath("assets/");
 				std::ofstream file{ "assets/" + a.first, std::ios_base::binary };
-				std::clog << " - " << a.first << "\n";
+				std::clog << " - " << a.second.get("name") << "(" << a.first << ")\n";
 				if (file.is_open() == false) throw std::runtime_error("Could not write 'assets/" + a.first + "'");
-				auto data = a.second.get("data").to_str();
-
-				size_t left = data.size();
-				const unsigned char* in = (const unsigned char*)data.data();
-				while (left > 4) {
-					file.put(pr2six[in[0]] << 2 | pr2six[in[1]] >> 4);
-					file.put(pr2six[in[1]] << 4 | pr2six[in[2]] >> 2);
-					file.put(pr2six[in[2]] << 6 | pr2six[in[3]]);
-					in += 4;
-					left -= 4;
-				}
-
-				/* Note: (nprbytes == 1) would be an error, so just ingore that case */
-				if (left > 1) {
-					file.put(pr2six[in[0]] << 2 | pr2six[in[1]] >> 4);
-				}
-				if (left > 2) {
-					file.put(pr2six[in[1]] << 4 | pr2six[in[2]] >> 2);
-				}
-				if (left > 3) {
-					file.put(pr2six[in[2]] << 6 | pr2six[in[3]]);
-				}
+				auto decode = b64decode(a.second.get("data").to_str());
+				file.write((const char*)decode.data(), decode.size());
 			} else {
 				std::clog << " - " << a.first << " exists\n";
 			}

          
@@ 680,10 645,11 @@ void render_to_db(const char* url, const
 
 			auto webr = WebRequest("GET", dburl, assetUrl);
 			if (!webr.Ok() || !((picojson::value)webr).contains("_id")) {
-				std::clog << "\n- Uploading '" << a.first << "'\n";
+				std::clog << "\n- Uploading '" << a.second.get("name") << "'\n";
 
 				picojson::object blob{
-					{ "type", "asset" }
+					{ "type", "asset" },
+					{ "source", a.second.get("name") }
 				};
 				auto blobStr = picojson::value{ blob }.serialize();
 

          
@@ 748,8 714,6 @@ void render_to_db(const char* url, const
 
 int link_page(const char **files, int num_files, const char* db_url, const char *db_auth) {
 	try {
-		if (!strcmp(db_auth, "anonymous")) db_auth = "";
-
 		std::clog << "Linking... " << (db_auth ? "(database)" : "(local files)") << "\n";
 
 		link = picojson::object{};

          
@@ 813,6 777,7 @@ int link_page(const char **files, int nu
 
 		copy_attachments();
 		if (db_url && db_auth) {
+			if (!strcmp(db_auth, "anonymous")) db_auth = "";
 			page_url = [](std::string path) {
 				for (auto& c : path) {
 					if (c == '/') { } else if (isalnum(c) || c == '.' || c == '-' || c == '_') { c = tolower(c); } else { c = '-'; }

          
@@ 832,9 797,8 @@ int link_page(const char **files, int nu
 				}
 				return path + ".html";
 			};
-
+			render_pages();
 			copy_attachments();
-			render_pages();
 		}
 		return 0;
 	} catch (std::exception & e) {

          
M src/website/md2json.cpp +34 -55
@@ 23,60 23,13 @@ 
 #include "driver/package.h"
 #include "subrepl.h"
 
-template <typename ITER> void base64_encode(std::ostream& out, ITER begin, ITER end) {
-	static const std::string base64_chars =
-		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
-		"abcdefghijklmnopqrstuvwxyz"
-		"0123456789+/";
-
-	static_assert(sizeof(decltype(*begin)) == 1, "assuming byte data for now");
-
-	char buf[3] = { 0 };
-	int bp = 0;
+extern struct stat exeStat;
 
-	while (begin != end) {
-		buf[bp++] = *begin++;
-		if (bp == 3) {
-			out << base64_chars[(buf[0] & 0xfc) >> 2];
-			out << base64_chars[((buf[0] & 0x03) << 4) + ((buf[1] & 0xf0) >> 4)];
-			out << base64_chars[((buf[1] & 0x0f) << 2) + ((buf[2] & 0xc0) >> 6)];
-			out << base64_chars[((buf[2] & 0x3f))];
-			bp = 0;
-		}
-	}
+picojson::value attachments = picojson::object{ };
 
-	if (bp) {
-		for (int i(bp); i < 3; ++i) buf[i] = 0;
-		out << base64_chars[(buf[0] & 0xfc) >> 2];
-		if (bp >= 1) out << base64_chars[((buf[0] & 0x03) << 4) + ((buf[1] & 0xf0) >> 4)];
-		if (bp >= 2) out << base64_chars[((buf[1] & 0x0f) << 2) + ((buf[2] & 0xc0) >> 6)];
-		while (bp++ < 3) out << '=';
-	}
-}
-
-picojson::object attachments;
+const std::string b64encode(const void* data, const size_t& len);
 
-std::string attach(const std::string& path, const std::string& ext, const std::string& mime) {
-	std::ifstream file{ path, std::ios_base::binary };
-	if (file.is_open() == false) throw std::runtime_error("Could not load '" + path + "'");
-	file.seekg(0, file.end);
-	std::vector<char> data((size_t)file.tellg());
-
-	if (data.empty()) throw std::runtime_error("Empty file");
-
-	file.seekg(0, file.beg);
-	file.read(data.data(), data.size());
-
-	std::stringstream b64;
-	base64_encode(b64, data.begin(), data.end());
-
-	auto uid = Packages::fnv1a(data.data(), data.size()).to_str() + "." + ext;
-	attachments[uid] = picojson::object{ 
-		{"data", b64.str()},
-		{"mime", mime} 
-	};
-	return uid;
-}
+std::string attach(picojson::value& link, const std::string& path, const std::string& ext, const std::string& mime);
 
 namespace md {
 	const char *link = "link";

          
@@ 97,6 50,7 @@ namespace md {
 	const char *code_syntax = "lang";
 	const char *reflink = "ref";
 	const char *hashtag = "a.hashtag";
+	const char* raw_html = "html";
 }
 
 static lithe::node consolidate_paragraph(lithe::node e) {

          
@@ 164,6 118,8 @@ static lithe::rule mini_markdown() {
 										for_(repeat(span), eol, I(empty_line | end()))),
 									  consolidate_paragraph);
 
+	auto raw_html = E(md::raw_html, T("<") << characters("line", "\n", true));
+
 	auto quote = lithe::custom("quote",
 							   E(md::quote, for_(I(">") << repeat(span), eol, I(empty_line))),
 							   consolidate_paragraph);

          
@@ 177,7 133,7 @@ static lithe::rule mini_markdown() {
 						   I(codeblock_delimiter)));
 
 	return for_(
-		IE("entity", empty_line | reflink | header | codeblock | quote | paragraph),
+		IE("entity", empty_line | reflink | header | codeblock | quote | raw_html | paragraph),
 		{},
 		end());
 }

          
@@ 428,7 384,7 @@ picojson::value render_kronos_code(std::
 
 					repl.audio.emplace_back(code);
 
-					auto uid = attach(tmpfile, "mp3", "audio/mpeg");
+					auto uid = attach(attachments, tmpfile, ".mp3", "audio/mpeg");
 					remove(tmpfile.c_str());
 					codeblock.emplace_back(picojson::array{ {"audio", uid} });
 					continue;

          
@@ 464,7 420,7 @@ picojson::value convert(const lithe::nod
 					for (int i = 1; i < n.size(); ++i) {
 						n[i].to_stream(html);
 					}
-					return picojson::array{ "raw-html", html.str()};
+					return picojson::array{ md::raw_html, html.str()};
 				}
 			}
 		}

          
@@ 483,10 439,28 @@ picojson::value convert(const lithe::nod
 	return children;
 }
 
+bool is_fresh(const char* in, const char* out) {
+	struct stat in_s, out_s;
+	if (stat(out, &out_s)) {
+		return false;
+	}
+
+	stat(in, &in_s);
+
+	return 
+		out_s.st_mtime >= in_s.st_mtime &&
+		out_s.st_mtime >= exeStat.st_mtime;
+}
+
 int compile_page(const char *in, const char *out) {
 	std::clog << in << " -> " << out << "\n";
 
 	Packages::MakeMultiLevelPath(out);
+
+	if (is_fresh(in, out)) {
+		std::clog << "(not modified)\n";
+		return 0;
+	}
 	
 	std::ifstream read(in);
 

          
@@ 574,6 548,11 @@ int compile_page(const char *in, const c
 				e = std::regex_replace(e, whitespace, " ");
 				evalTests[caseName] = picojson::object{ {"label", e} };
 			}
+
+			picojson::value attach;
+			if (attachments.contains("~attachments")) {
+				attach = attachments.get("~attachments");
+			}
 			
 			picojson::object json{
 				{ "markdown", picojson::object{

          
@@ 588,7 567,7 @@ int compile_page(const char *in, const c
 				{ "modified", picojson::object {
 					{ basename, isodate }
 				}},
-				{ "~attachments", attachments },
+				{ "~attachments", attach },
 			};
 
 			if (audioTests.size() || evalTests.size()) {