From e43f252e44445f0c001046541d7169ef1f0669e9 Mon Sep 17 00:00:00 2001 From: moss Date: Thu, 29 Jan 2026 19:12:05 -0600 Subject: [PATCH] initial commit --- fmj.tm | 177 ++++++++++++++++++++++++++++ readme.md | 24 ++++ tests/defaultauthor/.fmjconf | 3 + tests/defaultauthor/alices_post.fmj | 2 + tests/defaultauthor/bobs_post.fmj | 3 + tests/example/.fmjconf | 2 + tests/example/test1.fmj | 3 + tests/noconf/test1.fmj | 1 + 8 files changed, 215 insertions(+) create mode 100644 fmj.tm create mode 100644 readme.md create mode 100644 tests/defaultauthor/.fmjconf create mode 100644 tests/defaultauthor/alices_post.fmj create mode 100644 tests/defaultauthor/bobs_post.fmj create mode 100644 tests/example/.fmjconf create mode 100644 tests/example/test1.fmj create mode 100644 tests/noconf/test1.fmj diff --git a/fmj.tm b/fmj.tm new file mode 100644 index 0000000..b2f3826 --- /dev/null +++ b/fmj.tm @@ -0,0 +1,177 @@ +enum Writable( + Stdout, + File(path:Path), +) + func write(self:Writable, text:Text, newline:Bool=no) + when self is Stdout + say(text, newline) + return + is File(path) + _ := path.append(text) + if newline + _ := path.append("\n") + return + +struct Option(option: Text, value: Text) + func parse(line: Text, expected: Text -> Option?) + val : Text = "" + if not line.starts_with(expected, &val) + return none + + if val == "" + return none + + foundequals : Bool = no + cursor := 1 + while yes + if val[cursor] == " " + cursor += 1 + continue + + if not foundequals and val[cursor] == "=" + foundequals = yes + cursor += 1 + continue + + if foundequals and cursor <= val.length + return Option(expected, val.slice(cursor)) + + return none + return none + +fmjconfpath := ".fmjconf" +fmj_extension := "fmj" + +description := "description" +title := "title" +link := "link" +default_author := "default_author" +author := "author" +category := "category" +pub_date := "pub_date" + +default_author_value : Text? = none + +func write_feed_header(out: Writable, config: Text?) + out.write("") + + found_title : Bool = no + found_link : Bool = no + found_description : Bool = no + found_default_author : Bool = no + if config + for line in config.lines() + opt : Option? = none + opt = Option.parse(line, title) + if not found_title and opt + out.write("$(opt!.value)") + found_title = yes + continue + opt = Option.parse(line, link) + if not found_link and opt + out.write("$(opt!.value)") + found_link = yes + continue + opt = Option.parse(line, description) + if not found_description and opt + out.write("$(opt!.value)") + found_description = yes + continue + opt = Option.parse(line, default_author) + if not found_default_author and opt + default_author_value = opt!.value + found_default_author = yes + continue + + if not found_title + out.write("fmj generated feed") + + if not found_link + out.write("http://example.com") + + if not found_description + out.write("an rss feed generated by fmj") + + out.write("fmj") + +func write_feed_footer(out : Writable) + out.write("") + +func write_feed_item(out : Writable, path : Path) + if not (path.extension() == fmj_extension) + return + lines := path.read()!.lines() + has_requirements : Bool = no + for line in lines + if Option.parse(line, title) or Option.parse(line, description) + has_requirements = yes + break + if not has_requirements + return + + found_title : Bool = no + found_description : Bool = no + found_link : Bool = no + found_pub_date : Bool = no + found_author : Bool = no + + out.write("") + for line in lines + opt : Option? = none + opt = Option.parse(line, title) + if not found_title and opt + out.write("$(opt!.value)") + found_title = yes + continue + opt = Option.parse(line, link) + if not found_link and opt + out.write("$(opt!.value)") + found_link = yes + continue + opt = Option.parse(line, description) + if not found_description and opt + out.write("$(opt!.value)") + found_description = yes + continue + opt = Option.parse(line, author) + if not found_author and opt + out.write("$(opt!.value)") + found_author = yes + continue + opt = Option.parse(line, pub_date) + if not found_pub_date and opt + out.write("$(opt!.value)") + found_pub_date = yes + continue + opt = Option.parse(line, category) + if opt + out.write("$(opt.value)") + continue + if not found_author and default_author_value + out.write("$(default_author_value)") + out.write("") + +func main(dir: Path, output: Text = "rss.xml") + usingStdout : Bool = no + outfile : Path? = none + if output == "-" + usingStdout = yes + else + outfile = Path.from_text(output) + + if not usingStdout and outfile!.exists() + _ := outfile!.remove() + + out : Writable = if usingStdout + Writable.Stdout + else + Writable.File(outfile!) + + config := dir.child(fmjconfpath).read() + + out.write_feed_header(config) + + for path in dir.children() + out.write_feed_item(path) + + out.write_feed_footer() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4e0692b --- /dev/null +++ b/readme.md @@ -0,0 +1,24 @@ +# fmj +fmj is an rss feed generator. it takes a directory of config files that describe the posts and outputs xml. + +the file format is more concise than handwriting xml: +```fmj +title = s/discord/??? +description = on replacing discord +link = https://bear.o7moon.dev/sdiscord/ + +anything that isnt one of the defined options is a comment +``` + +## usage examples: +- `fmj tests/examples \\-` will output to stdout +- `fmj tests/noconf filename.xml` will output to `filename.xml` +- `fmj tests/defaultauthor` will output to `rss.xml` +## building / platform support: +this repo is a port of the [previous c implementation](https://git.tilde.town/moss/fmj) to [tomo](https://github.com/bruce-hill/tomo), which is a relatively recent language and has some rough edges (the requirement to write \\- instead of - for stdout comes from a conflict with the language's built-in argument parsing) but ive found it to be nicer than c for this small program and i used it as a chance to both learn a new language and make a small improvement to the c implementation (this one handles utf-8! due to tomo's Text type also handling it). + +`fmj.tm` can be built with the tomo compiler like this, if you have it installed: +````` +``` +tomo -e fmj.tm +``` diff --git a/tests/defaultauthor/.fmjconf b/tests/defaultauthor/.fmjconf new file mode 100644 index 0000000..1868466 --- /dev/null +++ b/tests/defaultauthor/.fmjconf @@ -0,0 +1,3 @@ +title = default author test case +description = contains a post with an author option and one without, to test the default author option from the .fmjconf file +default_author = alice diff --git a/tests/defaultauthor/alices_post.fmj b/tests/defaultauthor/alices_post.fmj new file mode 100644 index 0000000..1415725 --- /dev/null +++ b/tests/defaultauthor/alices_post.fmj @@ -0,0 +1,2 @@ +title = alice's post +description = this post implicitly has alice as the author due to the .fmjconf diff --git a/tests/defaultauthor/bobs_post.fmj b/tests/defaultauthor/bobs_post.fmj new file mode 100644 index 0000000..0d20eb1 --- /dev/null +++ b/tests/defaultauthor/bobs_post.fmj @@ -0,0 +1,3 @@ +title = bob's post +author = bob +description = bob wrote this post. diff --git a/tests/example/.fmjconf b/tests/example/.fmjconf new file mode 100644 index 0000000..394d7da --- /dev/null +++ b/tests/example/.fmjconf @@ -0,0 +1,2 @@ +title = example rss feed !!!!!!!!! +description = this is an example generated by fmj as a test case diff --git a/tests/example/test1.fmj b/tests/example/test1.fmj new file mode 100644 index 0000000..6aee6c9 --- /dev/null +++ b/tests/example/test1.fmj @@ -0,0 +1,3 @@ +title = TEST TITLE !!!!!! + +this is a comment diff --git a/tests/noconf/test1.fmj b/tests/noconf/test1.fmj new file mode 100644 index 0000000..a66d5a3 --- /dev/null +++ b/tests/noconf/test1.fmj @@ -0,0 +1 @@ +title = this is a test where the config file doesnt exist