A statically hosted URL shortener. Paulias takes a markdown file of short paths and target URLs, generates a directory of HTML redirect files, and pushes them to a GitHub Pages repo. No server, no JavaScript required, no database.
- File over app. The list of shortlinks lives in a single human-readable
markdown file with YAML frontmatter and a table block. You can edit it on
GitHub, on your phone, in Obsidian, or with
paulias add. - No server. Each shortlink is a tiny static HTML file containing a meta refresh redirect. GitHub Pages serves it for free.
- The config is the source of truth. The generated HTML is derived; the markdown file is what matters. Both are committed to the same repo.
The project must follow phalt/paulblish and phalt/pauldot for all decisions about project layout, tooling, and conventions:
- Flat package layout with
paulias/at the repo root (nosrc/layout). uvfor dependency management.uv.lockcommitted..python-versionpins the Python version to 3.14.hatchlingas the build backend.Makefileprovidingmake install,make test,make lint,make format,make clean.rufffor linting and formatting, configured inpyproject.toml.pytestas the test runner. Tests intests/.README.md,CHANGELOG.md,CONTRIBUTING.md,LICENSE,CLAUDE.md,SPEC.mdat the repo root.- GitHub Actions workflows in
.github/workflows/.
paulias init Generate a starter paulias.md in the cwd
paulias add <path> <url> Append a shortlink to the config
paulias delete <path> Remove a shortlink from the config
paulias list Print the current list of shortlinks
paulias open <path> Open a shortlink in the default browser
paulias deploy Build the site and push to GitHub Pages
paulias add and paulias delete only edit the markdown config file. They
do not regenerate HTML or push. Use paulias deploy to build and ship.
This separation is deliberate: it lets you batch edits, edit the config by hand, and review the diff before publishing.
The config lives at paulias.md in the root of your shortener repo. It is a
markdown file with YAML frontmatter and a single table block.
---
cname: paulias.dev
repo: phalt/paulias-links
branch: main
title: "Paul's shortlinks"
about: "A personal collection of short links, generated by Paulias."
footer: "Made by [Paul](https://paulwrites.software) with [Paulias](https://github.com/phalt/paulias)."
---
[gh]: https://github.com/phalt
[drums]: https://www.alesis.com/products/...
[f1]: https://www.formula1.com
| Field | Required | Description |
|---|---|---|
cname |
no | Custom domain. Writes a CNAME file to docs/CNAME. |
repo |
yes | GitHub repo in owner/name form. Used by deploy. |
branch |
no | Branch to push to. Default main. |
title |
no | Title shown on the index page. Default Paulias. |
about |
no | Short description shown on the index page. |
footer |
no | Short text shown in the page footer. Supports markdown for inline links and emphasis. |
Shortlinks are standard markdown link reference definitions, one per line:
[short]: https://target-url
Each line maps a short path to its target URL. The short label is the path
that becomes the redirect file; the URL is where the visitor is sent.
Order of entries is preserved on disk so the file diffs cleanly when you add
or remove entries.
paulias deploy writes to a docs/ directory in the repo root. docs/ is
the GitHub Pages source directory.
docs/
├── CNAME # only if cname is set in frontmatter
├── index.html # landing page listing all shortlinks
├── 404.html # fallback for unknown paths
├── style.css # minimal stylesheet for the index and 404 pages
├── gh/index.html # meta refresh redirect to target
├── drums/index.html
└── f1/index.html
Each shortlink HTML file is the same minimal template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Redirecting…</title>
<meta http-equiv="refresh" content="0;url=TARGET">
<meta name="robots" content="noindex">
<link rel="canonical" href="TARGET">
<script>location.replace("TARGET")</script>
</head>
<body><a href="TARGET">Redirecting to TARGET</a></body>
</html>
The <script> is faster than meta refresh and doesn't pollute browser
history; the meta refresh is the fallback if JS is disabled; the <a> is the
fallback fallback.
The index page must match the visual theme of phalt/paulblish exactly. The live blog at paulwrites.software is the canonical visual reference.
Concretely, the bundled templates/style.css file in the Paulias package is
a verbatim copy of templates/static/style.css from the paulblish repo at
the time of release. The implementation step for the stylesheet (see phase
3.5 of the implementation plan) is a copy-paste task, not a "design in the
same spirit" task. Any deviations from paulblish's CSS must be additive,
limited to the new components Paulias introduces (the shortlinks table, the
custom footer block) and must reuse paulblish's existing CSS custom
properties (e.g. --bg, --text, --accent-teal, etc.) rather than
introducing new colours.
The bundled templates/base.html.j2 reuses paulblish's base.html
structure: the same <head> boilerplate, the same monospace heading font
stack, the same .site-nav and .site-footer wrappers, and the same
{% block content %} hook for child templates to fill.
The page contains:
- The site
titlefrom frontmatter as<h1>(default: "Paulias"). - The
aboutblock from frontmatter, rendered as a paragraph below the title. Omitted if not set. - A list of all shortlinks:
short→target. Eachshortis rendered as a link to its own redirect page (so clicking it actually performs the redirect, useful for testing). - A footer rendered from the
footerfield in frontmatter, supporting inline markdown (links and emphasis). Iffooteris not set, the footer falls back to a small attribution line linking to paulias on GitHub.
The template exposes the following Jinja2 hooks for users who want to override
it locally via a templates/ directory next to paulias.md:
| Variable | Type | Description |
|---|---|---|
title |
str | Site title (frontmatter title). |
about |
str | About text (frontmatter about). |
footer |
str | Footer HTML, already rendered from frontmatter footer (markdown-to-HTML). Empty string if not set, in which case the template falls back to the default attribution. |
cname |
str | Custom domain or empty string. |
shortlinks |
list | List of {"short": ..., "target": ...}. |
If a templates/index.html.j2 file exists next to paulias.md, it overrides
the bundled default. The same goes for base.html.j2, 404.html.j2, and
style.css.
A minimal "404 — not found" page, styled with the same style.css and
base.html.j2 as the index, so it inherits the paulblish theme. Links back
to /. Rendered from a Jinja2 template (404.html.j2) that can be
overridden the same way as the index template.
# one-off: init a new shortener repo
gh repo create phalt/paulias-links --public --clone
cd paulias-links
paulias init # writes a starter paulias.md
# daily usage
paulias add gh https://github.com/phalt
paulias add f1 https://www.formula1.com
paulias deploy
# editing by hand also works
vim paulias.md # add or remove rows
paulias deploy
paulias init
- Writes a starter
paulias.mdto the current working directory. - Errors out if
paulias.mdalready exists, unless--forceis passed. - Auto-detects the
repofield fromgit remote get-url originif the cwd is a git repo with anoriginremote pointing at GitHub. Otherwise leaves it as a placeholder (<owner>/<repo>) for the user to fill in. - The starter file contains:
- Frontmatter with
repo, an emptycname, an emptybranch(commented to show the default ismain), a placeholdertitle, and a placeholderabout. - An empty markdown table with the header row only.
- Frontmatter with
- Prints a short next-steps message after writing.
| Flag | Description |
|---|---|
--force |
Overwrite an existing paulias.md. |
--repo |
Set the repo field explicitly instead of auto-detecting. |
paulias add <path> <url>
- Appends a new row to the table in
paulias.md. - Validates the path and URL before writing (see Validation).
- Errors if
<path>already exists. Usepaulias delete <path>first if you want to replace it. - Does not touch
docs/, does not commit, does not push. - Prints the new row and a hint:
Run 'paulias deploy' to publish.
| Flag | Description |
|---|---|
--force |
Overwrite an existing entry with the same path. |
--deploy |
Run paulias deploy immediately after adding. |
paulias delete <path>
- Removes the matching row from
paulias.md. - Errors if
<path>does not exist. - Does not touch
docs/, does not commit, does not push.
| Flag | Description |
|---|---|
--deploy |
Run paulias deploy immediately after deleting. |
Prints the current shortlinks as a formatted table to stdout. Reads only from
paulias.md — does not look at docs/.
$ paulias list
Paul's shortlinks (3 entries, deploying to paulias.dev)
short target
───── ──────
gh https://github.com/phalt
drums https://www.alesis.com/products/...
f1 https://www.formula1.com
paulias list displays shortlinks as a formatted table regardless of the
link reference format on disk.
| Flag | Description |
|---|---|
--json |
Print as JSON instead of a table. |
paulias open <path>
Looks up <path> in paulias.md, resolves it to its target URL, and opens
the target in the default browser. Useful for quickly verifying that a
shortlink resolves to the right place without typing the full domain.
- Errors if
<path>does not exist inpaulias.md. - Opens the target URL directly, not the deployed shortlink. This means
paulias openworks even beforepaulias deployhas been run, and even for shortlinks added in the current session.
| Flag | Description |
|---|---|
--print |
Print the target URL to stdout instead of opening it. |
In order:
- Load and validate
paulias.md(see Validation below). - Wipe and regenerate
docs/:- For each row, write
docs/{short}/index.html. - Write
docs/CNAMEifcnameis set in frontmatter. - Write
docs/index.htmlusingindex.html.j2. - Write
docs/404.htmlusing404.html.j2. - Write
docs/style.css.
- For each row, write
- Stage
paulias.mdanddocs/together. - Commit with a generated message:
Deploy N shortlinks (M added, K removed). The message is computed by comparing the new shortlink set against the previous commit'spaulias.mdif it exists. - Push to the branch defined in frontmatter (default
main).
deploy is idempotent. Running it twice in a row with no changes results in
no commit (clean working tree, nothing to push).
| Flag | Description |
|---|---|
--dry-run |
Build to docs/ but do not commit or push. |
--no-push |
Commit but do not push. |
--message, -m |
Override the generated commit message. |
--force |
Skip the validation step (not recommended). |
Run on every add and deploy. All failures exit non-zero with a clear
message.
Path rules:
- Must match
^[a-z0-9][a-z0-9_-]{0,63}$(lowercase, alphanumerics, hyphen, underscore; must start with alphanumeric; max 64 chars). - Must not collide with reserved paths:
cname,404,index,style,docs,assets,static,templates,paulias. - Must be unique within the file.
Target rules:
- Must parse as a valid URL with
httporhttpsscheme. - Must have a non-empty host.
Frontmatter rules:
repois required and must match^[\w.-]+/[\w.-]+$.cnameif set must be a valid hostname (no scheme, no path).branchif set must be a non-empty string.
paulias-links/
├── paulias.md # source of truth
├── docs/ # generated, committed
│ ├── CNAME
│ ├── index.html
│ ├── 404.html
│ ├── style.css
│ ├── gh/index.html
│ └── ...
└── (optional)
└── templates/
├── base.html.j2
├── index.html.j2
└── 404.html.j2
GitHub Pages is configured once via the repo settings: source = main
branch, folder = /docs. No build action needed, since the HTML is
pre-generated and committed.
paulias/
├── .github/workflows/ # CI: tests + lint on PR
│ └── test.yml
├── paulias/ # package source
│ ├── __init__.py
│ ├── __main__.py # `python -m paulias`
│ ├── cli.py # Click entry points
│ ├── config.py # paulias.md parser + validator
│ ├── build.py # docs/ generator
│ ├── deploy.py # git wrapper for commit + push
│ ├── validate.py # path, URL, frontmatter validation
│ ├── render.py # Jinja2 environment + template resolution
│ └── templates/
│ ├── base.html.j2
│ ├── redirect.html.j2
│ ├── index.html.j2
│ ├── 404.html.j2
│ └── style.css
├── tests/
│ ├── conftest.py
│ ├── fixtures/
│ ├── test_config.py
│ ├── test_validate.py
│ ├── test_build.py
│ ├── test_deploy.py
│ └── test_cli.py
├── .gitignore
├── .python-version # 3.14
├── CHANGELOG.md
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE # MIT
├── Makefile
├── README.md
├── SPEC.md # this file
├── pyproject.toml
└── uv.lock
Keep minimal. Standard library plus:
clickfor the CLI (matches pauldot and paulblish).pyyamlfor frontmatter parsing.jinja2for HTML templates (matches paulblish).markdown-it-pyfor rendering thefooterfield's inline markdown (matches paulblish; reuse rather than ship a second markdown library).richfor nicelistoutput (matches pauldot).
Git operations shell out to the system git binary via subprocess — no
need for GitPython. Errors from git are surfaced verbatim.
uv tool install paulias
After install, run paulias from inside any directory containing a
paulias.md file.
The work is ordered so that each step produces a testable, runnable increment.
Rule: every implementation step must include test coverage. Each step either confirms existing tests cover the change and adapts them if needed, or writes new tests before the step is marked done.
The minimum to load and validate paulias.md. No HTML output yet.
- 1.1 Scaffold the project following paulblish/pauldot conventions:
pyproject.toml(hatchling backend, click + pyyaml + jinja2 + markdown-it-py + rich dependencies), flatpaulias/package,Makefile,.python-version(3.14),uv.lock,README.md,CHANGELOG.md,CONTRIBUTING.md,LICENSE(MIT),CLAUDE.md,.gitignore. - 1.2 Implement
config.py: dataclasses forPauliasConfig(the parsed frontmatter) andShortlink(a single entry). Parser that readspaulias.md, splits YAML frontmatter from the body, and parses the markdown link reference definitions (^\\[([^\\]]+)\\]: (.+)$) into orderedShortlinkobjects. - 1.3 Implement
validate.py: path regex check, target URL check, frontmatter field checks, reserved-path collision check, duplicate-path check. - 1.4 Wire
config.pyandvalidate.pytogether so loadingpaulias.mdruns validation by default. Surface validation errors with clear messages. - 1.5 Write tests for: parsing a valid file, missing frontmatter, invalid YAML, no link references in body, malformed link reference lines, invalid paths (uppercase, symbols, reserved words), invalid URLs (no scheme, no host), invalid repo field, duplicate paths.
Milestone: A paulias.md can be loaded into memory as a validated
PauliasConfig object.
list, add, delete, init. No HTML output, no git, no deploy.
- 2.1 Implement
cli.py: top-levelpauliasClick group, withadd,delete,list,init,deploysubcommands (deploy stubbed for now). All commands locatepaulias.mdin the cwd. - 2.2 Implement
paulias list: load the config, print as a rich table. Support--jsonflag. - 2.3 Implement
paulias add <path> <url>: validate the new entry, append a new link reference line to the body while preserving the rest of the file (frontmatter, existing entries, blank lines). Support--forceand--deploy. - 2.4 Implement
paulias delete <path>: find and remove the matching link reference line. Support--deploy. - 2.5 Implement
paulias init: write a starterpaulias.mdto the cwd, auto-detectingrepofromgit remote get-url originwhen possible. Error if a file already exists unless--force. Support--repoto override auto-detection. - 2.6 Write tests for each CLI command using Click's
CliRunner. Cover: success cases, validation failures, file-already-exists errors, missing-path errors, ordering preservation after add/delete, correct link reference syntax written to disk, JSON output format.
Milestone: A user can run paulias init, paulias add, paulias delete, and paulias list to manage the config file from the command line.
Generating the docs/ directory from a valid paulias.md. No git involved
yet.
- 3.1 Implement
render.py: Jinja2 environment that loads templates from the bundled package first, then overrides from a localtemplates/directory next topaulias.mdif present. Single entry pointrender(template_name, **context) -> str. The environment also exposes a Jinja filterinline_markdownthat runs a string through a markdown-to-HTML conversion limited to inline elements (links, emphasis, code) — block-level markdown is collapsed to plain text. Used to render thefooterfrontmatter field. - 3.2 Write the bundled
templates/redirect.html.j2per the redirect template in this spec. Context:target. The redirect template is the only one that does not extendbase.html.j2— it is a minimal standalone HTML document because it never reaches the user visually (the browser immediately follows the redirect). - 3.3 Copy
templates/static/style.cssfrom phalt/paulblish verbatim intopaulias/templates/style.css. Pin the source commit hash in a comment at the top of the file so future updates are traceable. Add an appended block of Paulias-specific styles for the shortlinks table (.shortlinks) and any custom footer styling, reusing the paulblish CSS custom properties (--bg,--text,--accent-teal, etc.) — no new colour literals. - 3.4 Write the bundled
templates/base.html.j2, structured identically to paulblish'sbase.html: the same<head>boilerplate, monospace heading font stack,.site-nav,.site-footer, and{% block content %}hook. The nav contains a single link back to the site root (the page title). The footer renders the pre-renderedfooterHTML if non-empty, otherwise falls back to a default attribution linking to the Paulias GitHub repo. - 3.5 Write the bundled
templates/index.html.j2. Extendsbase.html.j2. Context:title,about,footer,cname,shortlinks. Renders the title as<h1>, the about block as a<p>, and the shortlinks as a styled<table class="shortlinks">with eachshortrendered as a link to/short/. - 3.6 Write the bundled
templates/404.html.j2. Extendsbase.html.j2. Context:title,footer. Body content matches paulblish's 404 page styling. - 3.7 Implement
build.py: wipedocs/, create onedocs/{short}/index.htmlper row, writedocs/CNAMEifcnameis set, writedocs/index.html,docs/404.html,docs/style.css. Thefooterfrontmatter field is passed through theinline_markdownfilter before being injected into the templates. Return the list of files written for CLI reporting. - 3.8 Wire
build()into the deploy command path with a--dry-runflag that exits after building. - 3.9 Write tests for: a single shortlink produces a correct
redirect HTML,
cnameset writesCNAME,cnameunset omits it, index page lists all entries in order, 404 page renders, local template override is picked up, wipe removes stale files from a previous build, thefooterfield renders inline markdown correctly (link + emphasis) and falls back to the default attribution when unset, and the bundledstyle.cssbyte-for-byte matches the upstream paulblish file (excluding the appended Paulias-specific block).
Milestone: paulias deploy --dry-run produces a complete, browsable
docs/ directory that is visually indistinguishable from a paulblish site,
plus a footer customised via frontmatter.
- 4.1 Implement
deploy.py: thin wrapper aroundsubprocesscalls togit. Functions:is_clean(),stage(paths),commit(message),push(branch). Errors from git are surfaced verbatim with the failing command shown. - 4.2 Implement commit-message generation: diff the current shortlink
set against the previous commit's
paulias.md(read viagit show HEAD:paulias.md), reportM added, K removedcounts. Fall back to the total count if there is no previous commit. - 4.3 Wire the full
paulias deployflow: validate → build → stage → commit → push. Honour--dry-run,--no-push,--message,--force. - 4.4 Wire the
--deployflag onpaulias addandpaulias deleteto call the deploy command immediately after editing. - 4.5 Write tests for: commit message generation (no prior commit,
prior commit identical, prior commit different),
--dry-rundoes not call git,--no-pushcalls commit but not push, idempotency (deploy with no changes produces no commit). Mocksubprocessfor these tests.
Milestone: paulias deploy produces a commit and pushes it to GitHub.
- 5.1 Implement
paulias open <path>: look up<path>inpaulias.md, resolve it to its target URL, and open it in the default browser viawebbrowser.open()from the stdlib. Errors if<path>does not exist. Useful for quick verification of a shortlink without typing the full URL. Add a--printflag that prints the target URL to stdout instead of opening it (handy for piping to other commands). - 5.2 Final CLI output styling: rich-styled success messages,
consistent error formatting, one-line summary at the end of
deploy. - 5.3 Write the
test.ymlGitHub Actions workflow: run ruff and pytest on PRs againstmain. - 5.4 Write
README.mdwith sections: what is this, quick start, installation, commands (full table of each command and its flags),paulias.mdformat, custom domain setup viacname, deployment workflow, development, fork your own copy. - 5.5 Write
CHANGELOG.mdandCONTRIBUTING.md. - 5.6 Write
CLAUDE.mdsummarising the project conventions for AI assistants working on the repo.
Milestone: Production-ready. Anyone can uv tool install paulias, run
paulias init in a fresh repo, add a few links, and have a working
shortener on GitHub Pages.
Captured here so they aren't lost, but explicitly out of scope for the initial release.
- 6.1 Click counts via a tiny analytics include (Plausible or GoatCounter snippet in the redirect template). Strictly opt-in via a frontmatter field.
- 6.2 Bulk import from a CSV via
paulias import. - 6.3 A
--checkmode that exits non-zero if the markdown is invalid, useful as a pre-commit hook. - 6.4 Pre-built GitHub Actions workflow for the shortener repo, so
paulias deploycan be optional and the build runs in CI on push topaulias.mdinstead.