Skip to content

Repository files navigation

Texttile

A blog for two people who write together. Phoenix, LiveView and SQLite in one Docker image. One volume holds everything: the database, the pictures, the videos. No external service, no tracker, no account anywhere else.

Two people can have the same entry open. One writes the text while the other watches it arrive, moves tiles in the gallery, sets the tags, and takes over the writing with one click.

It is meant to stay small. A part of this blog is right when nothing is left to take away.

Status: ready for a real site. Run a version tag, not latest, and keep a copy of /data.

What you get

  • One container, your data. SQLite and the uploads in /data. Move that directory and you moved the blog.
  • Two writers, one entry. Only the body text is locked, and softly. Tiles, tags and settings stay open for both.
  • Your Markdown, byte for byte. What you typed is what is stored, so a version diff shows real edits and nothing else.
  • Pictures and videos on your own server. Scaled and converted here, played from here. No external player.
  • Nothing loaded from outside. No CDN, no tracker, no captcha, no hosted font. A reader's browser talks to your server and to nothing else.
  • Your own statistics. Counted here, stored here, with no cookie and no stored IP address.
  • Comments and a newsletter, both with built-in spam protection.
  • Light on a slow line. Small pages, little JavaScript, laid out for a phone first.
  • A backup that pulls. A machine at home fetches the whole installation; the server holds nothing that reaches your copies.
  • English and German, chosen in Settings, all the way into the mail.

Contents

Get it running

docker run -d --name texttile \
  -p 4000:4000 \
  -v texttile-data:/data \
  -e SECRET_KEY_BASE="$(openssl rand -base64 48)" \
  -e PHX_HOST=blog.example.com \
  -e [email protected],[email protected] \
  ghcr.io/texttile-blog/texttile:3

The container prepares the data directories, runs the migrations, drops root and starts the server. All state lives in /data. ADMIN_USERS holds the email addresses that get an account, so put yours there before the first start.

At the start the server makes an account for every address in it that has none, and mails each one a link that sets its password. Open the link, choose a password, and you are in. While no account has a password, the server also writes the link into its own log (docker logs texttile), so a site whose mail does not work yet still lets its first admin in. See Accounts.

The image is linux/amd64. An update is a pull and a restart:

docker pull ghcr.io/texttile-blog/texttile:3
docker rm -f texttile
# then the docker run above again

Which tag to run

Every build carries the version from mix.exs, and each build gets five tags. For version 3.0.1 they are:

Tag Points at
3.0.1 that one build, and never moves again
3.0 the newest repair of 3.0
3 the newest build of 3
latest the newest build there is
<sha> the commit the build came from

3 and 3.0 are not version ranges. The registry resolves no version logic: a later build with a fitting number takes the tag over, which is what these two tags are for. Run 3 to get repairs and new capabilities without a break, 3.0 to get repairs only, and 3.0.1 where nothing may move until you move it.

A build is published only when the version rises, and only after CI passes. A merge that keeps the version of the build before it publishes nothing and reports a failure. The publish starts when the CI run of the same commit reports success, and a red run publishes nothing. An exact tag never moves, so a version that carried a broken build stays broken: raise the version in mix.exs and merge the repair.

Each published build also gets a GitHub release. The release tags the commit the image was built from as v<version>, shows the pull command, and lists the pull requests since the release before.

Configuration

Texttile reads its configuration from environment variables at start. A missing variable stops the app at boot with a message that names it.

Variable Required in prod Default Purpose
DATABASE_PATH yes /data/texttile.db (image) SQLite database file
UPLOADS_PATH yes /data/uploads (image) directory for uploaded files
ADMIN_USERS first start none addresses that get an account, separated by commas
SECRET_KEY_BASE yes none signs cookies; generate with mix phx.gen.secret
PHX_HOST yes example.com public hostname
PORT no 4000 HTTP port
MAIL_ADAPTER no local preview mailbox resend, postmark, brevo, smtp, or ses
MAIL_FROM no texttile@PHX_HOST sender address for outgoing mail
CLIENT_IP_HEADER no none, the socket address header a trusted proxy writes, e.g. fly-client-ip

Mail

Each adapter loads exactly the credentials it needs. Without MAIL_ADAPTER, mail goes to a preview mailbox (/dev/mailbox, development only).

MAIL_ADAPTER Variables
resend RESEND_API_KEY
postmark POSTMARK_API_KEY
brevo BREVO_API_KEY
smtp SMTP_HOST, SMTP_USERNAME, SMTP_PASSWORD, SMTP_PORT (default 587, STARTTLS)
ses AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY

