From 2dd09078a6a285da5b59bd77a93a8e52f40dc847 Mon Sep 17 00:00:00 2001 From: Jeff Date: Thu, 6 Jun 2024 20:25:02 -0400 Subject: [PATCH] Initial commit --- .gitignore | 1 + Caddyfile | 7 ++ Containerfile | 15 +++ basic.css | 103 ++++++++++++++++++ build.fish | 19 ++++ src/an_auto_increment_crate_for_rust.md | 84 ++++++++++++++ src/cookbook.md | 36 ++++++ src/index.md | 14 +++ ..._time_to_rethink_the_command_line_shell.md | 96 ++++++++++++++++ src/nav.md | 5 + src/resume.md | 43 ++++++++ src/rust_packages_vs_crates.md | 57 ++++++++++ src/visualizing_data_with_dust.md | 48 ++++++++ templates/nav.html | 1 + templates/page.html | 23 ++++ 15 files changed, 552 insertions(+) create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Containerfile create mode 100644 basic.css create mode 100644 build.fish create mode 100644 src/an_auto_increment_crate_for_rust.md create mode 100644 src/cookbook.md create mode 100644 src/index.md create mode 100644 src/its_time_to_rethink_the_command_line_shell.md create mode 100644 src/nav.md create mode 100644 src/resume.md create mode 100644 src/rust_packages_vs_crates.md create mode 100644 src/visualizing_data_with_dust.md create mode 100644 templates/nav.html create mode 100644 templates/page.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..620ac16 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,7 @@ +jeffa.io, +www.jeffa.io, +localhost:8000 { + root * /srv/ + file_server + try_files {path} {path}.html {path}/ 404 +} diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..07a5435 --- /dev/null +++ b/Containerfile @@ -0,0 +1,15 @@ +FROM alpine as build + +RUN apk update && apk add pandoc fish + +WORKDIR /www.jeffa.io/ + +COPY . . + +RUN fish build.fish + +FROM caddy:2-alpine + +COPY Caddyfile /etc/caddy/Caddyfile + +COPY --from=build /www.jeffa.io/out /srv diff --git a/basic.css b/basic.css new file mode 100644 index 0000000..5321e4c --- /dev/null +++ b/basic.css @@ -0,0 +1,103 @@ +/* Basic.css */ + +* {box-sizing: border-box} + +:root{ +--sans: 1em/1.6 system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Droid Sans, Helvetica Neue, Fira Sans, sans-serif; +--mono: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, 'Courier New', monospace; +--c1:#0074d9; +--c2:#eee; +--c3:#fff; +--c4:#000; +--c5:#fff; +--m1: 8px; +--rc: 8px; +} + +@media (prefers-color-scheme: dark) { + :root { + --c2:#333; + --c3:#1e1f20; + --c4:#fff; + } +} + +html { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + +} + +/* General settings */ + +body { + margin: 10px 15% 10px 15%; + font: var(--sans); + font-weight: 400; + font-style: normal; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + background-color: var(--c3); + color: var(--c4); +} +img, iframe {border: none; max-width: 100%} + +a {color: var(--c1); text-decoration:none} + +a:hover {color: var(--c1); text-decoration: underline} + +pre {font: 1em/1.6 var(--mono); background: var(--c2); padding: 1em; overflow: auto} + +code {font: 1em/1.6 var(--mono);} + +blockquote {border-left: 5px solid var(--c2); padding: 1em 1.5em; margin: 0} + +hr {border:0; border-bottom: 1px solid var(--c4)} + + /* Headlines */ + +h1,h2,h3,h4,h5,h6 {margin: 0.6em 0; font-weight: normal} + +h1 {font-size: 2.625em; line-height: 1.2} + +h2 {font-size: 1.625em; line-height: 1.2} + +h3 {font-size: 1.3125em; line-height: 1.24} + +h4 {font-size: 1.1875em; line-height: 1.23} + +h5,h6 {font-size: 1em; font-weight:bold} + +/* Table */ + +table {border-collapse: collapse; border-spacing: 0; margin:1em 0} + +th, td {text-align: left; vertical-align: top; border: 1px solid; padding: 0.4em} + +thead,tfoot {background: var(--c2)} + +/* Rounded Corners*/ + +pre,code,input,select,textarea,button,img {border-radius: var(--rc)} + + +/* Forms */ + +input, select, textarea {font-size: 1em; color:var(--c4); background: var(--c2); border: 0; padding: 0.6em} + +button, input[type=submit], input[type=reset], input[type="button"] { -webkit-appearance: none; font-size: 1em; display: inline-block; color: var(--c5); background: var(--c1); border: 0; margin: 4px; padding: 0.6em; cursor: pointer; text-align: center} + +button:hover, button:focus, input:hover, textarea:hover, select:hover {opacity: 0.8} + +/* Infinite Grid */ + +section {display: flex; flex-flow: row wrap} + +[style*="--c:"], section>section, aside, article {flex:var(--c,1); margin:var(--m1)} + +/* Cards */ + +article {background: var(--c2); border-radius: var(--rc); padding: 1em; box-shadow: 0px 1px 0px rgba(0,0,0,0.3)} + +[style*="--c:"]:first-child, section>section:first-child, article:first-child {margin-left:0} +[style*="--c:"]:last-child, section>section:last-child, article:last-child {margin-right:0} diff --git a/build.fish b/build.fish new file mode 100644 index 0000000..4ac8864 --- /dev/null +++ b/build.fish @@ -0,0 +1,19 @@ +if ! test -d out/ + mkdir out/ +end + +cp basic.css out/index.css + +for file in src/**/*.md + set -l output (string replace .md .html $file) + set -l output (string replace src/ out/ $output) + + pandoc \ + --to html \ + --embed-resources \ + --standalone \ + --template templates/page.html \ + --email-obfuscation references \ + $file \ + > $output +end diff --git a/src/an_auto_increment_crate_for_rust.md b/src/an_auto_increment_crate_for_rust.md new file mode 100644 index 0000000..b9118ac --- /dev/null +++ b/src/an_auto_increment_crate_for_rust.md @@ -0,0 +1,84 @@ +--- +date: Jan 26th, 2021 +--- + +# An Auto-Increment Crate for Rust + +...now exists! [Check it out.][1] + +## The Problem + +I was recently working on a Rust program and needed a way to give instances a unique identifier. These IDs did not need to be *universally* unique. I wanted the simplest way to recognize two instances as unique even if they were identical aside from the ID. A serial (or auto-increment) ID seemed like an appropriate choice. + +As a rule, I try to write my own code to avoid over-reliance on dependencies. But this case seemed to warrant a dependency. It would be a simple thing to implement. What I wanted was essentially a counter, after all. I expected to find a reliable crate with lots of downloads. + +Surprisingly, no such crate existed. I tried searching for "serial", "increment", "auto increment", "serial id", etc. and found nothing. One [crate][2] had a promising name but no documentation and, from what I could tell from the code, did not quite do what I was looking for. + +I decided to write and publish such a crate myself. It seemed like a good opportunity to contribute a small but useful crate with relatively simple code to the greater Rust ecosystem. + +## The Solution + +### Are Universally Unique IDs a Universal Solution? + +Serial IDs are used as identifiers because they are simple. Universally unique identifiers (or UUIDs), on the other hand, are usually used when a program needs to create identifiers without access to information about IDs created elsewhere. This is why a distributed database would use them. Rust developers have access to the [rand] crate and the [uuid] crate, which uses rand internally but creates IDs in formats that conform to formal standards. The basic strategy is to create a pseudo-random value or to capture a value (such as a UTC timestamp) and hash it. The details of UUID generation are too extensive to get into here. The uuid crate is both well-documented and cleanly coded, and there are countless other descriptions of the standards it implements. + +The important difference between serial IDs and UUIDs is the performance cost. Hashing and random number generation are more expensive than increasing the value of an integer by one. Even if threads have to wait to get exclusive access to an ID generator, each thread will only hold it for the number of nanoseconds that it takes to calculate "x + 1". Serial IDs are also free from the issue of collisions, allowing them to be smaller. A 32-bit serial ID can have 4,294,967,296 unique instances. There is no probability to consider. + +*UUIDs are always good enough, but their cost is not always necessary.* Git, for example, creates an SHA-1 has of a commit's content to create a commit ID. The IDs need to be unique when commits from different machines are pushed to a single remote. But many IDs do not need a high probability of being universally unique. CLI applications or other client-side programs that only work with in-memory values or store data locally are such examples. + +### How Has This Been Implemented Before? + +Serial IDs are familiar to users of database systems. In SQL, auto-increment integers can be used by setting `GENERATE BY DEFAULT AS IDENTITY` as the default value for a column. + +```sql +CREATE TABLE distributors ( + did integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + name varchar(40) NOT NULL CHECK (name <> '') +); +``` + +[Source][Postgres: CREATE TABLE] + +Postgres has its own `SERIAL` type that mimics this behavior. + +```sql +CREATE TABLE tablename ( + colname SERIAL +); +``` + +[Source][Postgres: SERIAL] + +Postgres provides a reasonable standard to replicate: 32-bit integers by default with support for any other unsigned integer. By using a trait to define the types that can be generated, users can make their own types compatible with the API provided by the crate. My crate just needs to provide the generator, the trait and implementations for unsigned integers in the standard library. + +## Goals + +- Small: The crate should be tiny. This is just a counter. +- Safe: IDs should be unique to each generator. There should be no panics. +- Compatible: Prefer primitives as the output values instead of a new type. +- Usable: Use feature flags to offer serde implementations, atomic primitives and other features + that may be useful to some users. + +## Takeaway + +This process proved something about Rust that wasn't readily apparent to me before. Encouraging flexibility by defining behavior (through traits) makes it intuitive to implement new features. When you know what you want to do the code starts to seem obvious. If you need a single type that can output many types, define the common behavior of those types and make the single type operate based on that shared behavior. + +Rust is often described as flexible, but many languages are flexible. With Rust, it feels like the API writes itself once you clearly define what it should do. The code is not just expressive, it's obvious. + +I also realized that there are still plenty of opportunities to contribute Rust code. Both widely-applicable and more niche libraries have provided the tools that developers need to adopt the language. But while Rust has become mature enough to fit many use cases, the ecosystem is far from bloated. There is still plenty of space to do new or different things and make an impact. + +The serial_int crate is on [crates.io][1] and [lib.rs][lib.rs]. Contributions and feedback on the API are welcome! + +[1]: https://crates.io/crates/serial_int + +[2]: https://crates.io/crates/increment + +[lib.rs]: https://lib.rs/crates/serial_int + +[Postgres: CREATE TABLE]: https://www.postgresql.org/docs/13/sql-createtable.html + +[Postgres: SERIAL]: https://www.postgresql.org/docs/13/datatype-numeric.html#DATATYPE-SERIAL + +[rand]: https://crates.io/crates/rand + +[uuid]: https://crates.io/crates/uuid diff --git a/src/cookbook.md b/src/cookbook.md new file mode 100644 index 0000000..9e1cfc2 --- /dev/null +++ b/src/cookbook.md @@ -0,0 +1,36 @@ +# Personal Cookbook + +## Convert file formats + +### Markdown to HTML + +Convert a file + +```sh +pandoc --to html --standalone file.md > file.html +``` + +Add local CSS + +```sh +pandoc --to html --css file.css --self-contained file.md > file.html +``` + +## Kitty + +### Desktop launchers + +The default group uses the main kitty.conf settings. Having the socket set prevents other windows +from using these settings by default. + +```.desktop +[Desktop Entry] +Version=1.0 +Type=Application +Name=kitty +GenericName=Terminal emulator +Comment=Fast, feature-rich, GPU based terminal +Exec=kitty --instance-group default --listen-on unix:@kitty +Icon=kitty +Categories=System;TerminalEmulator; +``` diff --git a/src/index.md b/src/index.md new file mode 100644 index 0000000..2fbdca6 --- /dev/null +++ b/src/index.md @@ -0,0 +1,14 @@ +I am a programmer in New York. This is my website. + + +- [Résumé](resume) +- Sites + - [jeffa.io](https://www.jeffa.io) + - [git.jeffa.io](https://git.jeffa.io) + - [Sourcehut](https://sr.ht/~jeffa) + - [Github](https://github.com/solaeus) +- Articles + - [It's Time to Rethink the Command Line Shell](its_time_to_rethink_the_command_line_shell) + - [An Auto-Increment Crate for Rust](an_auto_increment_crate_for_rust) + - [Rust Packages vs Crates](rust_packages_vs_crates) + diff --git a/src/its_time_to_rethink_the_command_line_shell.md b/src/its_time_to_rethink_the_command_line_shell.md new file mode 100644 index 0000000..dea1c8f --- /dev/null +++ b/src/its_time_to_rethink_the_command_line_shell.md @@ -0,0 +1,96 @@ +# It's Time to Rethink the Command Line Shell + +I recently wrote a command-line shell and programming language called [whale]. I did so for fun and because I wanted to work out some frustrations with life on the command line by trying to build solutions. The [nushell] project was a big inspiration and assistance (I borrowed their readline library) and they were probably the first to make me wish for structured data. + +## The String Problem + +Traditional shells (e.g. sh, bash, fish and zsh) differ in meaningful ways. For example, control flow, variable assignment, how they read startup scripts, etc. But fundamentally, they all represent data the same way: as text strings. This is a problem because, unless the user entered it, a string is rarely the most meaningful way to present data. + +String-based shells work well when you are evoking commands with flags and arguments because those are all user-entered strings. But when you fetch some JSON data from the web or export a database as CSV, you are suddenly left with an unwieldy wall of text that the shell can't handle on its own. This is not actually a design flaw, these shells were designed to solve problems related to text because they ran on systems that were entirely text-based. + +> All the core UNIX system tools were designed so that they could operate +> together. The original text-based editors (and even TeX and LaTeX) use ASCII +> (the American text encoding standard; an open standard) and you can use tools +> such as; sed, awk, vi, grep, cat, more, tr and various other text-based tools +> in conjunction with these editors. +> +> [The Unix Tools Philosophy] + +But structured data has come a long way since those shells were designed. Three of the most popular shells (sh, bash and zsh) are older than both JSON and CSV. The CSV format was formally defined in 2005, the year that fish was released. And JSON's formal definition did not come until 2017. These shells use a single ambiguous data type (the string) because it is universal and ad-hoc parsing through sed and awk was the best that could be done. + +## The Unix Tool Problem + +The whole point of a command-line shell, in my estimation, is to allow the user to pass data to and between single-purpose programs. The unix tool philosophy is to do one thing and do it well. One writes programs by processing input into output. This is a simple yet monumental computing and programming paradigm. + +Is it possible to disambiguate data on the command line by using more than just strings without violating this philosophy? In this paradigm, it may seem that the shell *should* only recognize data in the form of an ambiguous type like the string because the meaningful work is supposed to happen inside of programs, not between them. + +But a shell that implements structured data correctly *can* work within the Unix tools philosophy and can even compliment its features. + +## Solving a Problem with Strings + +Let's write a function in fish that will allow us to save a quick note. + +```fish +function new_note + set -l message $argv[1] + set -l time (date) + set -l note "$time | $message" + + note >> notes.txt +end + +new_note "buy milk" +new_note "take out garbage" +``` + +This will give us a file like this. + +```txt +Thu Aug 17 01:00:00 AM EDT 2023 | buy milk +Thu Aug 17 02:00:00 AM EDT 2023 | take out garbage +``` + +In order to process this file, we have to start by treating the entire thing as single string. Then a program like awk, sed or grep may divide it by line before searching and interpolating positional values. This will work as long as the strings are uniform enough to pose as structured data, but it is a feeble framework that can easily be compromised by something as simple as two programs parsing dates differently. + +## Solving the Same Problem with Structured Data + +Here's how the still-experimental shell [whale] handles the same job of defining a function that saves a note to a file. + +```whale +new_note = ' + note.message = input; + note.time = now(); + + notes = from_toml(read_file("notes.toml")); + notes:push(note); + + data = notes:to_toml(); + data:write("notes.toml"); +'; + +new_note("buy milk"); +new_note("take out garbage"); +``` + +Notice that we first declare variables with dot notation to group them. Then we read our existing notes from a TOML file into a variable. This variable is a **list**, a data structure that is a simple contiguous sequence. We can then call **push** on the list to add new item to the end. Finally, we write the file using our new list of notes. + +TOML, unlike JSON, has a time data type that makes it a good choice when saving dates and times. That solves the problem of different programs processing dates differently, `from_toml` and `to_toml`are the shell's universal means of conversion for this format. There are also conversion macros for CSV and JSON. These are infallible: they create only valid output and will convert any valid input. This improvement is virtually without downsides: any structured data can still be treated as a string and *structured data can be converted between formats*, which improves interoperability. + +```whale +toml_string = read_file("data.toml"); +parsed_data = from_toml(toml_string); +json_string = to_json(parsed_data); +``` + +## Writing Unix Tools for Structured Data + +Rust users are lucky to have [serde], a library that can create in-memory values from data formats and vica versa. If you are compiling a program to be used as a command-line tool, I would urge you to think about how you might use structured data as input and output. [Pandoc], for example, can use a YAML file as an alternative to command-line arguments but I would prefer it to have an additional option to pass that YAML to standard input. In the meantime, it is easy enough to implement pandoc's features in whale by translating a data structure into command line arguments. It is not a perfect solution but it goes a long way to prove that the commacnd line shell is still the gold standard of computing. + +If you are interested in structured data on the command line, [whale] is in its experimental phase and I'm happy to talk more about it. [Nushell] is a more mature example of a command-line shell with structured data and [jq] is an excellent resource for querying JSON. + +[Pandoc]: https://pandoc.org/ +[serde]: https://serde.rs/ +[nushell]: https://www.nushell.sh/ +[whale]: https://git.jeffa.io/jeff/whale +[jq]: https://jqlang.github.io/jq/ +[The Unix Tools Philosophy]: https://tldp.org/LDP/GNU-Linux-Tools-Summary/html/c1089.htm diff --git a/src/nav.md b/src/nav.md new file mode 100644 index 0000000..9e63b36 --- /dev/null +++ b/src/nav.md @@ -0,0 +1,5 @@ + +- [jeffa.io](/index) + - Articles + - [How To Code]() + - [/cookbook](/cookbook) \ No newline at end of file diff --git a/src/resume.md b/src/resume.md new file mode 100644 index 0000000..0103bd3 --- /dev/null +++ b/src/resume.md @@ -0,0 +1,43 @@ +# Jeff Anderson + +## About Me + +I was a butcher at Bread Garden Market in Iowa City, IA before moving to Manhattan in March of 2020. Since the beginning of the pandemic, I have sharpened the skills I learned at the Hack Reactor bootcamp by learning Typescript and Rust as well as dozens of other skills in programming and computing. + +- Strong communication and language skills +- Meticulous attention to detail +- Fastidious about preparation and planning +- Receptive to feedback and criticism + +## Projects + +### Dust + +Data-oriented programming language and interactive shell. + +- Implemented a powerful tool using the Rust programming language. +- Designed a separate parser and syntax highlighter for Dust using tree sitter. +- Worked extensively with the standard library and major community crates. + +### serial_int + +Auto-increment integer library for Rust. + +- Wrote a minimal library with tests and documentation. +- Interfaced with global open-source contributors. +- Reviewed and accepted pull requests from volunteers. + +### jeffa.io + +Self-hosted blog and point of contact. + +- Deployed reliably and automatically with Fedora CoreOS +- Published content updates by pushing to a personally-owned container registry +- Achieved top scores from Google PageSpeed Insights including perfect scores in performance and SEO. + +## Training + +- Hack Reactor: Graduated in the HRNYC16 cohort in 2018. + +- International Culinary Center: Graduated 2014 at California Campus. + diff --git a/src/rust_packages_vs_crates.md b/src/rust_packages_vs_crates.md new file mode 100644 index 0000000..776dcd7 --- /dev/null +++ b/src/rust_packages_vs_crates.md @@ -0,0 +1,57 @@ +--- +date: Nov 18th, 2021 +--- + +# Rust Packages vs Crates + +It is a common misconception that packages are crates and visa versa. I will +admit that this is a pet peeve. But for Rust coders, it is important to know the +difference because otherwise you are denied an understanding of how Rust code is +organized, shared and consumed. + +## Crate + +A **crate**, like a **module** inside of a crate, is a means of organizing code. + +A **crate** is either a binary or a library. + +A **crate** is not published independently, but rather as a member of its +**package**. + +The compiler knows what a **crate** is and uses **crates** as namespaces for +items. If `std::hash::Hash` is not in scope, you can define your own trait +called `Hash`. + +## Package + +A **package** is a wrapper for at least one crate. + +A **package** is publishable. + +A **package** can contain one or zero library **crates**. + +A **package** can contain any number of binary **crates**. + +When you add a **package** to your dependencies, you consume the one library +**crate** inside of that **package**. + +When you use **cargo run** or **cargo install** without specificying a +**crate**, you consume the one binary **crate** in that **package**. + +When you use **cargo run --bin** or **cargo install --bin** followed by a +**crate** name, you consume the specified binary **crate** in that **package**. + +## Why Is There Confusion About This? + +I think the reason people get mixed up is because [crates.io] is actually a +repository for packages. If you find a library on [crates.io], you add the +*package* to the dependencies in your Cargo.toml file. You don't have to +specificy the crate because a package can only have one library crate. If the +default repository were called "packages.io", this may be less of an issue. + +As always, The Book is there as an easy-to-read authority that clears up these +little confusions. + +[The Rust Programming Language: Packages and Crates](https://doc.rust-lang.org/stable/book/ch07-01-packages-and-crates.html) + +[crates.io]:https://crates.io diff --git a/src/visualizing_data_with_dust.md b/src/visualizing_data_with_dust.md new file mode 100644 index 0000000..9e97f03 --- /dev/null +++ b/src/visualizing_data_with_dust.md @@ -0,0 +1,48 @@ +# Visualizing Data with Dust + +Dust is an experimental data-oriented programming language with powerful tools for working with structured data from external sources. Despite being a command line tool, dust is able to spin up GUI windows with plots, charts and graphs. This is done through the language itself and dust is distributed with this cross-platform capability built in. So it works in the REPL, in scripts or in ad-hoc commands through your shell. + +To read more about dust, the project status and to learn the language, see the [README]. + +## Load Data + +Loading data into a dust program or environment can be done by downloading it or loading it from a file. Let's use an example that downloads some raw data and saves it so we don't have to keep fetching the it. + +``` +raw_data = download "faithful.csv"; +raw_data:write "faithful.csv"; +``` + +Now we can load the data any time we like by reading the file. + +``` +raw_data = read("faithful.csv"); +``` + +The second step is parsing the data into dust values. This is as easy as knowing the format of the data you just assigned to variable. In this case we'll use `from_csv` to turn `raw_data` into a table. The `from_json` and `from_toml` macros are also available as well as `to_csv`, etc. + +## Explore + +Printing the parsed data to the terminal will show that `from_csv` has created a table with three columns and 272 rows. Dust has pretty terminal output for tables but it's still a lot of scrolling and a wall of text does not make the data easy to understand. But if we want the data to fit in a bar graph, we will have to take an extra step to make it clear what the graph should display. + +## Transform + +The simplest bar graph just needs a list of values. The index of each value can provide the x axis position and a flaoting point value will provide the height. But that's not what were' working with. Our data is in a variable that is a table, not a list. If we want the rows of the table as list, we simple call `rows()`, which shows us the following output. + +As you can see, each row is represented as another list. We want to *transform* each list into just the second value, which is a floating point. + +``` +table = from_csv(read("faithful.csv")); +rows = table:rows(); +list = rows:transform('input.1'); +``` + +The resulting list looks like this. + +## Visualize + +Now each item in the list can represent a single bar on the graph at they will be arranged by their position in the list. Just one last step to spin up our GUI! + +``` +bar_graph(list) +``` diff --git a/templates/nav.html b/templates/nav.html new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/templates/nav.html @@ -0,0 +1 @@ + diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..dd93dc4 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,23 @@ + + + + + + + $if(date)$ + + $endif$ + + jeffa.io + + +

jeffa.io

+ $body$ +
+ + +