Repository: neurosnap/lists.sh
Branch: main
Commit: bc7f4fd43f42
Files: 63
Total size: 134.4 KB
Directory structure:
gitextract_evvli2ae/
├── .github/
│ └── workflows/
│ └── lint.yml
├── .gitignore
├── Caddyfile
├── Dockerfile
├── Dockerfile.caddy
├── LICENSE
├── Makefile
├── README.md
├── build/
│ └── .gitkeep
├── cmd/
│ ├── gemini/
│ │ └── main.go
│ ├── ssh/
│ │ └── main.go
│ └── web/
│ └── main.go
├── db/
│ ├── migrations/
│ │ ├── 20220310_init.sql
│ │ ├── 20220422_add_desc_to_user_and_post.sql
│ │ ├── 20220426_add_index_for_filename.sql
│ │ ├── 20220427_username_to_lower.sql
│ │ ├── 20220523_timestamp_with_tz.sql
│ │ ├── 20220721_analytics.sql
│ │ └── 20220722_post_hidden.sql
│ ├── setup.sql
│ └── teardown.sql
├── docker-compose.yml
├── gmi/
│ ├── base.layout.tmpl
│ ├── blog.page.tmpl
│ ├── footer.partial.tmpl
│ ├── help.page.tmpl
│ ├── list.partial.tmpl
│ ├── marketing-footer.partial.tmpl
│ ├── marketing.page.tmpl
│ ├── ops.page.tmpl
│ ├── post.page.tmpl
│ ├── privacy.page.tmpl
│ ├── read.page.tmpl
│ ├── rss.page.tmpl
│ ├── spec.page.tmpl
│ └── transparency.page.tmpl
├── go.mod
├── go.sum
├── html/
│ ├── base.layout.tmpl
│ ├── blog.page.tmpl
│ ├── footer.partial.tmpl
│ ├── help.page.tmpl
│ ├── list.partial.tmpl
│ ├── marketing-footer.partial.tmpl
│ ├── marketing.page.tmpl
│ ├── ops.page.tmpl
│ ├── post.page.tmpl
│ ├── privacy.page.tmpl
│ ├── read.page.tmpl
│ ├── rss.page.tmpl
│ ├── spec.page.tmpl
│ └── transparency.page.tmpl
├── internal/
│ ├── api.go
│ ├── config.go
│ ├── db_handler.go
│ ├── gemini/
│ │ ├── gemini.go
│ │ └── router.go
│ ├── router.go
│ └── util.go
├── pkg/
│ └── parser.go
├── production.yml
└── public/
├── main.css
└── robots.txt
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/lint.yml
================================================
name: Lint
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.18
- name: Lint the codebase
uses: golangci/golangci-lint-action@v2
with:
version: latest
args: -E goimports -E godot
================================================
FILE: .gitignore
================================================
*.log
*.swp
.env
.envrc
build/*
!build/.gitkeep
ssh_data/*
!ssh_data/.gitkeep
caddy_data/*
!caddy_data/.gitkeep
caddy_config/*
!caddy_config/.gitkeep
.env.prod
*.bak
================================================
FILE: Caddyfile
================================================
*.lists.sh, lists.sh {
reverse_proxy web:3000
tls webmaster@lists.sh
tls {
dns cloudflare {env.CF_API_TOKEN}
}
encode zstd gzip
header {
# disable FLoC tracking
Permissions-Policy interest-cohort=()
# enable HSTS
Strict-Transport-Security max-age=31536000;
# disable clients from sniffing the media type
X-Content-Type-Options nosniff
# clickjacking protection
X-Frame-Options DENY
# keep referrer data off of HTTP connections
Referrer-Policy no-referrer-when-downgrade
Content-Security-Policy "default-src 'self'; img-src * 'unsafe-inline'"
X-XSS-Protection "1; mode=block"
}
}
================================================
FILE: Dockerfile
================================================
FROM golang:1.18.1-alpine3.15 AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/ssh ./cmd/ssh
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/web ./cmd/web
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/gemini ./cmd/gemini
FROM alpine:3.15 AS ssh
WORKDIR /app
COPY --from=0 /app/build/ssh ./
CMD ["./ssh"]
FROM alpine:3.15 AS web
WORKDIR /app
COPY --from=0 /app/build/web ./
COPY --from=0 /app/html ./html
COPY --from=0 /app/public ./public
CMD ["./web"]
FROM alpine:3.15 AS gemini
WORKDIR /app
COPY --from=0 /app/build/gemini ./
COPY --from=0 /app/gmi ./gmi
ENV LISTS_SUBDOMAINS=0
CMD ["./gemini"]
================================================
FILE: Dockerfile.caddy
================================================
FROM caddy:builder-alpine AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare
FROM caddy:alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 Eric Bower
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
PGDATABASE?="lists"
PGHOST?="db"
PGUSER?="postgres"
PORT?="5432"
DB_CONTAINER?=listssh_db_1
DOCKER_TAG?=$(shell git log --format="%H" -n 1)
test:
docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -E goimports -E godot
.PHONY: test
build:
go build -o build/web ./cmd/web
go build -o build/ssh ./cmd/ssh
go build -o build/gemini ./cmd/gemini
.PHONY: build
format:
go fmt ./...
.PHONY: format
create:
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) < ./db/setup.sql
.PHONY: create
teardown:
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/teardown.sql
.PHONY: teardown
migrate:
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220310_init.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220422_add_desc_to_user_and_post.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220426_add_index_for_filename.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220427_username_to_lower.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220523_timestamp_with_tz.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220721_analytics.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.sql
.PHONY: migrate
latest:
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220721_analytics.sql
docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.sql
.PHONY: latest
psql:
docker exec -it $(DB_CONTAINER) psql -U $(PGUSER)
.PHONY: psql
dump:
docker exec -it $(DB_CONTAINER) pg_dump -U $(PGUSER) $(PGDATABASE) > ./backup.sql
.PHONY: dump
restore:
docker cp ./backup.sql $(DB_CONTAINER):/backup.sql
docker exec -it $(DB_CONTAINER) /bin/bash
# psql postgres -U postgres < /backup.sql
.PHONY: restore
bp-setup:
docker buildx ls | grep pico || docker buildx create --name pico
docker buildx use pico
.PHONY: bp-setup
bp-caddy: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-caddy:$(DOCKER_TAG) -f Dockerfile.caddy .
.PHONY: bp-caddy
bp-ssh: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-ssh:$(DOCKER_TAG) --target ssh .
.PHONY: bp-ssh
bp-web: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-web:$(DOCKER_TAG) --target web .
.PHONY: bp-web
bp-gemini: bp-setup
docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-gemini:$(DOCKER_TAG) --target gemini .
.PHONY: bp-gemini
bp: bp-ssh bp-web bp-gemini bp-caddy
.PHONY: bp
deploy:
docker system prune -f
docker-compose -f production.yml pull --ignore-pull-failures
docker-compose -f production.yml up --no-deps -d
.PHONY: deploy
================================================
FILE: README.md
================================================
# lists.sh
A microblog for lists.
## comms
- [website](https://pico.sh)
- [irc #pico.sh](irc://irc.libera.chat/#pico.sh)
- [mailing list](https://lists.sr.ht/~erock/pico.sh)
- [ticket tracker](https://todo.sr.ht/~erock/pico.sh)
- [email](mailto:hello@pico.sh)
## setup
- golang `v1.18`
You'll also need some environment variables
```
export POSTGRES_PASSWORD="secret"
export DATABASE_URL="postgresql://postgres:secret@db/lists?sslmode=disable"
export LISTS_SSH_PORT=2222
export LISTS_WEB_PORT=3000
export LISTS_DOMAIN="lists.sh"
export LISTS_EMAIL="support@lists.sh"
export LISTS_PROTOCOL="http"
```
I just use `direnv` which will load my `.env` file.
## development
### db
I use `docker-compose` to standup a postgresql server. If you already have a
server running you can skip this step.
Copy example `.env`
```bash
cp .env.example .env
```
Then run docker compose.
```bash
docker-compose up -d
```
Then create the database and migrate
```bash
make create
make migrate
```
### build the apps
```bash
make build
```
### run the apps
There are two apps: an ssh and web server.
```bash
./build/ssh
```
Default port for ssh server is `2222`.
```bash
./build/web
```
Default port for web server is `3000`.
### subdomains
Since we use subdomains for blogs, you'll need to update your `/etc/hosts` file
to accommodate.
```bash
# /etc/hosts
127.0.0.1 lists.test
127.0.0.1 erock.lists.test
```
Wildcards are not support in `/etc/hosts` so you'll have to add a subdomain for
each blog in development. For this example you'll also want to change the domain
env var to `LISTS_DOMAIN=lists.test`.
## deployment
I use `docker-compose` for deployment. First you need `.env.prod`.
```bash
cp .env.example .env.prod
```
The `production.yml` file in this repo uses my docker hub images for deployment.
```bash
docker-compose -f production.yml up -d
```
If you want to deploy using your own domain then you'll need to edit the
`Caddyfile` with your domain.
================================================
FILE: build/.gitkeep
================================================
================================================
FILE: cmd/gemini/main.go
================================================
package main
import "git.sr.ht/~erock/lists.sh/internal/gemini"
func main() {
gemini.StartServer()
}
================================================
FILE: cmd/ssh/main.go
================================================
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"git.sr.ht/~erock/lists.sh/internal"
"git.sr.ht/~erock/wish/cms"
"git.sr.ht/~erock/wish/cms/db/postgres"
"git.sr.ht/~erock/wish/proxy"
"git.sr.ht/~erock/wish/send/scp"
"git.sr.ht/~erock/wish/send/sftp"
"github.com/charmbracelet/wish"
bm "github.com/charmbracelet/wish/bubbletea"
lm "github.com/charmbracelet/wish/logging"
"github.com/gliderlabs/ssh"
)
type SSHServer struct{}
func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
return true
}
func createRouter(handler *internal.DbHandler) proxy.Router {
return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
cmd := s.Command()
mdw := []wish.Middleware{}
if len(cmd) == 0 {
mdw = append(mdw,
bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
lm.Middleware(),
)
} else if cmd[0] == "scp" {
mdw = append(mdw, scp.Middleware(handler))
}
return mdw
}
}
func withProxy(handler *internal.DbHandler) ssh.Option {
return func(server *ssh.Server) error {
err := sftp.SSHOption(handler)(server)
if err != nil {
return err
}
return proxy.WithProxy(createRouter(handler))(server)
}
}
func main() {
host := internal.GetEnv("PROSE_HOST", "0.0.0.0")
port := internal.GetEnv("PROSE_SSH_PORT", "2222")
cfg := internal.NewConfigSite()
logger := cfg.Logger
dbh := postgres.NewDB(&cfg.ConfigCms)
defer dbh.Close()
handler := internal.NewDbHandler(dbh, cfg)
sshServer := &SSHServer{}
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
wish.WithPublicKeyAuth(sshServer.authHandler),
withProxy(handler),
)
if err != nil {
logger.Fatal(err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
logger.Infof("Starting SSH server on %s:%s", host, port)
go func() {
if err = s.ListenAndServe(); err != nil {
logger.Fatal(err)
}
}()
<-done
logger.Info("Stopping SSH server")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer func() { cancel() }()
if err := s.Shutdown(ctx); err != nil {
logger.Fatal(err)
}
}
================================================
FILE: cmd/web/main.go
================================================
package main
import "git.sr.ht/~erock/lists.sh/internal"
func main() {
internal.StartApiServer()
}
================================================
FILE: db/migrations/20220310_init.sql
================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS app_users (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
name character varying(50),
created_at timestamp without time zone NOT NULL DEFAULT NOW(),
CONSTRAINT unique_name UNIQUE (name),
CONSTRAINT app_user_pkey PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS public_keys (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL,
public_key varchar(2048) NOT NULL,
created_at timestamp without time zone NOT NULL DEFAULT NOW(),
CONSTRAINT user_public_keys_pkey PRIMARY KEY (id),
CONSTRAINT unique_key_for_user UNIQUE (user_id, public_key),
CONSTRAINT fk_user_public_keys_owner
FOREIGN KEY(user_id)
REFERENCES app_users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS posts (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL,
title character varying(255) NOT NULL,
text text NOT NULL DEFAULT '',
publish_at timestamp without time zone NOT NULL DEFAULT NOW(),
created_at timestamp without time zone NOT NULL DEFAULT NOW(),
updated_at timestamp without time zone NOT NULL DEFAULT NOW(),
CONSTRAINT posts_pkey PRIMARY KEY (id),
CONSTRAINT unique_title_for_user UNIQUE (user_id, title),
CONSTRAINT fk_posts_app_users
FOREIGN KEY(user_id)
REFERENCES app_users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
================================================
FILE: db/migrations/20220422_add_desc_to_user_and_post.sql
================================================
ALTER TABLE app_users ADD COLUMN bio character varying(150) NOT NULL DEFAULT '';
ALTER TABLE posts ADD COLUMN description character varying(150) NOT NULL DEFAULT '';
ALTER TABLE posts ADD COLUMN filename character varying(255);
UPDATE posts SET filename = title;
ALTER TABLE posts ADD CONSTRAINT unique_filename_for_user UNIQUE (user_id, filename);
ALTER TABLE posts DROP CONSTRAINT unique_title_for_user;
================================================
FILE: db/migrations/20220426_add_index_for_filename.sql
================================================
CREATE INDEX posts_filename ON posts USING btree(filename);
ALTER TABLE app_users DROP COLUMN bio;
================================================
FILE: db/migrations/20220427_username_to_lower.sql
================================================
UPDATE app_users SET name = LOWER(name) WHERE name != LOWER(name);
================================================
FILE: db/migrations/20220523_timestamp_with_tz.sql
================================================
ALTER TABLE posts ALTER COLUMN updated_at TYPE timestamp WITH TIME ZONE USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE posts ALTER COLUMN publish_at TYPE timestamp WITH TIME ZONE USING publish_at AT TIME ZONE 'UTC';
ALTER TABLE posts ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';
ALTER TABLE app_users ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';
ALTER TABLE public_keys ALTER COLUMN created_at TYPE timestamp WITH TIME ZONE USING created_at AT TIME ZONE 'UTC';
================================================
FILE: db/migrations/20220721_analytics.sql
================================================
CREATE TABLE IF NOT EXISTS post_analytics (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
post_id uuid NOT NULL,
views int DEFAULT 0,
updated_at timestamp without time zone NOT NULL DEFAULT NOW(),
CONSTRAINT analytics_pkey PRIMARY KEY (id),
CONSTRAINT fk_analytics_posts
FOREIGN KEY(post_id)
REFERENCES posts(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
================================================
FILE: db/migrations/20220722_post_hidden.sql
================================================
ALTER TABLE posts ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE;
UPDATE posts SET hidden = TRUE WHERE filename LIKE E'\\_%';
================================================
FILE: db/setup.sql
================================================
CREATE DATABASE "lists" OWNER "postgres";
================================================
FILE: db/teardown.sql
================================================
DROP TABLE posts CASCADE;
DROP TABLE app_users CASCADE;
DROP TABLE public_keys CASCADE;
================================================
FILE: docker-compose.yml
================================================
version: "3.4"
services:
db:
image: postgres
restart: always
ports:
- "5433:5432"
env_file:
- .env
================================================
FILE: gmi/base.layout.tmpl
================================================
{{define "base"}}
{{template "body" .}}
{{end}}
================================================
FILE: gmi/blog.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# {{.Header.Title}}
{{.Header.Bio}}
{{range .Header.Nav}}
{{if .IsURL}}=> {{.URL}} {{.Value}}{{end}}
{{- end}}
=> {{.RSSURL}} rss
{{- if .Readme.HasItems}}
---
{{- template "list" .Readme -}}
{{- end}}
{{- range .Posts}}
=> {{.URL}} {{.Title}} ({{.UpdatedTimeAgo}})
{{- end}}
{{- template "footer" . -}}
{{end}}
================================================
FILE: gmi/footer.partial.tmpl
================================================
{{define "footer"}}
---
=> / published with {{.Site.Domain}}
{{end}}
================================================
FILE: gmi/help.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Need help?
Here are some common questions on using this platform that we would like to answer.
## I get a permission denied when trying to SSH
Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does not currently support access via new SSH RSA keys: only the old SHA-1 ones will work. Until we sort this out you’ll either need an SHA-1 RSA key or a key with another algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the following:
```
$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;
```
If you’re curious about the inner workings of this problem have a look at:
=> https://github.com/golang/go/issues/37278 golang/go#37278
=> https://go-review.googlesource.com/c/crypto/+/220037 go-review
=> https://github.com/golang/crypto/pull/197 golang/crypto#197
## Generating a new SSH key
=> https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent Github reference
```
ssh-keygen -t ed25519 -C "your_email@example.com"
```
* When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.
* At the prompt, type a secure passphrase.
## What should my blog folder look like?
Currently {{.Site.Domain}} only supports a flat folder structure. Therefore, `scp -r` is not permitted. We also only allow `.txt` files to be uploaded.
=> https://github.com/neurosnap/lists-blog Here is the source to my blog on this platform
Below is an example of what your blog folder should look like:
```
blog/
first-post.txt
second-post.txt
third-post.txt
```
Underscores and hyphens are permitted and will be automatically removed from the title of the list.
## How do I update a list?
Updating a list requires that you update the source document and then run the `scp` command again. If the filename remains the same, then the list will be updated.
## How do I delete a list?
Because `scp` does not natively support deleting files, I didn't want to bake that behavior into my ssh server.
However, if a user wants to delete a post they can delete the contents of the file and then upload it to our server. If the file contains 0 bytes, we will remove the post. For example, if you want to delete `delete.txt` you could:
```
cp /dev/null delete.txt
scp ./delete.txt {{.Site.Domain}}:/
```
Alternatively, you can go to `ssh @{{.Site.Domain}}` and select "Manage posts." Then you can highlight the post you want to delete and then press "X." It will ask for confirmation before actually removing the list.
## When I want to publish a new post, do I have to upload all posts everytime?
Nope! Just `scp` the file you want to publish. For example, if you created a new post called `taco-tuesday.txt` then you would publish it like this:
```
scp ./taco-tuesday.txt {{.Site.Domain}}:/
```
## How do I change my blog's name?
All you have to do is create a post titled `_header.txt` and add some information to the list.
```
=: title My new blog!
=: description My blog description!
=> https://xyz.com website
=> https://twitter.com/xyz twitter
```
* `title` will change your blog name
* `description` will add a blurb right under your blog name (and add meta descriptions)
* The links will show up next to the `rss` link to your blog
## How do I add an introduction to my blog?
All you have to do is create a post titled `_readme.txt` and add some information to the list.
```
=: list_type none
# Hi my name is Bob!
I like to sing. Dance. And I like to have fun fun fun!
```
Whatever is inside the `_readme` file will get rendered (as a list) right above your blog posts. Neat!
## What is my blog URL?
```
gemini://{{.Site.Domain}}/{username}
```
## How can I automatically publish my post?
There is a github action that we built to make it easy to publish your blog automatically.
=> https://github.com/marketplace/actions/scp-publish-action github marketplace
=> https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml example workflow
A user also created a systemd task to automatically publish new posts.
=> https://github.com/neurosnap/lists.sh/discussions/24 Check out this github discussion for more details.
## Can I create multiple accounts?
Yes! You can either a) create a new keypair and use that for authentication or b) use the same keypair and ssh into our CMS using our special username `ssh new@{{.Site.Domain}}`.
Please note that if you use the same keypair for multiple accounts, you will need to always specify the user when logging into our CMS.
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/list.partial.tmpl
================================================
{{define "list"}}
{{range .Items}}
{{- if .IsText}}
{{- if .Value}}
* {{.Value}}
{{- end}}
{{- else if .IsURL}}
=> {{.URL}} {{.Value}}
{{- else if .IsImg}}
=> {{.URL}} {{.Value}}
{{- else if .IsBlock}}
> {{.Value}}
{{- else if .IsHeaderOne}}
## {{.Value}}
{{- else if .IsHeaderTwo}}
### {{.Value}}
{{- else if .IsPre}}
```
{{.Value}}
```
{{- end}}
{{- end}}
{{end}}
================================================
FILE: gmi/marketing-footer.partial.tmpl
================================================
{{define "marketing-footer"}}
---
Built and maintained by pico.sh
=> https://pico.sh
=> / home
=> /spec spec
=> /ops ops
=> /help help
=> /rss rss
=> https://github.com/neurosnap/lists.sh source
{{end}}
================================================
FILE: gmi/marketing.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# {{.Site.Domain}}
A microblog for lists
=> /read discover some interesting lists
---
## Examples
=> /news official blog
=> https://git.sr.ht/~erock/lists-official-blog blog source
## Create your account
We don't want your email address.
To get started, simply ssh into our content management system:
```
ssh new@{{.Site.Domain}}
```
=> /help#permission-denied note: getting permission denied?
After that, just set a username and you're ready to start writing! When you SSH again, use your username that you set in the CMS.
## You control the source files
Create lists using your favorite editor in plain text files.
`~/blog/days-in-week.txt`
```
Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
```
## Publish your posts with one command
When your post is ready to be published, copy the file to our server with a familiar command:
```
scp ~/blog/*.txt {{.Site.Domain}}
```
We'll either create or update the lists for you.
## Terminal workflow without installation
Since we are leveraging tools you already have on your computer (`ssh` and `scp`), there is nothing to install. This provides the convenience of a web app, but from inside your terminal!
## Plain text format
A simple specification that is flexible and with no frills.
=> /spec specification
## Features
* Just lists
* Looks great on any device
* Bring your own editor
* You control the source files
* Terminal workflow with no installation
* Public-key based authentication
* No ads, zero tracking
* No platform lock-in
* No javascript
* Subscriptions via RSS
* Not a platform for todos
* Minimalist design
* 100% open source
## Philosophy
I love writing lists. I think restricting writing to a set of lists can really help improve clarity in thought. The goal of this blogging platform is to make it simple to use the tools you love to write and publish lists. There is no installation, signup is as easy as SSH'ing into our CMS, and publishing content is as easy as copying files to our server.
Another goal of this microblog platform is to satisfy my own needs. I like to write and share lists with people because I find it's one of the best way to disseminate knowledge. Whether it's a list of links or a list of paragraphs, writing in lists is very satisfying and I welcome you to explore it on this site!
Other blogging platforms support writing lists, but they don't emphasize them. Writing lists is pretty popular on Twitter, but discoverability is terrible. Other blogging platforms focus on prose, but there really is nothing out there catered specifically for lists ... until now.
## Roadmap
* Feature complete?
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/ops.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Operations
=> /privacy privacy
=> /transparency transparency
## Purpose
{{.Site.Domain}} exists to allow people to create and share their lists without the need to set up their own server or be part of a platform that shows ads or tracks its users.
## Ethics
We are committed to:
* No tracking of user or visitor behaviour.
* Never sell any user or visitor data.
* No ads — ever.
## Code of Content Publication
Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any combination of words and pixels except for: content of animosity or disparagement of an individual or a group on account of a group characteristic such as race, color, national origin, sex, disability, religion, or sexual orientation, which will be taken down immediately.
If one notices something along those lines in a blog please let us know at {{.Site.Email}}.
## Liability
The user expressly understands and agrees that Eric Bower, the operator of this website shall not be liable, in law or in equity, to them or to any third party for any direct, indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
## Account Terms
* The user is responsible for all content posted and all actions performed with their account.
* We reserve the right to disable or delete a user's account for any reason at any time. We have this clause because, statistically speaking, there will be people trying to do something nefarious.
## Service Availability
We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer service-level agreements but do take uptime seriously.
## Contact and Support
Email us at {{.Site.Email}} with any questions.
## Acknowledgments
{{.Site.Domain}} was inspired by Mataroa Blog[0] and Bear Blog[1].
=> https://mataroa.blog [0]mataroa blog
=> https://bearblog.dev [1]bearblog
{{.Site.Domain}} is built with many open source technologies.
In particular we would like to thank:
=> https://charm.sh The charm community
=> https://go.dev The golang community
=> https://www.postgresql.org The postgresql community
=> https://github.com/caddyserver/caddy The caddy community
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/post.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# {{.Title}}
{{.PublishAt}}
{{if .Description}}{{.Description}}{{end}}
=> {{.BlogURL}} on {{.BlogName}}
---
{{- template "list" . -}}
{{- template "footer" . -}}
{{end}}
================================================
FILE: gmi/privacy.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Privacy
Details on our privacy and security approach.
## Account Data
In order to have a functional account at {{.Site.Domain}}, we need to store your public key. That is the only piece of information we record for a user.
Because we use public-key cryptography, our security posture is a battle-tested and proven technique for authentication.
## Third parties
We have a strong commitment to never share any user data with any third-parties.
## Service Providers
We host our server on digital ocean [0]
=> https://digitalocean.com [0]
## Cookies
We do not use any cookies, not even account authentication.
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/read.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# read
recently updated lists
{{if .NextPage}}=> {{.NextPage}} next{{end}}
{{if .PrevPage}}=> {{.PrevPage}} prev{{end}}
{{range .Posts}}
=> {{.URL}} {{.UpdatedTimeAgo}}{{.Padding}} {{.Title}} ({{.Username}})
{{- end}}
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/rss.page.tmpl
================================================
{{template "list" .}}
================================================
FILE: gmi/spec.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Plain text list
Speculative specification
## Overview
Version: 2022.05.02.dev
Status: Draft
Author: Eric Bower
The goal of this specification is to understand how we render plain text lists. The overall design of this format is to be easy to parse and render.
The format is line-oriented, and a satisfactory rendering can be achieved with a single pass of a document, processing each line independently. As per gopher, links can only be displayed one per line, encouraging neat, list-like structure.
Feedback on any part of this is extremely welcome, please email {{.Site.Email}}.
The source code for our parser can be found on github[0].
=> https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go [0]github
The source code for an example list demonstrating all the features can be found on github[1].
=> https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt [1]lists-official-blog
## Parameters
As a subtype of the top-level media type "text", "text/plain" inherits the "charset" parameter defined in RFC 2046[2]. The default value of "charset" is "UTF-8" for "text" content.
=> https://datatracker.ietf.org/doc/html/rfc2046#section-4.1 [2]rfc 2046
## Line orientation
As mentioned, the text format is line-oriented. Each line of a document has a single "line type". It is possible to unambiguously determine a line's type purely by inspecting its first (3) characters. A line's type determines the manner in which it should be presented to the user. Any details of presentation or rendering associated with a particular line type are strictly limited in scope to that individual line.
## File extensions
{{.Site.Domain}} only supports the `.txt` file extension and will ignore all other file extensions.
## List item
List items are separated by newline characters `\n`. Each list item is on its own line. A list item does not require any special formatting. A list item can contain as much text as it wants. We encourage soft wrapping for readability in your editor of choice. Hard wrapping is not permitted as it will create a new list item.
Empty lines will be completely removed and not rendered to the end user.
## Hyperlinks
Hyperlinks are denoted by the prefix `=>`. The following text should then be the hyperlink.
```
=> https://{{.Site.Domain}}
```
Optionally you can supply the hyperlink text immediately following the link.
```
=> https://{{.Site.Domain}} microblog for lists
```
## Images
List items can be represented as images by prefixing the line with =<.
```
=< https://i.imgur.com/iXMNUN5.jpg
```
Optionally you can supply the image alt text immediately following the link.
```
=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw
```
## Headers
List items can be represented as headers. We support two headers currently. Headers will end the previous list and then create a new one after it. This allows a single document to contain multiple lists.
```
# Header One
## Header Two
```
## Blockquotes
List items can be represented as blockquotes.
```
> This is a blockquote.
```
## Preformatted
List items can be represented as preformatted text where newline characters are not considered part of new list items. They can be represented by prefixing the line with ```.
```
#!/usr/bin/env bash
set -x
echo "this is a preformatted list item!
```
You must also close the preformatted text with another ``` on its own line. The next example with NOT work.
## Variables
Variables allow us to store metadata within our system. Variables are list items with key value pairs denoted by `=:` followed by the key, a whitespace character, and then the value.
```
=: publish_at 2022-04-20
```
These variables will not be rendered to the user inside the list.
### List of available variables:
* `title` (custom title not dependent on filename)
* `description` (what is the purpose of this list?)
* `publish_at` (format must be `YYYY-MM-DD`)
* `list_type` (customize bullets; value gets sent directly to css property list-style-type[3])
=> https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type [3]list-style-type
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: gmi/transparency.page.tmpl
================================================
{{template "base" .}}
{{define "body"}}
# Transparency
## Analytics
Here are some interesting stats on usage.
Total users:
{{.Analytics.TotalUsers}}
New users in the last month:
{{.Analytics.UsersLastMonth}}
Total posts:
{{.Analytics.TotalPosts}}
New posts in the last month:
{{.Analytics.PostsLastMonth}}
Users with at least one post:
{{.Analytics.UsersWithPost}}
Service maintenance costs:
* Server $5.00/mo
* Domain name $3.25/mo
* Programmer $0.00/mo
{{template "marketing-footer" .}}
{{end}}
================================================
FILE: go.mod
================================================
module git.sr.ht/~erock/lists.sh
go 1.18
// replace git.sr.ht/~erock/wish => /home/erock/pico/wish
require (
git.sr.ht/~adnano/go-gemini v0.2.3
git.sr.ht/~aw/gorilla-feeds v1.1.4
git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120
github.com/charmbracelet/wish v0.5.0
github.com/gliderlabs/ssh v0.3.4
github.com/gorilla/feeds v1.1.1
go.uber.org/zap v1.21.0
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/caarlos0/sshmarshal v0.1.0 // indirect
github.com/charmbracelet/bubbles v0.12.0 // indirect
github.com/charmbracelet/bubbletea v0.22.0 // indirect
github.com/charmbracelet/keygen v0.3.0 // indirect
github.com/charmbracelet/lipgloss v0.5.0 // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.10.6 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.12.0 // indirect
github.com/pkg/sftp v1.13.5 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
golang.org/x/sys v0.0.0-20220702020025-31831981b65f // indirect
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
================================================
FILE: go.sum
================================================
git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=
git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
git.sr.ht/~aw/gorilla-feeds v1.1.4 h1:bL78pZ1DtHEhumHK0iWQi30uwEkWtetMfnyt9TFcdlc=
git.sr.ht/~aw/gorilla-feeds v1.1.4/go.mod h1:VLpbtNDEWoaJKU41Crj6r3ChvlqYvBm56c0O6IM457g=
git.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292 h1:KnP4IH79pVSf+yw8qe59KlzhOG9H+qbTMlXpFcDXopw=
git.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120 h1:9O4PKFF8JGvK9g3aVHr2wgozHK0s6BaVISPRl8MAovs=
git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=
github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
github.com/charmbracelet/bubbles v0.12.0 h1:fxb9U9yI60Hek3tcPmMTFya5NhvPrqpkpyMaNngFh7A=
github.com/charmbracelet/bubbles v0.12.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg=
github.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw=
github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go=
github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220702020025-31831981b65f h1:xdsejrW/0Wf2diT5CPp3XmKUNbr7Xvw8kYilQ+6qjRY=
golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: html/base.layout.tmpl
================================================
{{define "base"}}
{{template "title" .}}
{{template "meta" .}}
{{template "body" .}}
{{end}}
================================================
FILE: html/blog.page.tmpl
================================================
{{template "base" .}}
{{define "title"}}{{.PageTitle}}{{end}}
{{define "meta"}}
{{if .Header.Bio}}{{end}}
{{if .Header.Bio}}{{end}}
{{end}}
{{define "body"}}
Unfortunately SHA-2 RSA keys are not currently supported.
Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does
not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.
Until we sort this out you’ll either need an SHA-1 RSA key or a key with another
algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the
following:
Updating a list requires that you update the source document and then run the scp
command again. If the filename remains the same, then the list will be updated.
Because scp does not natively support deleting files, I didn't want to bake
that behavior into my ssh server.
However, if a user wants to delete a post they can delete the contents of the file and
then upload it to our server. If the file contains 0 bytes, we will remove the post.
For example, if you want to delete delete.txt you could:
Alternatively, you can go to ssh {{.Site.Domain}} and select "Manage posts."
Then you can highlight the post you want to delete and then press "X." It will ask for
confirmation before actually removing the list.
#
When I want to publish a new post, do I have to upload all posts everytime?
Nope! Just scp the file you want to publish. For example, if you created
a new post called taco-tuesday.txt then you would publish it like this:
Yes! You can either a) create a new keypair and use that for authentication
or b) use the same keypair and ssh into our CMS using our special username
ssh new@{{.Site.Domain}}.
Please note that if you use the same keypair for multiple accounts, you will need to
always specify the user when logging into our CMS.
I love writing lists. I think restricting writing to a set of lists can really
help improve clarity in thought. The goal of this blogging platform is to make it
simple to use the tools you love to write and publish lists. There is no installation,
signup is as easy as SSH'ing into our CMS, and publishing content is as easy as
copying files to our server.
Another goal of this microblog platform is to satisfy my own needs. I like to
write and share lists with people because I find it's one of the best way to disseminate
knowledge. Whether it's a list of links or a list of paragraphs, writing in lists is
very satisfying and I welcome you to explore it on this site!
Other blogging platforms support writing lists, but they don't
emphasize them. Writing lists is pretty popular
on Twitter, but discoverability is terrible. Other blogging platforms focus on prose,
but there really is nothing out there catered specifically for lists ... until now.
{{.Site.Domain}} exists to allow people to create and share their lists
without the need to set up their own server or be part of a platform
that shows ads or tracks its users.
Ethics
We are committed to:
No browser-based tracking of visitor behavior.
No attempt to identify users.
Never sell any user or visitor data.
No ads — ever.
Code of Content Publication
Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any
combination of words and pixels except for: content of animosity or disparagement of an
individual or a group on account of a group characteristic such as race, color, national
origin, sex, disability, religion, or sexual orientation, which will be taken down
immediately.
If one notices something along those lines in a blog please let us know at
{{.Site.Email}}.
Liability
The user expressly understands and agrees that Eric Bower, the operator of this website
shall not be liable, in law or in equity, to them or to any third party for any direct,
indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
Analytics
We are committed to zero browser-based tracking or trying to identify visitors. This
means we do not try to understand the user based on cookies or IP address. We do not
store personally identifiable information.
However, in order to provide a better service, we do have some analytics on posts.
List of metrics we track for posts:
anonymous view counts
We might also inspect the headers of HTTP requests to determine some tertiary information
about the request. For example we might inspect the User-Agent or
Referer to filter out requests from bots.
Account Terms
The user is responsible for all content posted and all actions performed with
their account.
We reserve the right to disable or delete a user's account for any reason at
any time. We have this clause because, statistically speaking, there will be
people trying to do something nefarious.
Service Availability
We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer
service-level agreements but do take uptime seriously.
In order to have a functional account at {{.Site.Domain}}, we need to store
your public key. That is the only piece of information we record for a user.
Because we use public-key cryptography, our security posture is a battle-tested
and proven technique for authentication.
Third parties
We have a strong commitment to never share any user data with any third-parties.
The goal of this specification is to understand how we render plain text lists.
The overall design of this format is to be easy to parse and render.
The format is line-oriented, and a satisfactory rendering can be achieved with a single
pass of a document, processing each line independently. As per gopher, links can only be
displayed one per line, encouraging neat, list-like structure.
Feedback on any part of this is extremely welcome, please email
{{.Site.Email}}.
The source code for an example list demonstrating all the features can be found
here.
As a subtype of the top-level media type "text", "text/plain" inherits the "charset"
parameter defined in RFC 2046.
The default value of "charset" is "UTF-8" for "text" content.
As mentioned, the text format is line-oriented. Each line of a document has a single
"line type". It is possible to unambiguously determine a line's type purely by
inspecting its first (3) characters. A line's type determines the manner in which it
should be presented to the user. Any details of presentation or rendering associated
with a particular line type are strictly limited in scope to that individual line.
File extension
{{.Site.Domain}} only supports the .txt file extension and will
ignore all other file extensions.
List item
List items are separated by newline characters \n.
Each list item is on its own line. A list item does not require any special formatting.
A list item can contain as much text as it wants. We encourage soft wrapping for readability
in your editor of choice. Hard wrapping is not permitted as it will create a new list item.
Empty lines will be completely removed and not rendered to the end user.
Hyperlinks
Hyperlinks are denoted by the prefix =>. The following text should then be
the hyperlink.
=> https://{{.Site.Domain}}
Optionally you can supply the hyperlink text immediately following the link.
=> https://{{.Site.Domain}} microblog for lists
Images
List items can be represented as images by prefixing the line with =<.
=< https://i.imgur.com/iXMNUN5.jpg
Optionally you can supply the image alt text immediately following the link.
=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw
Headers
List items can be represented as headers. We support two headers currently. Headers
will end the previous list and then create a new one after it. This allows a single
document to contain multiple lists.
# Header One
## Header Two
Blockquotes
List items can be represented as blockquotes.
> This is a blockquote.
Preformatted
List items can be represented as preformatted text where newline characters are not
considered part of new list items. They can be represented by prefixing the line with
```.
```
#!/usr/bin/env bash
set -x
echo "this is a preformatted list item!
```
You must also close the preformatted text with another ``` on its own line. The
next example with NOT work.
```
#!/usr/bin/env bash
echo "This will not render properly"```
Variables
Variables allow us to store metadata within our system. Variables are list items with
key value pairs denoted by =: followed by the key, a whitespace character,
and then the value.
=: publish_at 2022-04-20
These variables will not be rendered to the user inside the list.
List of available variables:
title (custom title not dependent on filename)
description (what is the purpose of this list?)
publish_at (format must be YYYY-MM-DD)
list_type (customize bullets; value gets sent directly to css property
list-style-type)