Behind a proxy

Set CLIENT_IP_HEADER only when a proxy stands in front of Texttile and writes that header itself. The comment rate limit and the reader count both read it, so a header the caller may write is a header a spammer may change. Without the variable both count by the address of the connection, which is right everywhere except behind a proxy. Of a header that carries a list (X-Forwarded-For), the last entry counts: that is the one the proxy appended, and everything before it is what the caller sent.

The site readers get

Readers get the list of published entries at /blog, each entry at its own address, and every published page in the menu. A post lives under the day it went live, /2026/08/23/harbor-mornings; a page lives at its slug alone, /about-us. Admins sign in at /login and work at /admin, which sends them on to the entries at /admin/texts.

The root, /, is the front door. It sends the reader to /blog, or it shows the one page you picked in Settings > Front page. The list keeps /blog either way, so a bookmark of it stays good when you change your mind.

The list shows ten entries a page and then a pager. Change the number in Settings

Front page. Over the list stands the archive: one line of years, and the months of the open year under it, only the months that carry something. Both are addresses (/blog?y=2026&m=8), so a year or a month can be copied and bookmarked. Under an entry stand the way to the entry before it and the one after it, and the About block from Settings > About.

An entry that is not live yet answers at the address it will wear, but only for somebody signed in; a reader gets a 404 there. So the way out of the editor leads into the real reader's page, drafts included.

The feed

The blog has an RSS feed at /feed.xml. It carries every published post, newest first, with the whole entry. Each page points at it from its head, and the footer carries an RSS link.

One password for the whole blog

The blog can sit behind one shared password (Settings > Access). It is an access word to hand around, not a login: it is stored in plain text, one word opens the whole blog, and it guards the blog or nothing. No entry has a switch of its own.

While the blog is protected, the editor of every entry shows the word under Share, so you never have to go and look it up. Once an entry is live, the same block holds the lines to hand on: the title, the address, and the password under it. Copy puts them on the clipboard.

A blog behind the shared password has no feed: /feed.xml answers 404 and no page offers a link. A feed reader cannot enter a password, and the entries would travel out of the gate. Remove the password to get the feed back.

Writing

A live entry keeps two texts. The one in the editor is the working copy; the one the readers get is the version you last published. Typing in a live entry moves the working copy alone, so a half-written sentence never reaches the site.

The bar says which of the two is which. As soon as the working copy runs ahead, the state word turns to Live · draft and the button beside it turns to Publish changes. That button hands the text over: the same day, the same address, the same state, and no mail, because the subscriber mail belongs to the entry going out and not to a correction inside it. The menu next to it also offers Discard the changes, which puts the published text back and keeps what you threw away as a version.

Only the title and the body split in two. Tags, the address, the tiles and the switches have one state and go live as you change them, which is what a version has always held.

Signed in, the entry's public address shows the working copy with a strip over it that says so. Signed out, it shows what was published. So does the list, the tag archive, the search field, the feed and the subscriber mail.

The entries overview marks a live entry whose working copy has run ahead with edited since publishing, and the Versions tab marks the version the readers have with live.

Changing the slug or the publish date of a live entry moves it, because the address of a post carries its date. The old address stays alive as a permanent redirect, and the editor lists what is standing under the address field, each with a Delete for the day you want it gone.

Pictures and videos

Pictures and videos go into an entry the same way: paste one into the body, drop one on it, or add it to the entry's gallery. Every original file is kept as it came, below UPLOADS_PATH, and nothing leaves the server: no external player, no third-party host.

One roof holds for a picture and for a video: Settings > Storage > Biggest upload, 512 MB by default. The browser turns a bigger file away before the upload starts, and the server stops reading one that arrives anyway. Settings > Storage also reports what lies in each folder of the volume, how much it weighs, and how much room is left.

A picture is scaled to display sizes on demand, capped by Settings > Images > Max longer edge. A video is converted once, by ffmpeg, into one MP4 (H.264 and AAC) that every browser plays, plus a poster frame. Settings > Videos > Max longer edge caps it, 1280 px by default; nothing is ever scaled up. A new value applies to what is converted after the change, because a converted video is never made again.

The conversion is the most expensive thing this server does, so it stays out of everybody's way: one video at a time, ffmpeg on one thread, at the lowest scheduling priority, and with idle disk priority where the kernel offers it. While a video converts, the admin area shows the state on its tile and under the entry; the reader's page shows the video once it is ready.

