FastAPI backend rendering HTML with html5tagger, content persisted in a kanta database and rendered per request. Vue only for the editing tools (page editor over a WebSocket, site/structure editor); public pages are plain HTML with fetch navigation. No auth: single trusted author.
450 lines
16 KiB
Python
450 lines
16 KiB
Python
"""Seed content written to the database on first run (when it is empty).
|
|
|
|
Demonstrates the formatting options: images attached to pages and served
|
|
from the page path, figures with captions, attribute classes for
|
|
positioning, footnotes, definition lists, task lists, tables and raw HTML.
|
|
"""
|
|
|
|
WELCOME = """\
|
|
Welcome to your new **Pagerite** site. Pages are written in Markdown —
|
|
including raw HTML — and served from pretty URLs.
|
|
|
|
Have a look around:
|
|
|
|
- The [docs](/docs) section explains [how to write content](/docs/editing),
|
|
including images and positioning.
|
|
- [The Long Read](/blog/the-long-read) demonstrates a longer article with
|
|
scroll effects.
|
|
- The [about](/about) page shows off assorted formatting.
|
|
|
|

|
|
"""
|
|
|
|
ABOUT = """\
|
|
This site runs on **Pagerite**: FastAPI + html5tagger + kanta, with content
|
|
written in Markdown.
|
|
|
|
Some formatting samples:
|
|
|
|
- [x] Write content in Markdown
|
|
- [x] Attach images to pages
|
|
- [ ] Add editing UI
|
|
|
|
Term
|
|
: A definition list entry, rendered by the deflist plugin.
|
|
|
|
And a table:
|
|
|
|
| Feature | Status |
|
|
|---------|--------|
|
|
| Pages | done |
|
|
| Images | done |
|
|
| Comments| later |
|
|
|
|
Footnotes work too.[^1]
|
|
|
|
[^1]: Rendered at the bottom of the page, with a back-reference.
|
|
"""
|
|
|
|
EDITING = """\
|
|
Pages are written in Markdown with extensions. Everything below is plain
|
|
Markdown source — no special support from the article is needed for the
|
|
site's layout or scroll effects.
|
|
|
|
## Images
|
|
|
|
Upload a file (`PUT /_/api/files/{filename}`) and it lands in the
|
|
content-addressed store, served immutable from `/_/f/{hash}.ext` — an
|
|
absolute URL that survives page moves:
|
|
|
|
```
|
|
{.right width=280}
|
|
```
|
|
|
|
{.right width=280}
|
|
|
|
The title becomes a `<figcaption>`, and brace attributes (the attrs
|
|
plugin) control positioning: `{.right}`, `{.left}`, `{.wide}`, plus plain
|
|
attributes like `width=280`. Absolute and external URLs pass through
|
|
unchanged.
|
|
|
|
## Text
|
|
|
|
*Emphasis*, **strong**, ~~strikethrough~~, `inline code`, and
|
|
[links](/about) as usual. Blockquotes:
|
|
|
|
> The URL space is the author's. Pretty slugs at the root, nesting only
|
|
> where the content is genuinely structured.
|
|
|
|
## Code
|
|
|
|
```python
|
|
def render(text: str, page_path: str) -> str:
|
|
return md.render(text, {"page_path": page_path})
|
|
```
|
|
"""
|
|
|
|
LONG_READ = """\
|
|
*An essay long enough to scroll, to demonstrate the gentle reveal of
|
|
headings, figures and code blocks as they enter the viewport.*
|
|
|
|
{.wide}
|
|
|
|
## Chapter one
|
|
|
|
The distinction between a blog and a website is largely an accident of
|
|
history. Early content management systems filed everything under "posts",
|
|
stamped them with a date, and arranged them in reverse chronological order
|
|
under a `/blog/` prefix. Anything else was a "page", which lived somewhere
|
|
else entirely, often in a separate editing interface with separate rules.
|
|
|
|
But readers do not think in these terms. A reader follows a link, reads
|
|
what is there, and follows another link. The URL is a promise about where
|
|
something lives, not about which database table it came from. Pagerite
|
|
therefore treats every piece of content as a page: named, addressable, and
|
|
rendered on the fly.
|
|
|
|
## Chapter two
|
|
|
|
Consider what happens to URLs when the tooling leads the design. You get
|
|
addresses like `/cms/frontpage` or `/blog/post1` — the name of the machine
|
|
leaking into the name of the thing. The slug should be chosen by the
|
|
author, the way a book's title is chosen, and it should sit at the root of
|
|
the site like the title sits on the cover.
|
|
|
|
Nesting still has its place. Structured content — documentation, a series,
|
|
a portfolio — benefits from paths that mirror the structure. The
|
|
navigation on this very site is derived from the paths: open a section,
|
|
and you see what it contains. No menu editor, no duplication of structure
|
|
in two places.
|
|
|
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
|
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
|
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
|
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
|
|
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
|
|
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
|
|
est laborum.
|
|
|
|
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium
|
|
doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo
|
|
inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
|
|
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut
|
|
fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem
|
|
sequi nesciunt.
|
|
|
|
## Chapter three
|
|
|
|
On the reading experience itself: motion on the web is usually either
|
|
absent or obnoxious. The interesting middle ground is motion that
|
|
acknowledges the reader's own movement — the scroll. Elements that fade
|
|
in as they enter the viewport give the page a sense of depth, as if the
|
|
content were arriving just in time.
|
|
|
|
Crucially, none of this may depend on the article. The author writes
|
|
Markdown; the effects come from the layout. And when the reader prefers
|
|
reduced motion, everything must hold still.
|
|
|
|
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet,
|
|
consectetur, adipisci velit, sed quia non numquam eius modi tempora
|
|
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad
|
|
minima veniam, quis nostrum exercitationem ullam corporis suscipit
|
|
laboriosam, nisi ut aliquid ex ea commodi consequatur?
|
|
|
|
```text
|
|
Quis autem vel eum iure reprehenderit
|
|
qui in ea voluptate velit esse quam nihil
|
|
molestiae consequatur, vel illum qui
|
|
dolorem eum fugiat quo voluptas nulla pariatur?
|
|
```
|
|
|
|
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis
|
|
praesentium voluptatum deleniti atque corrupti quos dolores et quas
|
|
molestias excepturi sint occaecati cupiditate non provident, similique
|
|
sunt in culpa qui officia deserunt mollitia animi, id est laborum et
|
|
dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.
|
|
|
|
## Chapter four
|
|
|
|
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil
|
|
impedit quo minus id quod maxime placeat facere possimus, omnis voluptas
|
|
assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut
|
|
officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates
|
|
repudiandae sint et molestiae non recusandae.
|
|
|
|
Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis
|
|
voluptatibus maiores alias consequatur aut perferendis doloribus
|
|
asperiores repellat. And so we arrive back where we started: the blog and
|
|
the website were one thing all along. [Return to the front page](/).
|
|
"""
|
|
|
|
NOTES_ON_URLS = """\
|
|
A URL is part of the content. A few rules of thumb I keep coming back to:
|
|
|
|
- Pick slugs like book titles, not like database keys.
|
|
- Nest only when the structure is real.
|
|
- Once published, a URL is a promise. Redirect if you must break it.
|
|
|
|
> Cool URIs don't change; uncool ones at least apologise.
|
|
|
|
That's all. Short posts are posts too.
|
|
"""
|
|
|
|
CANVAS_NIGHTS = """\
|
|
This post's banner is not an image at all — it's a `<canvas>` animated by
|
|
a few lines of JavaScript embedded in the page's banner HTML.
|
|
|
|
Banners on this site are arbitrary markup: an image, a gradient div, or a
|
|
small animated scene like the one above. Subpages inherit the nearest
|
|
banner up their path, so a whole section can share one look.
|
|
|
|
```js
|
|
// the essence of the banner above
|
|
stars.forEach(s => { s.x = (s.x + s.speed * dt) % 1 })
|
|
```
|
|
|
|
No build step, no framework — the snippet is stored with the page and
|
|
dropped into the header as-is.
|
|
"""
|
|
|
|
SMALL_RELEASES = """\
|
|
Software wants to be shipped. The longer a change sits unmerged, the more
|
|
it rots: context fades, conflicts accumulate, and the diff grows teeth.
|
|
|
|
1. Cut the scope until it fits in a day.
|
|
2. Ship it behind whatever door you like.
|
|
3. Let real use argue with your assumptions.
|
|
|
|
A release is a conversation with reality. Small releases keep the
|
|
conversation lively.
|
|
"""
|
|
|
|
CANVAS_BANNER = """\
|
|
<canvas id="stars"></canvas>
|
|
<script>
|
|
(() => {
|
|
const c = document.getElementById("stars");
|
|
const ctx = c.getContext("2d");
|
|
const fit = () => { c.width = c.clientWidth; c.height = c.clientHeight; };
|
|
fit();
|
|
addEventListener("resize", fit);
|
|
const stars = Array.from({ length: 110 }, () => ({
|
|
x: Math.random(), y: Math.random(),
|
|
r: Math.random() * 1.4 + 0.3, v: Math.random() * 0.05 + 0.01,
|
|
}));
|
|
let prev = performance.now();
|
|
(function frame(now) {
|
|
if (!c.isConnected) return;
|
|
const dt = Math.min(now - prev, 100); prev = now;
|
|
ctx.fillStyle = "#0b0e1d";
|
|
ctx.fillRect(0, 0, c.width, c.height);
|
|
ctx.fillStyle = "#cdd6ff";
|
|
for (const s of stars) {
|
|
s.x = (s.x + s.v * dt / 1000) % 1;
|
|
ctx.beginPath();
|
|
ctx.arc(s.x * c.width, s.y * c.height, s.r, 0, 7);
|
|
ctx.fill();
|
|
}
|
|
requestAnimationFrame(frame);
|
|
})(prev);
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
EYES_BANNER = """\
|
|
<canvas id="eyes"></canvas>
|
|
<script>
|
|
(() => {
|
|
const c = document.getElementById("eyes");
|
|
const ctx = c.getContext("2d");
|
|
const BG = "#f3e9d7";
|
|
const fit = () => { c.width = c.clientWidth; c.height = c.clientHeight; };
|
|
fit();
|
|
addEventListener("resize", fit);
|
|
|
|
// Mouse in canvas coordinates; pupils wander idly when it goes stale.
|
|
let mx = 0, my = 0, lastMove = 0;
|
|
addEventListener("mousemove", (e) => {
|
|
const r = c.getBoundingClientRect();
|
|
mx = e.clientX - r.left;
|
|
my = e.clientY - r.top;
|
|
lastMove = performance.now();
|
|
});
|
|
|
|
// The pair of eyes is one critter: it wanders around the banner, and
|
|
// every so often ducks below the bottom edge, then pops back up.
|
|
let gx = 0.5, gy = 0.5; // group position (fractions of the canvas)
|
|
let tx = 0.5, ty = 0.5; // wander target
|
|
let yoff = 0, vy = 0; // vertical hide/pop spring (px)
|
|
let hidePhase = 0; // 0 = up, 1 = ducking, 2 = down, waiting
|
|
let nextMove = 0, nextHide = 4000 + Math.random() * 5000, resurfaceAt = 0;
|
|
|
|
// Per-eye pupil state: spring physics for goofy lag and overshoot.
|
|
const eyes = [{ x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }, { x: 0, y: 0, vx: 0, vy: 0, pr: 0.3 }];
|
|
|
|
let prev = performance.now();
|
|
(function frame(now) {
|
|
if (!c.isConnected) return;
|
|
const dt = Math.min(now - prev, 100) / 16.7; prev = now;
|
|
ctx.fillStyle = BG;
|
|
ctx.fillRect(0, 0, c.width, c.height);
|
|
const R = Math.min(c.height * 0.32, 70);
|
|
|
|
// Wander: ease toward a spot, pick a new one every few seconds.
|
|
if (now > nextMove && !hidePhase) {
|
|
tx = 0.15 + Math.random() * 0.7;
|
|
ty = 0.3 + Math.random() * 0.4;
|
|
nextMove = now + 2500 + Math.random() * 3500;
|
|
}
|
|
gx += (tx - gx) * 0.02 * dt;
|
|
gy += (ty - gy) * 0.02 * dt;
|
|
|
|
// Duck down, wait hidden, then spring back (underdamped = pops past
|
|
// the resting point and wobbles). Resurfaces at a new spot.
|
|
if (hidePhase === 0 && now > nextHide) hidePhase = 1;
|
|
if (hidePhase === 1 && yoff > c.height * 0.9) {
|
|
hidePhase = 2;
|
|
resurfaceAt = now + 500 + Math.random() * 900;
|
|
}
|
|
if (hidePhase === 2 && now > resurfaceAt) {
|
|
hidePhase = 0;
|
|
nextHide = now + 5000 + Math.random() * 7000;
|
|
tx = 0.15 + Math.random() * 0.7;
|
|
gx = tx;
|
|
nextMove = now + 3000 + Math.random() * 3000;
|
|
}
|
|
const yTarget = hidePhase ? c.height : 0;
|
|
vy += (yTarget - yoff) * 0.06 * dt;
|
|
vy *= 0.85;
|
|
yoff += vy * dt;
|
|
|
|
const cy0 = gy * c.height + yoff;
|
|
const cx0 = gx * c.width;
|
|
const watching = now - lastMove < 4000;
|
|
eyes.forEach((e, i) => {
|
|
const cx = cx0 + (i ? 1.3 : -1.3) * R;
|
|
// Pupil target: toward the cursor, or a slow idle drift.
|
|
let ptx, pty;
|
|
if (watching) {
|
|
const dx = mx - cx, dy = my - cy0;
|
|
const d = Math.hypot(dx, dy) || 1;
|
|
const reach = R * 0.45 * Math.min(1, d / 200);
|
|
ptx = (dx / d) * reach; pty = (dy / d) * reach;
|
|
} else {
|
|
ptx = Math.sin(now / 900 + i * 2) * R * 0.3;
|
|
pty = Math.cos(now / 1300 + i * 3) * R * 0.2;
|
|
}
|
|
// Spring toward the target (underdamped: overshoots, wobbles).
|
|
e.vx += (ptx - e.x) * 0.08 * dt; e.vy += (pty - e.y) * 0.08 * dt;
|
|
e.vx *= 0.82; e.vy *= 0.82;
|
|
e.x += e.vx * dt; e.y += e.vy * dt;
|
|
// Pupils dilate when the cursor comes close to the eye.
|
|
const near = Math.hypot(mx - cx, my - cy0) < R * 2.5;
|
|
e.pr += ((near ? 0.42 : 0.3) - e.pr) * 0.1 * dt;
|
|
// Sclera.
|
|
ctx.fillStyle = "#fff";
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
|
ctx.fill();
|
|
// Iris + pupil + glint, clipped to the sclera.
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
|
ctx.clip();
|
|
ctx.fillStyle = "#7c5cff";
|
|
ctx.beginPath();
|
|
ctx.arc(cx + e.x, cy0 + e.y, R * 0.55, 0, 7);
|
|
ctx.fill();
|
|
ctx.fillStyle = "#1d1730";
|
|
ctx.beginPath();
|
|
ctx.arc(cx + e.x, cy0 + e.y, R * e.pr, 0, 7);
|
|
ctx.fill();
|
|
ctx.fillStyle = "#fff";
|
|
ctx.beginPath();
|
|
ctx.arc(cx + e.x - R * 0.15, cy0 + e.y - R * 0.18, R * 0.09, 0, 7);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
ctx.strokeStyle = "#2b2440";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy0, R, R * 1.15, 0, 0, 7);
|
|
ctx.stroke();
|
|
});
|
|
requestAnimationFrame(frame);
|
|
})(prev);
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
BLOG_BANNER = '<div style="background: linear-gradient(100deg, #14243d, #3d2b6b 45%, #7c5cff 75%, #ff5c8a)"></div>'
|
|
|
|
FRONT_BANNER = '<img src="/waves.svg" alt="">'
|
|
|
|
WAVES_SVG = """\
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">
|
|
<defs>
|
|
<linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0" stop-color="#7c5cff"/>
|
|
<stop offset="1" stop-color="#00d4c8"/>
|
|
</linearGradient>
|
|
<linearGradient id="g2" x1="0" y1="1" x2="1" y2="0">
|
|
<stop offset="0" stop-color="#ff5c8a"/>
|
|
<stop offset="1" stop-color="#7c5cff"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect width="800" height="400" fill="#12101c"/>
|
|
<path d="M0 260 Q 200 180 400 260 T 800 260 V400 H0 Z" fill="url(#g1)" opacity="0.85"/>
|
|
<path d="M0 310 Q 200 240 400 310 T 800 310 V400 H0 Z" fill="url(#g2)" opacity="0.75"/>
|
|
<circle cx="600" cy="110" r="70" fill="#ffd75c" opacity="0.9"/>
|
|
</svg>
|
|
"""
|
|
|
|
SHAPES_SVG = """\
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
|
<rect width="400" height="400" fill="#0f1a1c"/>
|
|
<circle cx="140" cy="150" r="90" fill="#00d4c8" opacity="0.85"/>
|
|
<rect x="180" y="180" width="150" height="150" rx="24" fill="#ffb35c" opacity="0.9"/>
|
|
<path d="M140 60 L 220 200 L 60 200 Z" fill="#ff5c8a" opacity="0.8"/>
|
|
</svg>
|
|
"""
|
|
|
|
DUNES_SVG = """\
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 300">
|
|
<defs>
|
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stop-color="#2b1b4d"/>
|
|
<stop offset="1" stop-color="#ff8a5c"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect width="1200" height="300" fill="url(#sky)"/>
|
|
<path d="M0 210 Q 300 150 600 210 T 1200 210 V300 H0 Z" fill="#3d2b6b"/>
|
|
<path d="M0 250 Q 300 200 600 250 T 1200 250 V300 H0 Z" fill="#241842"/>
|
|
</svg>
|
|
"""
|
|
|
|
#: path -> (title, markdown, {filename: bytes}, banner HTML, menu order).
|
|
#: Note there are deliberately no "docs" or "blog" landing pages: those
|
|
#: labels are created without content, so entering them redirects to the
|
|
#: first child (see views.first_leaf).
|
|
PAGES: dict[str, tuple[str, str, dict[str, bytes], str, float]] = {
|
|
"": ("Welcome", WELCOME, {"waves.svg": WAVES_SVG.encode()}, FRONT_BANNER, 1),
|
|
"about": ("About", ABOUT, {}, "", 2),
|
|
"docs/editing": (
|
|
"Writing Content",
|
|
EDITING,
|
|
{"shapes.svg": SHAPES_SVG.encode()},
|
|
"",
|
|
1,
|
|
),
|
|
"blog/the-long-read": (
|
|
"The Long Read",
|
|
LONG_READ,
|
|
{"dunes.svg": DUNES_SVG.encode()},
|
|
BLOG_BANNER,
|
|
1,
|
|
),
|
|
"blog/notes-on-urls": ("Notes on URLs", NOTES_ON_URLS, {}, EYES_BANNER, 2),
|
|
"blog/canvas-nights": ("Canvas Nights", CANVAS_NIGHTS, {}, CANVAS_BANNER, 3),
|
|
"blog/small-releases": ("Small Releases", SMALL_RELEASES, {}, "", 4),
|
|
}
|