Skip to content

Latest commit

 

History

History
617 lines (495 loc) · 26.7 KB

File metadata and controls

617 lines (495 loc) · 26.7 KB

Paulias

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.

Philosophy

  • 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.

Reference project

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 (no src/ layout).
  • uv for dependency management. uv.lock committed. .python-version pins the Python version to 3.14.
  • hatchling as the build backend.
  • Makefile providing make install, make test, make lint, make format, make clean.
  • ruff for linting and formatting, configured in pyproject.toml.
  • pytest as the test runner. Tests in tests/.
  • README.md, CHANGELOG.md, CONTRIBUTING.md, LICENSE, CLAUDE.md, SPEC.md at the repo root.
  • GitHub Actions workflows in .github/workflows/.

Commands

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.

Config file: paulias.md

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

Frontmatter fields

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.

Link reference format

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.

Generated output

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

Redirect template

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.

Index page

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:

  1. The site title from frontmatter as <h1> (default: "Paulias").
  2. The about block from frontmatter, rendered as a paragraph below the title. Omitted if not set.
  3. A list of all shortlinks: shorttarget. Each short is rendered as a link to its own redirect page (so clicking it actually performs the redirect, useful for testing).
  4. A footer rendered from the footer field in frontmatter, supporting inline markdown (links and emphasis). If footer is 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.

404 page

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.

Workflow

# 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 in detail

paulias init
  • Writes a starter paulias.md to the current working directory.
  • Errors out if paulias.md already exists, unless --force is passed.
  • Auto-detects the repo field from git remote get-url origin if the cwd is a git repo with an origin remote 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 empty cname, an empty branch (commented to show the default is main), a placeholder title, and a placeholder about.
    • An empty markdown table with the header row only.
  • Prints a short next-steps message after writing.

Flags

Flag Description
--force Overwrite an existing paulias.md.
--repo Set the repo field explicitly instead of auto-detecting.

paulias add in detail

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. Use paulias 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.

Flags

Flag Description
--force Overwrite an existing entry with the same path.
--deploy Run paulias deploy immediately after adding.

paulias delete in detail

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.

Flags

Flag Description
--deploy Run paulias deploy immediately after deleting.

paulias list in detail

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.

Flags

Flag Description
--json Print as JSON instead of a table.

paulias open in detail

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 in paulias.md.
  • Opens the target URL directly, not the deployed shortlink. This means paulias open works even before paulias deploy has been run, and even for shortlinks added in the current session.

Flags

Flag Description
--print Print the target URL to stdout instead of opening it.

paulias deploy in detail

In order:

  1. Load and validate paulias.md (see Validation below).
  2. Wipe and regenerate docs/:
    • For each row, write docs/{short}/index.html.
    • Write docs/CNAME if cname is set in frontmatter.
    • Write docs/index.html using index.html.j2.
    • Write docs/404.html using 404.html.j2.
    • Write docs/style.css.
  3. Stage paulias.md and docs/ together.
  4. 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's paulias.md if it exists.
  5. 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).

Flags

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).

Validation

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 http or https scheme.
  • Must have a non-empty host.

Frontmatter rules:

  • repo is required and must match ^[\w.-]+/[\w.-]+$.
  • cname if set must be a valid hostname (no scheme, no path).
  • branch if set must be a non-empty string.

Repo layout (for the shortener repo, not Paulias itself)

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's own repo layout

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

Dependencies

Keep minimal. Standard library plus:

  • click for the CLI (matches pauldot and paulblish).
  • pyyaml for frontmatter parsing.
  • jinja2 for HTML templates (matches paulblish).
  • markdown-it-py for rendering the footer field's inline markdown (matches paulblish; reuse rather than ship a second markdown library).
  • rich for nice list output (matches pauldot).

Git operations shell out to the system git binary via subprocess — no need for GitPython. Errors from git are surfaced verbatim.

Installation

uv tool install paulias

After install, run paulias from inside any directory containing a paulias.md file.


Implementation plan

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.

Phase 1: Scaffolding and config parsing

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), flat paulias/ 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 for PauliasConfig (the parsed frontmatter) and Shortlink (a single entry). Parser that reads paulias.md, splits YAML frontmatter from the body, and parses the markdown link reference definitions (^\\[([^\\]]+)\\]: (.+)$) into ordered Shortlink objects.
  • 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.py and validate.py together so loading paulias.md runs 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.

Phase 2: CLI and config-editing commands

list, add, delete, init. No HTML output, no git, no deploy.

  • 2.1 Implement cli.py: top-level paulias Click group, with add, delete, list, init, deploy subcommands (deploy stubbed for now). All commands locate paulias.md in the cwd.
  • 2.2 Implement paulias list: load the config, print as a rich table. Support --json flag.
  • 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 --force and --deploy.
  • 2.4 Implement paulias delete <path>: find and remove the matching link reference line. Support --deploy.
  • 2.5 Implement paulias init: write a starter paulias.md to the cwd, auto-detecting repo from git remote get-url origin when possible. Error if a file already exists unless --force. Support --repo to 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.

Phase 3: Build pipeline

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 local templates/ directory next to paulias.md if present. Single entry point render(template_name, **context) -> str. The environment also exposes a Jinja filter inline_markdown that 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 the footer frontmatter field.
  • 3.2 Write the bundled templates/redirect.html.j2 per the redirect template in this spec. Context: target. The redirect template is the only one that does not extend base.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.css from phalt/paulblish verbatim into paulias/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's base.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-rendered footer HTML 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. Extends base.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 each short rendered as a link to /short/.
  • 3.6 Write the bundled templates/404.html.j2. Extends base.html.j2. Context: title, footer. Body content matches paulblish's 404 page styling.
  • 3.7 Implement build.py: wipe docs/, create one docs/{short}/index.html per row, write docs/CNAME if cname is set, write docs/index.html, docs/404.html, docs/style.css. The footer frontmatter field is passed through the inline_markdown filter 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-run flag that exits after building.
  • 3.9 Write tests for: a single shortlink produces a correct redirect HTML, cname set writes CNAME, cname unset 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, the footer field renders inline markdown correctly (link + emphasis) and falls back to the default attribution when unset, and the bundled style.css byte-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.

Phase 4: Deploy (git commit + push)

  • 4.1 Implement deploy.py: thin wrapper around subprocess calls to git. 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 via git show HEAD:paulias.md), report M added, K removed counts. Fall back to the total count if there is no previous commit.
  • 4.3 Wire the full paulias deploy flow: validate → build → stage → commit → push. Honour --dry-run, --no-push, --message, --force.
  • 4.4 Wire the --deploy flag on paulias add and paulias delete to 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-run does not call git, --no-push calls commit but not push, idempotency (deploy with no changes produces no commit). Mock subprocess for these tests.

Milestone: paulias deploy produces a commit and pushes it to GitHub.

Phase 5: Polish

  • 5.1 Implement paulias open <path>: look up <path> in paulias.md, resolve it to its target URL, and open it in the default browser via webbrowser.open() from the stdlib. Errors if <path> does not exist. Useful for quick verification of a shortlink without typing the full URL. Add a --print flag 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.yml GitHub Actions workflow: run ruff and pytest on PRs against main.
  • 5.4 Write README.md with sections: what is this, quick start, installation, commands (full table of each command and its flags), paulias.md format, custom domain setup via cname, deployment workflow, development, fork your own copy.
  • 5.5 Write CHANGELOG.md and CONTRIBUTING.md.
  • 5.6 Write CLAUDE.md summarising 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.


Phase 6: Future extras (not v1)

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 --check mode 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 deploy can be optional and the build runs in CI on push to paulias.md instead.