The upload takes .mp4, .mov, .m4v, .webm, .avi and .mkv, and the server reads which container a file really holds before ffmpeg opens it. A name is not a promise, and a file that names other files never reaches the converter. ffmpeg runs with the local disk as its only source.

The container brings ffmpeg. On a development machine, make tools installs it.

Language

A blog has one language, and Settings > Site is where you pick it. English and German are shipped. The choice reaches everything a person reads: the reader pages, the admin area, the dates, and every mail the blog sends. It also sets lang on every page and <language> in the feed.

What you write yourself is not touched. Titles, entries, tags, the About text and the site description stay in the language you wrote them in; only the words around them change.

English is the language of the source, so it has no translation file: a sentence nobody translated is shown in English. Each other language is exactly one file, priv/gettext/<code>/LC_MESSAGES/default.po, and it holds all of them, the error messages and the sentences the browser says included.

To add a language:

# 1. add {"fr", "Français"} to @languages in lib/texttile/i18n.ex
# 2. write the file
mix gettext.merge priv/gettext --locale fr
# 3. translate every msgstr in priv/gettext/fr/LC_MESSAGES/default.po
mix test test/texttile/translations_test.exs   # names every gap

Nothing else changes: the settings menu reads the list, and the new language is there.

Comments

Readers can comment under every entry that allows it (the switch is in the entry's settings). There is no approval queue. By default a new address gets one confirmation mail. The comment appears when its reader follows the mailed link, and every later comment from that address appears at once. Turn the confirmation off in Settings > Comments and no comment waits for anything.

A reader can give the address of their own site with the comment. The name over the comment then leads there, with nofollow, and a bare christel.example is enough.

Comment while signed in and the form fills itself: the name, the address and the website come from your account and this blog, the three fields take no typing, and the comment stands under the entry at once. The sign-in already proved the address, so no confirmation mail goes out. The comment counts show under each card, on /blog and on /admin.

Admins see all comments at /admin/comments and on the Comments tab of each text. There they can do three things to a comment:

  • Release a comment whose reader has not confirmed the address. It puts that one comment under the entry. The address itself stays unconfirmed, so the next comment from it waits again.
  • Edit the words. The name and the address stay as the reader sent them, and the comment is marked "edited" in the admin area.
  • Delete it into the trash. Readers stop seeing it at once, and it waits 30 days at the foot of /admin/comments, where Restore puts it back. After 30 days the comment is removed from the database for good.

Every admin also gets the comment by mail, unless "Mail me every new comment" is switched off in Settings > Comments. The mail leaves when the comment stands under the entry, so a comment that still waits for its reader mails nobody. The address of the reader is never in it.

Spam protection is built in and always on: a honeypot field, a time trap, and a rate limit per caller. No captcha, no Google, no third parties.

Newsletter

Readers can put their address on the newsletter list through the form in the footer of every page. The address gets one confirmation mail and receives entries only after its reader follows the mailed link. Admins see the list at /admin/newsletter and can add addresses by hand; an added address is confirmed at once.

When a post goes live with "Email subscribers" checked (the switch is in the entry's settings), every confirmed address gets one plain email: the title, the first paragraph, and the address the entry lives at. If the blog sits behind the shared password, the mail includes it. Every mail carries the way off the list. The email goes out once per entry; publishing the same entry again does not send it again.

The subscribe form wears the same spam protection as the comment form.

Stats

Texttile counts its own readers at /admin/stats, and each entry again on its Stats tab in the editor. Nothing is loaded from anywhere else and nothing is sent anywhere else.

Every reader page reports itself to this server with one small request. It carries three things: the address of the page, the entry it shows, and the address the reader came from. No cookie is set, none is read, and none is sent with that request.

The server turns the caller into one number for the day: their IP address and their browser line, hashed together with a secret this server draws every morning and keeps in memory only. The secret is never written to disk and is gone at midnight, so no row can be traced back to an address, and nothing links the same reader to two days. That is what "people" counts: a reader who comes back next week counts as one person each time. The IP address itself is never stored.

Four rules keep the numbers about people, not machines:

  • The report needs JavaScript, so almost everything that crawls the web never reports at all.
  • A browser line that says bot is dropped, and so is a page the browser only fetched ahead.
  • One reader counts once per page every half hour, so reloading changes nothing.
  • One caller writes at most 60 views a minute.

An admin reading their own blog is not a reader: while you are signed in, no page reports itself. Drafts, scheduled entries and previews count for nobody.

The numbers live in the page_views table in your database, one row per counted view. They are yours; nothing leaves the server.

The referrer and the address of a page come from the browser, so the tables of sources and of other addresses hold the 20 biggest and say so when they are full. The top entries table counts entries, which the blog knows itself, so it holds 20 without a word.

If a proxy stands in front of Texttile, set CLIENT_IP_HEADER (see Configuration). Without it every reader behind the proxy shares one address, and the counter reads them as one person.

Accounts

You sign in with your email address. There is no username: an account is an address, a password, and the name readers see under the entries.

The accounts are the guest list. Nothing else says who may sign in, and there is no registration, so there is nothing to guess.

An account comes into being in one of two ways, and both end in a mailed link that sets its first password.

At the start of the server. ADMIN_USERS holds the addresses that get an account, separated by commas:

Every address in it that never had an account here gets one at the start, and a link. The link works one time, and for a week. This is how the first admin of a new installation gets in: nobody is inside yet who could invite them, and there is no window in which a stranger could take the account instead.

Adding an address later works too. Set the variable, restart, and the new address gets its link.

An address that has an account here is never given a second one, so an address whose owner moved to another one does not come back as a stranger's way in.

A deleted account frees its address again, in this variable too: while the address stands here, the next start invites it once more, as a new account. Taking an address out of the variable takes nothing away by itself. Both halves of that are in Taking access away.

From Settings. In Settings > Users, type an address into Invite an admin and the same link goes out. No deploy, no restart. The account appears in the list at once and says it is waiting for its first password. Until the link is used it cannot sign in, Send the link again replaces the link, and Delete takes the account back.

Both ways prove the same thing: whoever reads that inbox is who the account belongs to. Nothing is handed over by hand.

When the mail does not go out

While no account here has a password, the server writes every link it mails into its own log as well:

docker logs texttile

That is the way into a fresh installation whose MAIL_ADAPTER is missing or wrong. Restarting the server mints a fresh link and writes it again. The moment one account has a password, the log line stops.

Taking access away

Delete the account in Settings > Users. It cannot sign in from that moment, every session open right now ends, and it is out of the list. Nobody can delete their own account, and the last account cannot go.

What the person wrote stays, and keeps their name. The entries read exactly as before: the byline under an entry is the name that account carries, and a reader is told nothing about accounts at all. In the admin area the name says the account is gone: Julia (deleted) in the author list of an entry, in the entries list and in the version list. The account itself is in no list of accounts any more.

The address is free again at once. Invite it from Settings and it gets a new account, with its own entries from then on; the old one keeps what it wrote. The same goes for ADMIN_USERS: while the address stands in that variable, the next start of the server invites it by itself. Taking access away from somebody named in ADMIN_USERS means deleting the account and taking the address out of the variable. One without the other lasts until the next restart.

That is the trade this product takes: the variable is operator configuration, and the operator is the person who deploys. If you need more than that, the database on the volume is yours.

There are no roles and no permission matrix. Everybody who can sign in can do everything, which is what a blog of people who trust each other needs.

A forgotten password

The sign-in screen has a "Forgot your password?" link. It asks for the email address of the account and sends a link that sets a new password. The link works one time, and for 24 hours. Outgoing mail has to work for this, so a real installation sets MAIL_ADAPTER.

The profile changes the address later, and that change asks for the password first: whoever holds the address holds the account, so a browser somebody left open must not be enough to move it. Readers never see the address; they see the displayed name, or the part in front of the @ while that name is empty.

When the mail cannot go out, another admin deletes the account in Settings and invites the address again. For the only account of a site, fix the mail settings and restart: while nobody can sign in, the link stands in the log.

Import from another system

Texttile imports entries from a zip archive of bundles: one folder per entry, with Markdown, settings, and pictures. A picture can be a file in the bundle or a URL that the server downloads, so a migration zip stays small. IMPORT.md is the complete format contract, written so that a script or an AI agent can convert any export (WordPress, for example) into bundles. The import itself lives in Settings: upload the zip, read the validation report, start the import.

A film travels in the bundle as a file. A picture may come from a URL, a film may not: a film is large, and the import does not carry one over the network.

Export one entry

Every entry hands out a copy of itself as a zip. It stands in the menu at the right of the editor bar, and on every card of the entry list.

beach-days.zip
└── beach-days/
    ├── index.md
    └── gallery/
        ├── 001_beach.jpg     the tiles, in gallery order
        ├── 002_pier.jpg
        ├── xxx_001_map.png   the files in the words, in reading order
        └── xxx_002_walk.mp4

index.md carries the front matter of IMPORT.md and the text below it, with every reference pointing into gallery/. That makes the zip two things at once: a Hugo page bundle, and a bundle this Texttile or another one imports again.

What travels is the entry as the readers have it, and the file as it was uploaded, never a rendition. An entry that was never live has only the working copy, so that is what it exports. The comments stay on the site; a copy of an entry is its text and its pictures.

Backup

Texttile is backed up by pulling, not by pushing. A machine you keep, a Raspberry Pi at home or a NAS, fetches the whole installation on a clock of its own. The backup machine holds the token; this server holds nothing that reaches your copies. Whoever breaks into the server therefore cannot delete them, which is the one property a push to an object store does not have.

Switch it on in Settings, section Backup:

  1. Tick Serve a backup client at /backup. Off on a fresh install.
  2. Press Create a token. It is shown once, and stored here as a hash. Write it into the configuration of the backup machine now.
  3. Optionally name the addresses the backup machine calls from. Empty is the usual case: the token alone decides.

The token opens the backup endpoints only, and it only ever reads. Keep it like a password all the same. What it fetches is the database, and the database carries the blog password in clear, the password hash and the live sessions of every account, and the address of every reader who commented or joined the newsletter. Whoever holds the token holds a copy of the site.

The address list is a second lock, never the first. Behind a proxy it is only as good as CLIENT_IP_HEADER: a proxy that passes a caller's X-Forwarded-For through instead of appending to it lets the caller write their own address. Set CLIENT_IP_HEADER only where the proxy writes the header itself.

Last fetched on the same screen dates the last run, with the address it came from. A backup that stopped running says nothing until the day you need it, so that line is where you see it.

What the client copies

Data Copied Why
the SQLite database in full, per run a few MB, and it changes constantly
image and video originals what is new large, but written once and kept
what ffmpeg made of a video what is new a conversion no page can wait for
the logo and the favicon what is new small, and not made again by itself
the rendition cache never made again the moment a page asks

Leaving the cache out often halves what travels. After a restore, the renditions are made again as readers arrive.

The client

scripts/texttile-backup.sh in this repository is the client. It needs curl, jq, flock, and sha256sum (Debian, Raspberry Pi OS: apt install curl jq). Copy it to the backup machine and write its configuration to ~/.texttile-backup.conf:

TEXTTILE_URL=https://blog.example.com
TEXTTILE_TOKEN=<the token Settings showed once>
BACKUP_DIR=/mnt/backup/texttile
DB_KEEP=30          # dated database copies to keep

Then run it from cron, every day at 03:17, with mail on failure:

17 3 * * * /home/pi/texttile-backup.sh >> /var/log/texttile-backup.log 2>&1

It exits non-zero on any failure, so cron reports it.

It sets umask 077 and tightens BACKUP_DIR on every run, so the copies are readable by their owner alone. Keep them that way, above all on a NAS that exports the volume: the database in there opens the blog.

What it does, and why:

  • It archives, it does not mirror. A file that disappears from the site stays in the backup. A copy that faithfully repeats a deletion is no backup.
  • The layout matches the server. Restoring is a copy, not a script.
  • The database comes last, after every file, so the copied database never names a picture that is missing from the copy.
  • Database copies are dated and rotated (30 by default). A few MB a day buys a month of points to go back to. The originals are never rotated.
  • Every file is its own request, downloaded to .part and moved into place only after its SHA-256 matches. An interrupted run costs one file.

To restore

  1. Copy files/ into the uploads directory (UPLOADS_PATH, /data/uploads in the image).
  2. Copy the newest db/texttile-*.db to the database path (DATABASE_PATH, /data/texttile.db in the image).
  3. Start the container. The renditions are made again on demand.

The endpoints

Three, all read-only, all wanting Authorization: Bearer <token>. Never a token in the address line: an address goes into the access log of this server, of every proxy on the way, and into your shell history.

Endpoint Answers
GET /backup/manifest JSON: the database, and every file with size and hash
GET /backup/db a consistent copy of the database, made with VACUUM INTO
GET /backup/file/:id one original, by the id the manifest gave it

While the feature is switched off, all three answer 404 rather than 403: a scanner learns nothing about what this server can do. An address that is not on the allowlist gets the same answer. Every call is logged, served or refused, and one caller may fetch 600 times a minute.

A caller who knocks too often is turned away before anything else is asked, so a scanner at a switched-off installation costs one line in the log and no work. The first manifest of an existing blog reads every uploaded file once, to hash it. After that a hash is stored beside the file's size and write time, and a manifest is a query: ten gigabytes of pictures cost that one first run and nothing after it.

Deploy on Fly.io

fly.toml in this repo deploys from source with one volume for the database and the uploads. Its header documents the one-time setup (app, volume, secrets, certificate).

A push to main deploys itself, but only after CI passes. The deploy starts when the CI run of the same commit reports success, and it deploys that commit, not the head of the branch.

One machine, never two

The whole installation is one SQLite file on one volume. SQLite takes one writer, and a volume mounts on one machine. A second machine gets a volume of its own: it would answer readers out of a second, older database and take entries, comments and uploads that the first machine never sees. Both sites work, and each one is missing half the blog.

So never fly scale count 2, never fly machine clone, and never deploy with a standby. fly status must show exactly one machine. A deploy replaces that machine in place, which costs a few seconds of downtime.

This is not a Fly rule. Any host runs Texttile the same way: one process, one /data.

How big the machine must be

The video conversion decides this, and nothing else does. A blog of words and pictures runs on one shared CPU and 512 MB of memory (shared-cpu-1x, the [[vm]] section of fly.toml as it stands).

Videos want more. Give the machine four shared CPUs and 1 GB (shared-cpu-4x) before you upload the first one:

[[vm]]
  cpu_kind = "shared"
  cpus = 4
  memory = "1gb"

ffmpeg runs on one thread at the lowest priority, so the other three keep the site quick while a video converts, and the gigabyte is what the conversion itself needs. Videos also need room on the volume: the original and the converted file live side by side. A machine that stops mid-conversion loses nothing; the queue picks up every unfinished video when the app starts again.

Which version runs

Settings > Version names the version of the running build, and every release raises that number. The page is behind the sign-in and the number stands nowhere else: a public version number tells an attacker which holes to try. Name it when you report a problem.

Development

Requires Elixir 1.19+, Erlang/OTP 28+, Node.js (for browser tests), and ffmpeg (for the video conversion; make tools installs it).

make tools       # install the command line tools: ffmpeg and ffprobe
make start       # dev server on port 4000, no configuration needed
make test        # JS unit tests, Elixir unit tests, browser tests
make check       # everything CI checks: warnings, formatting, tests
make db-pull     # pull the production snapshot next to the dev database
make dev-reset   # throw the shared development database and uploads away

npm --prefix assets test runs the JS unit tests on their own: plain node --test over assets/js_test/, no browser involved.

In development ADMIN_USERS holds [email protected], so an empty database gets an account at the first start. Nobody can sign in yet, so the link stands in the server output: open it and choose a password. A second address is invited from Settings, the same way a real installation does. ADMIN_USERS in .env replaces the address. To test a real mail adapter locally, copy .env.example to .env in the main checkout; dev loads it on every start, from every worktree, and real environment variables win over .env values.

The dev server is on the network

The development server listens on all interfaces, and make start opens the address of the network instead of localhost: the address of the cable network first, then Wi-Fi, then 127.0.0.1. A phone in the same network reaches the server at that address, and http://localhost:4000 keeps working on this machine. bin/dev-host prints the address that gets chosen. Everybody in that network can open the site and the dev routes, so start the server on a network you trust. DEV_HOST replaces the address: DEV_HOST=localhost make start writes http://localhost:4000 into the links that leave the server, mails included.

Shared state across worktrees

All git worktrees of the repository share the dev state of the main checkout: the texttile_dev.db database, the priv/uploads folder, and the .env file. make db-pull writes the snapshot to texttile-snapshot.db in that same checkout root. Outside a git checkout, everything stays next to the code.

make dev-reset throws that state away: the database and the uploads together, because a database without its uploads is a site full of pictures that are not there. It takes the state of every worktree with it, so stop the development server first; it refuses while a process holds the database. The next make start makes an empty installation and applies all migrations.

Translations

Text a person reads goes through gettext. After you write a new sentence, mix gettext.extract --merge puts it into the template and into every language file; mix precommit refuses a template that is out of date, so a sentence cannot slip past the translations unnoticed. The sentences the browser hooks say are the exception the tool cannot see by itself: they are listed in TexttileWeb.JsStrings, and the layout hands them to assets/js/i18n.js as data-words.

Contributing

AGENTS.md holds the permanent rules of this repository: branches, worktrees, versioning, tests, and the words to use. PHOENIX.md is the framework guide. IMPORT.md is the bundle format contract.

About

The Multiplayer Blog Engine

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages