commit 1f51f969de109213a0b92121310444f35ab53e84
Author: lumidify <nobody@lumidify.org>
Date:   Fri, 21 Feb 2020 14:15:30 +0100
Initial Commit
Diffstat:
| A | LICENSE |  |  | 121 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG.pm |  |  | 47 | +++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG/Config.pm |  |  | 103 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG/Generate.pm |  |  | 88 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG/Markdown.pm |  |  | 203 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG/Metadata.pm |  |  | 100 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG/Misc.pm |  |  | 43 | +++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG/Template.pm |  |  | 194 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | LSG/UserFuncs.pm |  |  | 110 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ | 
| A | README |  |  | 45 | +++++++++++++++++++++++++++++++++++++++++++++ | 
| A | generate.pl |  |  | 28 | ++++++++++++++++++++++++++++ | 
11 files changed, 1082 insertions(+), 0 deletions(-)
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
diff --git a/LSG.pm b/LSG.pm
@@ -0,0 +1,47 @@
+#!/usr/bin/env perl
+
+# LSG.pm - Lumidify Site Generator
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+# Note: cross-platform path processing is used wherever possible, but
+# other parts won't work properly anyways if the path separator isn't /.
+# Good that nobody important uses any OS on which that's the case.
+
+package LSG;
+use strict;
+use warnings;
+use LSG::Config;
+use LSG::Template;
+use LSG::UserFuncs;
+use LSG::Metadata;
+use LSG::Generate;
+use Data::Dumper;
+
+# FIXME: don't just chdir into $path, in case that messes up anything
+# the calling script wanted to do afterwards
+sub init {
+	my $path = shift;
+	chdir($path) or die "Unable to access directory \"$path\": $!\n";
+	LSG::Config::init_config("config.ini", "modified_dates");
+	LSG::Template::init_templates();
+	LSG::Metadata::init_metadata();
+	LSG::UserFuncs::init_userfuncs();
+}
+
+sub generate_site {
+	LSG::Generate::gen_files();
+	LSG::Generate::delete_obsolete();
+	LSG::Config::write_modified_dates("modified_dates");
+}
+
+1;
diff --git a/LSG/Config.pm b/LSG/Config.pm
@@ -0,0 +1,103 @@
+#!/usr/bin/env perl
+
+# LSG::Config - configuration for the LSG
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+package LSG::Config;
+use strict;
+use warnings;
+use utf8;
+use open qw< :encoding(UTF-8) >;
+
+use Exporter qw(import);
+our @EXPORT_OK = qw($config);
+
+# Yes, I know this isn't just used for real config
+our $config;
+
+sub read_modified_dates {
+	my $path = shift;
+	my %dates = (pages => {}, templates => {});
+	if (!-f $path) {
+		print(STDERR "Unable to open \"$path\". Using empty modified_dates.\n");
+		return \%dates;
+	}
+	open (my $fh, "<", $path) or die "Unable to open $path: $!\n";
+	foreach (<$fh>) {
+		chomp;
+		my @fields = split(" ", $_, 2);
+		my $date = $fields[0];
+		my $filename = $fields[1];
+		if ($filename =~ /\Apages\//) {
+			$dates{"pages"}->{substr($filename, 6)} = $date;
+		} elsif ($filename =~ /\Atemplates\//) {
+			$dates{"templates"}->{substr($filename, 10)} = $date;
+		} else {
+			die "Invalid file path \"$filename\" in \"$path\".\n";
+		}
+	}
+	close($fh);
+	return \%dates;
+}
+
+sub write_modified_dates {
+	my $path = shift;
+	open(my $fh, ">", $path) or die "Unable to open \"$path\": $!\n";
+	foreach my $pageid (keys %{$config->{"metadata"}}) {
+		foreach my $lang (keys %{$config->{"metadata"}->{$pageid}->{"modified"}}) {
+			print($fh $config->{"metadata"}->{$pageid}->{"modified"}->{$lang} . " pages/$pageid.$lang\n");
+		}
+	}
+	foreach my $template (keys %{$config->{"templates"}}) {
+		print($fh $config->{"templates"}->{$template}->{"modified"} . " templates/$template\n");
+	}
+	close($fh);
+}
+
+sub read_config {
+	my $path = shift;
+	my %config;
+	open (my $fh, "<", $path) or die "Unable to open $path: #!\n";
+	my $section = "";
+	foreach (<$fh>) {
+		chomp;
+		if ($_ eq "") {
+			$section = "";
+			next;
+		}
+		if (/^\[(.*)\]$/) {
+			$section = $1;
+			next;
+		}
+		my ($key, $value) = split("=", $_, 2);
+		if ($value =~ /:/) {
+			my @value = split(":", $value);
+			$value = \@value;
+		}
+		if ($section) {
+			$config{$section}->{$key} = $value;
+		} else {
+			$config{$key} = $value;
+		}
+	}
+	close($fh);
+	return \%config;
+}
+
+sub init_config {
+	my ($config_path, $modified_path) = @_;
+	$config = read_config($config_path);
+	$config->{"modified_dates"} = read_modified_dates($modified_path);
+}
+
+1;
diff --git a/LSG/Generate.pm b/LSG/Generate.pm
@@ -0,0 +1,88 @@
+#!/usr/bin/env perl
+
+# LSG::Generate - main generation function for the LSG
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+package LSG::Generate;
+use strict;
+use warnings;
+use utf8;
+use open qw< :encoding(UTF-8) >;
+binmode(STDOUT, ":utf8");
+use Cwd;
+use File::Spec::Functions qw(catfile);
+use File::Path qw(make_path);
+use LSG::Markdown;
+use LSG::Config qw($config);
+
+sub gen_files() {
+        foreach my $pageid (keys %{$config->{"metadata"}}) {
+                foreach my $lang (keys %{$config->{"langs"}}) {
+                        my $template = $config->{"metadata"}->{$pageid}->{"template"} . ".$lang.html";
+                        if (
+                                exists($config->{"modified_dates"}->{"pages"}->{"$pageid.$lang"}) &&
+                                exists($config->{"modified_dates"}->{"templates"}->{$template}) &&
+                                $config->{"modified_dates"}->{"pages"}->{"$pageid.$lang"} eq $config->{"metadata"}->{$pageid}->{"modified"}->{$lang} &&
+                                $config->{"modified_dates"}->{"templates"}->{$template} eq $config->{"templates"}->{$template}->{"modified"}
+                        ) {
+                                next;
+                        }
+                        print("Processing $pageid.$lang\n");
+                        my $html_dir = catfile("site", $lang, $config->{"metadata"}->{$pageid}->{"dirname"});
+                        make_path($html_dir);
+                        my $fullname = catfile("pages", "$pageid.$lang");
+                        my $html = LSG::Markdown::parse_md($lang, $pageid, $fullname);
+                        my $final_html = LSG::Template::render_template($html, $lang, $pageid);
+                        my $html_file = catfile("site", $lang, $pageid) . ".html";
+                        open(my $in, ">", $html_file) or die "ERROR: can't open $html_file for writing\n";
+                        print $in $final_html;
+                        close($in);
+                }
+        }
+}
+
+sub delete_obsolete_recurse {
+        my $dir = shift;
+        opendir(my $dh, $dir) or die "Unable to open directory \"" . getcwd() . "/$dir\": $!\n";
+        my $filename;
+        my @dirs;
+        while ($filename = readdir($dh)) {
+                next if $filename =~ /\A\.\.?\z/;
+                my $path = $dir eq "." ? $filename : catfile($dir, $filename);
+                if (-d $path) {
+                        push(@dirs, $path);
+                        next;
+                }
+                my $pageid = $path;
+                $pageid =~ s/\.html\z//;
+                if (!exists($config->{"metadata"}->{$pageid})) {
+                        print("Deleting old file \"" . getcwd() . "/$path\".\n");
+                        unlink($path);
+                }
+        }
+        closedir($dh);
+        foreach (@dirs) {
+                delete_obsolete_recurse($_);
+        }
+}
+
+sub delete_obsolete {
+        my $cur = getcwd();
+        foreach my $lang (keys %{$config->{"langs"}}) {
+                chdir(catfile("site", $lang)) or die "Unable to access directory \"site/$lang\": $!\n";
+                delete_obsolete_recurse(".");
+                chdir($cur);
+        }
+}
+
+1;
diff --git a/LSG/Markdown.pm b/LSG/Markdown.pm
@@ -0,0 +1,203 @@
+#!/usr/bin/env perl
+
+# LSG::Markdown - markdown preprocessor for the LSG
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+package LSG::Markdown;
+use strict;
+use warnings;
+use utf8;
+use open qw< :encoding(UTF-8) >;
+use File::Spec::Functions;
+use Text::Markdown qw(markdown);
+use LSG::Misc;
+use LSG::Config qw($config);
+
+sub handle_fnc {
+	my $pageid = shift;
+	my $lang = shift;
+	my $line = shift;
+	my $file = shift;
+	my $fnc_name = shift;
+	my @fnc_args = split(/ /, shift);
+	if (!exists($config->{"funcs"}->{$fnc_name})) {
+		die "ERROR: $file: undefined function \"$fnc_name\":\n$line\n";
+	}
+	return $config->{"funcs"}->{$fnc_name}->($pageid, $lang, @fnc_args);
+}
+
+sub handle_lnk {
+	my $pageid = shift;
+	my $lang = shift;
+	my $line = shift;
+	my $file = shift;
+	my $txt = shift;
+	my $lnk = shift;
+	my $lnk_file = "";
+	my $lnk_path = "";
+	my $url = "";
+
+	my $char_one = substr($lnk, 0, 1);
+	if ($char_one eq "@") {
+		$lnk_file = $config->{"metadata"}->{$pageid}->{"basename"} . substr($lnk, 1);
+		$lnk_path = catfile("site", "static", $lnk_file);
+		$url = LSG::Misc::gen_relative_link("$lang/$pageid", "static/$lnk_file");
+	} elsif ($char_one eq "#") {
+		$lnk_file = substr($lnk, 1);
+		$lnk_path = catfile("site", "static", $lnk_file);
+		$url = LSG::Misc::gen_relative_link("$lang/$pageid", "static/$lnk_file");
+	} elsif ($char_one eq "\$") {
+		$lnk_file = substr($lnk, 1);
+		$lnk_path = catfile("pages", $lnk_file);
+		# Convert to /lang/page format
+		my $lnk_abs = substr($lnk_file, -2) . "/" . substr($lnk_file, 0, length($lnk_file) - 3) . ".html";
+		$url = LSG::Misc::gen_relative_link("$lang/$pageid", $lnk_abs);
+	} else {
+		$url = $lnk;
+	}
+        if ($lnk_path && !(-f $lnk_path)) {
+                die "ERROR: $file: linked file $lnk_path does not exist:\n$line\n";
+        }
+	return "[$txt]($url)";
+}
+
+sub handle_img {
+        my $pageid = shift;
+        my $lang = shift;
+        my $line = shift;
+        my $file = shift;
+        my $txt = shift;
+	my $img = shift;
+	my $img_file = "";
+	my $img_path = "";
+	my $src = "";
+
+	my $char_one = substr($img, 0, 1);
+        if ($char_one eq "@") {
+                $img_file = $config->{"metadata"}->{$pageid}->{"basename"} . substr($img, 1);
+		$img_path = catfile("site", "static", $img_file);
+		$src = LSG::Misc::gen_relative_link("$lang/$pageid", "static/$img_file");
+        } elsif ($char_one eq "#") {
+                $img_file = substr($img, 1);
+		$img_path = catfile("site", "static", $img_file);
+		$src = LSG::Misc::gen_relative_link("$lang/$pageid", "static/$img_file");
+        } else {
+		$src = $img;
+        }
+	if ($img_path && !(-f $img_path)) {
+		die "ERROR: $file: image file $img_path does not exist:\n$line\n";
+	}
+
+	return "";
+}
+
+sub add_child {
+	my $parent = shift;
+	my $type = shift;
+	$parent->{"child"} = {type => $type, txt => "", url => "", parent => $parent, child => {}};
+
+	return $parent->{"child"};
+}
+
+sub finish_child {
+	my $child = shift;
+	my $pageid = shift;
+	my $lang = shift;
+	my $line = shift;
+	my $file = shift;
+	my $parent = $child->{"parent"};
+
+	if ($child->{"type"} eq "img") {
+		$parent->{"txt"} .= handle_img($pageid, $lang, $line, $file, $child->{"txt"}, $child->{"url"});
+	} elsif ($child->{"type"} eq "lnk") {
+		$parent->{"txt"} .= handle_lnk($pageid, $lang, $line, $file, $child->{"txt"}, $child->{"url"});
+	} elsif ($child->{"type"} eq "fnc") {
+		$parent->{"txt"} .= handle_fnc($pageid, $lang, $line, $file, $child->{"txt"}, $child->{"url"});
+	}
+
+	return $parent;
+}
+
+sub parse_md {
+	my $lang = shift;
+	my $pageid = shift;
+	my $inpath = shift;
+	open(my $in, "<", $inpath) or die "ERROR: Can't open $inpath for reading.";
+	# skip metadata
+	while (<$in> =~ /^([^:]*):(.*)$/) {}
+
+	my $txt = "";
+	my $bs = 0;
+	my $IN_IMG = 1;
+	my $IN_LNK = 2;
+	my $IN_FNC = 4;
+	my $IN_TXT = 8;
+	my $IN_URL = 16;
+	my $IN_IMG_START = 32;
+	my %structure = (txt => "", child => {});
+	my $cur_child_ref = \%structure;
+	my @states = (0);
+	foreach (<$in>) {
+		foreach my $char (split //, $_) {
+			if ($char eq "\\") {
+				$bs++;
+				if (!($bs %= 2)) {$txt .= "\\"};
+			} elsif ($bs % 2) {
+				# FIXME: CLEANUP!!!
+				if ($states[-1] & $IN_TXT) {
+					$cur_child_ref->{"txt"} .= $char;
+				} elsif ($states[-1] & $IN_URL) {
+					$cur_child_ref->{"url"} .= $char;
+				} elsif (!($states[-1] & ($IN_IMG | $IN_LNK | $IN_FNC))) {
+					$structure{"txt"} .= $char;
+				}
+				$bs = 0;
+			} elsif ($char eq "!") {
+				push(@states, $IN_IMG_START);
+			} elsif ($char eq "[") {
+				if ($states[-1] & $IN_IMG_START) {
+					$states[-1] = $IN_IMG | $IN_TXT;
+					$cur_child_ref = add_child($cur_child_ref, "img");
+				} else {
+					push(@states, $IN_LNK | $IN_TXT);
+					$cur_child_ref = add_child($cur_child_ref, "lnk");
+				}
+			} elsif ($char eq "{") {
+				$cur_child_ref = add_child($cur_child_ref, "fnc");
+				push(@states, $IN_FNC | $IN_TXT);
+			} elsif ($char eq "]" && ($states[-1] & ($IN_IMG | $IN_LNK) && $states[-1] & $IN_TXT)) {
+				$states[-1] &= ~$IN_TXT;
+			} elsif ($char eq "}" && $states[-1] & $IN_FNC && $states[-1] & $IN_TXT) {
+				$states[-1] &= ~$IN_TXT;
+			} elsif ($char eq "(" && $states[-1] & ($IN_IMG | $IN_LNK | $IN_FNC)) {
+				$states[-1] |= $IN_URL;
+			} elsif ($char eq ")" && ($states[-1] & $IN_URL)) {
+				pop(@states);
+				$cur_child_ref = finish_child($cur_child_ref, $pageid, $lang, $_, $inpath);
+			} else {
+				if ($states[-1] & $IN_IMG_START) {pop(@states)}
+				if ($states[-1] & $IN_TXT) {
+					$cur_child_ref->{"txt"} .= $char;
+				} elsif ($states[-1] & $IN_URL) {
+					$cur_child_ref->{"url"} .= $char;
+				} elsif (!($states[-1] & ($IN_IMG | $IN_LNK | $IN_FNC))) {
+					$structure{"txt"} .= $char;
+				}
+			}
+		}
+	}
+
+	return markdown($structure{"txt"});
+}
+
+1;
diff --git a/LSG/Metadata.pm b/LSG/Metadata.pm
@@ -0,0 +1,100 @@
+#!/usr/bin/env perl
+
+# LSG::Metadata - metadata parser for the LSG
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+package LSG::Metadata;
+use strict;
+use warnings;
+use utf8;
+use open qw< :encoding(UTF-8) >;
+use File::Find;
+use File::Spec::Functions qw(catfile catdir splitdir);
+use File::Path;
+use LSG::Config qw($config);
+
+sub parse_metadata_file {
+	my $in = shift;
+	my %tmp_fm = ();
+	while (<$in> =~ /^([^:]*):(.*)$/) {
+		$tmp_fm{$1} = $2;
+		if (eof) {last}
+	}
+	return \%tmp_fm;
+}
+
+sub parse_metadata {
+	if (!(-f)) {return};
+	my $fullname = $File::Find::name;
+	# Strip "pages/" from dirname
+	my @dirs = splitdir($File::Find::dir);
+	my $dirname = catdir(@dirs[1..$#dirs]);
+	my $basename = substr($_, 0, $#_-2);
+	# Note: this will only work if language codes are two chars
+	my $lang = substr($_, -2);
+	my $pageid = $basename;
+	if ($dirname) {$pageid = catfile($dirname, $basename)};
+	open(my $in, "<", $_) or die "Can't open $fullname: $!";
+	my %tmp_md = %{parse_metadata_file($in)};
+	close($in);
+	my $modified_date = (stat($_))[9];
+	$config->{"metadata"}->{$pageid}->{"modified"}->{$lang} = $modified_date;
+	if (!exists($tmp_md{"template"})) {
+		die "ERROR: $fullname does not specify a template\n";
+	}
+	if (!exists($config->{"templates"}->{$tmp_md{"template"} . ".$lang.html"})) {
+		die "ERROR: $fullname: template " . $tmp_md{"template"} . " does not exist\n";
+	}
+	# Note: if different templates are specified for different languages,
+	# the one from the last language analyzed is used.
+	# FIXME: change this - if different templates are specified for different langs,
+	# the template isn't checked for existance in other langs
+	# Wait, why not just use the actual template given? It's stored in $config->{"metadata"} anyways
+	$config->{"metadata"}->{$pageid}->{"template"} = $tmp_md{"template"};
+	foreach my $md_id (split / /, $config->{"templates"}->{$tmp_md{"template"} . ".$lang.html"}->{"metadata"}) {
+		if (!exists($tmp_md{$md_id})) {
+			die "ERROR: $fullname does not include \"$md_id\" metadata\n";
+		}
+	}
+	foreach my $md_id (keys %tmp_md) {
+		$config->{"metadata"}->{$pageid}->{$lang}->{$md_id} = $tmp_md{$md_id};
+	}
+	$config->{"metadata"}->{$pageid}->{"dirname"} = $dirname;
+	$config->{"metadata"}->{$pageid}->{"basename"} = $basename;
+}
+
+sub gen_metadata_hash {
+	find(\&parse_metadata, "pages/");
+}
+
+sub check_metadata_langs {
+	my $not_found;
+	foreach my $pageid (keys %{$config->{"metadata"}}) {
+		$not_found = "";
+		foreach my $lang (keys %{$config->{"langs"}}) {
+			if (!exists($config->{"metadata"}->{$pageid}->{$lang})) {
+				$not_found .= " $lang";
+			}
+		}
+		if ($not_found) {
+			die("ERROR: languages \"$not_found\" not found for $pageid\n");
+		}
+	}
+}
+
+sub init_metadata {
+	gen_metadata_hash();
+	check_metadata_langs();
+}
+
+1;
diff --git a/LSG/Misc.pm b/LSG/Misc.pm
@@ -0,0 +1,43 @@
+#!/usr/bin/env perl
+
+# LSG::Misc - miscellaneous functions for the LSG
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+package LSG::Misc;
+use strict;
+use warnings;
+use utf8;
+use open qw< :encoding(UTF-8) >;
+
+# Generate relative link - both paths must already be relative,
+# starting at the same place!
+# e.g. "bob/hi/whatever/meh.txt","bob/hi/bla/fred.txt" => ../bla/fred.txt
+sub gen_relative_link {
+        my ($base, $linked) = @_;
+        my @parts_base = split("/", $base);
+        my @parts_linked = split("/", $linked);
+        # don't include last element in @parts_base (the filename)
+        my $i = 0;
+        while ($i < $#parts_base && $i < $#parts_linked) {
+                if ($parts_base[$i] ne $parts_linked[$i]) {
+                        last;
+                }
+                $i++;
+        }
+        my $rel_lnk = "";
+        $rel_lnk .= "../" x ($#parts_base-$i);
+        $rel_lnk .= join("/", @parts_linked[$i..$#parts_linked]);
+        return $rel_lnk;
+}
+
+1;
diff --git a/LSG/Template.pm b/LSG/Template.pm
@@ -0,0 +1,194 @@
+#!/usr/bin/env perl
+
+# LSG::Template - template processor for the LSG
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+package LSG::Template;
+use strict;
+use warnings;
+use utf8;
+use open qw< :encoding(UTF-8) >;
+use File::Spec::Functions qw(catfile);
+use Storable 'dclone';
+use LSG::Config qw($config);
+use LSG::Metadata;
+
+sub parse_template {
+	my $template_name = shift;
+	my $state = 0;
+	my $IN_BRACE = 1;
+	my $IN_BLOCK = 2;
+	my $txt = "";
+	my $bs = 0;
+
+	# Note: there needs to be a line between metadata and content since the
+	# metadata parser takes a line to realize it is not in fm anymore
+	my $inpath = catfile("templates", $template_name);
+	open(my $in, "<", $inpath) or die "ERROR: template: Can't open $inpath for reading.";
+	my $template = LSG::Metadata::parse_metadata_file($in);
+
+	foreach (<$in>) {
+		foreach my $char (split //, $_) {
+			if ($char eq "\\") {
+				$bs++;
+				if (!($bs %= 2)) {$txt .= "\\"};
+			} elsif ($bs % 2) {
+				$txt .= $char;
+				$bs = 0;
+			} elsif ($char eq "{" && !($state & $IN_BRACE)) {
+				$state |= $IN_BRACE;
+				if ($txt ne "") {
+					if ($state & $IN_BLOCK) {
+						push(@{$template->{"contents"}->[-1]->{"contents"}},
+						     {type => "txt", contents => $txt});
+					} else {
+						push(@{$template->{"contents"}}, {type => "txt", contents => $txt});
+					}
+					$txt = "";
+				}
+			} elsif ($char eq "}" && $state & $IN_BRACE) {
+				$state &= ~$IN_BRACE;
+				my @brace = split(/ /, $txt);
+				if (!@brace) {
+					die("ERROR: empty brace in $inpath:\n$_\n");
+				} else {
+					if ($brace[0] eq "endblock") {
+						$state &= ~$IN_BLOCK
+					} elsif ($brace[0] eq "block") {
+						$state |= $IN_BLOCK;
+						if ($#brace != 1) {
+							die("ERROR: wrong number of arguments for block in $inpath\n");
+						} else {
+							push(@{$template->{"contents"}}, {type => $brace[0],
+									 id => $brace[1],
+									 contents => []});
+						}
+					} else {
+						my %tmp = (type => $brace[0]);
+						if ($#brace > 0) {
+							@{$tmp{"args"}} = @brace[1..$#brace];
+						}
+						if ($state & $IN_BLOCK) {
+							push(@{$template->{"contents"}->[-1]->{"contents"}}, \%tmp);
+						} else {
+							push(@{$template->{"contents"}}, \%tmp);
+						}
+					}
+				}
+				$txt = "";
+			} else {
+				$txt .= $char;
+			}
+		}
+	}
+	if ($state & ($IN_BRACE | $IN_BLOCK)) {
+		die("ERROR: unclosed block or brace in $inpath\n");
+	} elsif ($txt ne "") {
+		push(@{$template->{"contents"}}, {type => "txt", contents => $txt});
+	}
+	close($in);
+	my $modified_date = (stat($inpath))[9];
+	$template->{"modified"} = $modified_date;
+	return $template;
+}
+
+sub handle_parent_template {
+	my $parentid = shift;
+	my $childid = shift;
+	if (exists $config->{"templates"}->{$parentid}->{"extends"}) {
+		handle_parent_template($config->{"templates"}->{$parentid}->{"extends"}, $parentid);
+	}
+	if ($config->{"templates"}->{$parentid}->{"modified"} > $config->{"templates"}->{$childid}->{"modified"}) {
+		$config->{"templates"}->{$childid}->{"modified"} = $config->{"templates"}->{$parentid}->{"modified"};
+	}
+	my $parent = $config->{"templates"}->{$parentid}->{"contents"};
+	my $child = $config->{"templates"}->{$childid}->{"contents"};
+	my $child_new = dclone($parent);
+	# Replace blocks from parent template with child blocks
+	# Not very efficient...
+	foreach my $item (@{$child_new}) {
+		if ($item->{"type"} eq "block") {
+			foreach my $item_new (@{$child}) {
+				if ($item_new->{"type"} eq "block" && $item_new->{"id"} eq $item->{"id"}) {
+					$item->{"contents"} = $item_new->{"contents"};
+					last;
+				}
+			}
+		}
+	}
+	$config->{"templates"}->{$childid}->{"contents"} = $child_new;
+	delete $config->{"templates"}->{$childid}->{"extends"};
+}
+
+sub do_template_inheritance {
+	foreach my $template_id (keys %{$config->{"templates"}}) {
+		if (exists $config->{"templates"}->{$template_id}->{"extends"}) {
+			handle_parent_template($config->{"templates"}->{$template_id}->{"extends"}, $template_id);
+		}
+	}
+}
+
+sub init_templates {
+	opendir(my $dir, "templates") or die "ERROR: couldn't open dir templates/\n";
+	my @files = grep {!/\A\.\.?\z/} readdir($dir);
+	closedir($dir);
+	foreach my $filename (@files) {
+		$config->{"templates"}->{$filename} = parse_template($filename);
+	}
+	do_template_inheritance();
+}
+
+# FIXME: more error checking - arg numbers
+# -> not too important though since these are just templates (won't be edited too often)
+sub do_template_items {
+	my $main_content = shift;
+	my $lang = shift;
+	my $pageid = shift;
+	my $template = shift;
+	my $final = "";
+	for my $item (@{$template->{"contents"}}) {
+		if ($item->{"type"} eq "txt") {
+			$final .= $item->{"contents"};
+		} elsif ($item->{"type"} eq "var") {
+			$final .= $config->{"metadata"}->{$pageid}->{$lang}->{$item->{"args"}->[0]};
+		} elsif ($item->{"type"} eq "content") {
+			$final .= $main_content;
+		} elsif ($item->{"type"} eq "block") {
+			$final .= do_template_items($main_content, $lang, $pageid, $item);
+		} elsif ($item->{"type"} eq "func") {
+			my $func = $item->{"args"}->[0];
+			my @func_args = @{$item->{"args"}}[1..$#{$item->{"args"}}];
+			# Pass in the array rather than a reference, so these arguments
+			# are received like all other arguments
+			if (!exists($config->{"funcs"}->{$func})) {
+				# FIXME: need more information to give for error
+				die "ERROR: undefined function \"$func\" in template.\n";
+			}
+			$final .= $config->{"funcs"}->{$func}->($pageid, $lang, @func_args);
+		}
+	}
+	return $final;
+}
+
+sub render_template {
+	my $html = shift;
+	my $lang = shift;
+	my $pageid = shift;
+	my $template = $config->{"metadata"}->{$pageid}->{"template"};
+	if (!exists($config->{"templates"}->{"$template.$lang.html"})) {
+		die "ERROR: can't open template $template.$lang.html\n";
+	}
+	return do_template_items($html, $lang, $pageid, $config->{"templates"}->{"$template.$lang.html"});
+}
+
+1;
diff --git a/LSG/UserFuncs.pm b/LSG/UserFuncs.pm
@@ -0,0 +1,110 @@
+#!/usr/bin/env perl
+
+#TODO: template - func processed once and func processed for each page
+
+# LSG::UserFuncs - user functions for the LSG (called from templates and markdown files)
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+package LSG::UserFuncs;
+use strict;
+use warnings;
+use utf8;
+use open qw< :encoding(UTF-8) >;
+use LSG::Config qw($config);
+use LSG::Misc;
+
+# FIXME: maybe also pass line for better error messages
+# Module arguments:
+# 1:  page id in %fm
+# 2:  page language
+# 3-: other args (e.g. for func call)
+
+sub sort_books {
+	my $pageid = shift;
+	my $lang = shift;
+	my $sort_by = shift;
+	my $create_subheadings = shift;
+	if (!$sort_by) {die "ERROR: not enough arguments to function call in $pageid\n"}
+	my $output = "";
+	my %tmp_md = ();
+	foreach my $id (keys %{$config->{"metadata"}}) {
+		if ($config->{"metadata"}->{$id}->{"dirname"} eq "books") {
+			$tmp_md{$id} = $config->{"metadata"}->{$id};
+			if (!exists($config->{"metadata"}->{$id}->{$lang}->{$sort_by})) {
+				die "ERROR: $pageid: can't sort by \"$sort_by\"\n";
+			}
+		}
+	}
+	my $current = "";
+	foreach my $id (sort {$tmp_md{$a}->{$lang}->{$sort_by} cmp $tmp_md{$b}->{$lang}->{$sort_by} or
+	                      $tmp_md{$a}->{$lang}->{"title"} cmp $tmp_md{$b}->{$lang}->{"title"}} (keys %tmp_md)) {
+		if ($create_subheadings && $create_subheadings eq "true" && $current ne $tmp_md{$id}->{$lang}->{$sort_by}) {
+			$current = $tmp_md{$id}->{$lang}->{$sort_by};
+			$output .= "<h3>$current</h3>\n";
+		}
+		my $rel_lnk = LSG::Misc::gen_relative_link("$lang/$pageid", "$lang/$id.html");
+		$output .= "<p><a href=\"$rel_lnk\">" . $tmp_md{$id}->{$lang}->{"title"} . "</a></p>\n";
+	}
+
+	return $output;
+}
+
+sub gen_lang_selector {
+	my $pageid = shift;
+	my $lang = shift;
+	my $output = "<ul>\n";
+	foreach my $nav_lang (keys %{$config->{"langs"}}) {
+		if ($nav_lang ne $lang) {
+			my $url = LSG::Misc::gen_relative_link("$lang/$pageid", "$nav_lang/$pageid.html");
+			$output .= "<li><a href=\"$url\">" . $config->{"langs"}->{$nav_lang} . "</a></li>\n";
+		}
+	}
+	$output .= "</ul>";
+
+	return $output;
+}
+
+sub gen_nav {
+	my $pageid = shift;
+	my $lang = shift;
+	# Don't print <ul>'s so extra content can be added in template
+	#my $output = "<ul>\n";
+	my $output = "";
+	my @nav = @{$config->{"nav"}};
+	# Not necessary because of direction: rtl in style
+	#if ($lang_dirs{$lang} eq "rtl") {
+	#	@nav = reverse(@nav);
+	#}
+	foreach my $nav_page (@nav) {
+		my $title = $config->{"metadata"}->{$nav_page}->{$lang}->{"title"};
+		my $url = LSG::Misc::gen_relative_link("$lang/$pageid", "$lang/$nav_page.html");
+		$output .= "<li><a href=\"$url\">$title</a></li>\n";
+	}
+	#$output .= "</ul>";
+
+	return $output;
+}
+
+sub gen_relative_link {
+	my ($pageid, $lang, $link) = @_;
+	return LSG::Misc::gen_relative_link("$lang/$pageid", $link);
+}
+
+sub init_userfuncs {
+	$config->{"funcs"}->{"gen_lang_selector"} = \&gen_lang_selector;
+	$config->{"funcs"}->{"sort_books"} = \&sort_books;
+	$config->{"funcs"}->{"gen_nav"} = \&gen_nav;
+	$config->{"funcs"}->{"gen_relative_link"} = \&gen_relative_link;
+}
+
+1;
diff --git a/README b/README
@@ -0,0 +1,45 @@
+Almost all standard markdown features (https://daringfireball.net/projects/markdown/syntax)
+should be supported since the markdown is just passed to the standard markdown parser after
+being preprocessed to make things easier to write.
+
+Notable changes:
+- Link titles and alt text for images is not supported in links that need to be preprocessed,
+  e.g. [Hi](@example.com "Title")
+- Reference-style links are not parsed by the preprocessor
+
+Special simplifications handled by the preprocessor:
+
+Links:
+[Whatever](@.pdf)-> [Whatever](relative/path/to/static/$name_of_page.pdf)
+[Whatever](#bob.pdf) -> [Whatever](relative/path/to/static/bob.pdf)
+[Whatever]($page.en) -> [Whatever](relative/path/to/en/page.html)
+
+Images:
+-> [Whatever](relative/path/to/static/$name_of_page.png)
+ -> [Whatever](relative/path/to/static/bob.png)
+
+Functions:
+Functions can be used for more advanced features. They are written using Perl in the file
+`LSG/UserFuncs.pm` and can be called from a markdown file as follows:
+`{name_of_function}(argument1 argument2 argument3)`
+Note: this format may change in the future if more advanced arguments are needed.
+
+Currently implemented functions:
+
+`sort_books`
+Parameters:
+- attribute to sort by
+- create heading when attribute changes or not
+Purpose:
+Generate sorted list of all books, first by the given attribute, which can be anything
+in the metadata, then by the titles. The second attribute can be used to create, for
+instance, category titles. This does not make sense though when the attribute is just
+the title which changes every time anyways. If the second argument is left out, it
+defaults to "false". The attribute to be sorted by (obviously) needs to be defined for
+each book.
+Example:
+{sort_books}(category false)
+
+Two more functions, `gen_nav` and `gen_lang_selector`, are defined, but they are
+currently only used internally in the templates and probably aren't needed for the
+actual pages.
diff --git a/generate.pl b/generate.pl
@@ -0,0 +1,28 @@
+#!/usr/bin/env perl
+
+# FIXME: standardize var names (e.g. $pageid, $page)
+
+# REQUIREMENTS: Text::Markdown
+
+# lsg.pl - Lumidify Site Generator
+# Written by lumidify <nobody@lumidify.org>
+# Last updated: 2019-08-21
+#
+# To the extent possible under law, the author has dedicated
+# all copyright and related and neighboring rights to this
+# software to the public domain worldwide. This software is
+# distributed without any warranty.
+#
+# You should have received a copy of the CC0 Public Domain
+# Dedication along with this software. If not, see
+# <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+use strict;
+use warnings;
+use FindBin;
+use lib "$FindBin::Bin";
+use LSG;
+
+my $path = $#ARGV >= 0 ? $ARGV[0] : ".";
+LSG::init($path);
+LSG::generate_site();