Blogging

PostArchive

A blog's posts by year and month, newest first: each year a disclosure with its count, its months and their posts, or only the months as links.

#blogging#navigation#list#archive#no-js

Demo

Preview

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/post_archive.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/post_archive.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/post_archive.qwe
let post_archive_demo_posts = [    { title: "Islands, resumed: how a page wakes up", href: "#post_archive-islands", date: "2026-10-14" },    { title: "Week notes: 43", href: "#post_archive-wn43", date: "2026-10-24T18:20:00Z" },    { title: "Streaming the slow parts of a page", href: "#post_archive-defer", date: "2026-10-02" },    { title: "Decimals, and why money needs them", href: "#post_archive-decimals", date: "2026-09-18" },    { title: "One file to deploy", href: "#post_archive-deploy", date: "2026-07-21" },    { title: "A year of Quillo", href: "#post_archive-year", date: "2025-12-30" },    { title: "Hello, world", href: "#post_archive-hello", date: "2025-12-01" },    { title: "Plugins in Rust", href: "#post_archive-rust", date: "2025-06-09" },    { title: "A draft with no date yet", href: "#post_archive-draft" },]view PostArchiveDemo() =    <div class="demo-row" style="align-items: start; gap: 40px">        <div style="flex: 1 1 360px; max-width: 520px">            <PostArchive id="post-archive-demo" title="Archive" posts={post_archive_demo_posts} year_href="#post_archive-{year}"/>        </div>        <div style="flex: 0 1 220px">            <PostArchive id="post-archive-demo-compact" posts={post_archive_demo_posts} show_posts={false} open={[2026, "2025"]}                         month_href="#post_archive-{year}-{month}" locale="fr-FR"/>        </div>    </div>

Source

ui/post_archive.qwe
// PostArchive: a blog's posts by year and month, newest first. Each year is a// disclosure (a <details>, so it opens without JavaScript) saying how many posts it// holds; inside, each month with its count and its posts, the day before each title.// Or, with show_posts false, only the months and their counts, as links to the month's// page: the archive of a sidebar.////   <PostArchive posts={[//       { title: "Islands, resumed", href: "/blog/islands", date: "2026-10-14" },//       { title: "One file to deploy", href: "/blog/deploy", date: "2026-07-21" },//       { title: "Hello, world", href: "/blog/hello", date: "2025-12-01" },//   ]}/>//   <PostArchive posts={posts} show_posts={false} month_href="/blog/{year}/{month}"/>//// posts: maps of { title, href, date }, in any order: date an ISO date or date-time// (a post without one is left out). open: the years shown open to start with, as ints// or text ([2026]; the newest year by default, [] for none). show_posts: list the posts// under each month (true), or only the months. month_href and year_href: where a month// or a year links, with {year} and {month} (two digits) put in ("/blog/{year}/{month}");// a year's link is "All of 2026" under its months. None, no links. locale: the month// names and dates ("en-US"; also "de-DE", "fr-FR", "es-ES", ...). title: a heading over// it (none by default). label: what screen readers call it ("Archive"). id: the// element's id.import ui/theme.qwe use ui_clslet post_archive_css = css`.ui-barch { display: grid; gap: 6px; margin: 0; font: 15px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-barch .ui-barch-title { margin: 0 0 6px; font-size: 18px; font-weight: 700; }.ui-barch-year { border-bottom: 1px solid var(--ui-border); }.ui-barch-year > summary { display: flex; align-items: center; gap: 10px; padding: 10px 2px; cursor: pointer; list-style: none; border-radius: var(--ui-radius-sm); }.ui-barch-year > summary::-webkit-details-marker { display: none; }.ui-barch-year > summary::before { content: ""; flex: none; width: 7px; height: 7px; margin: 0 4px 0 2px; border: solid var(--ui-muted);  border-width: 0 2px 2px 0; transform: rotate(-45deg); transition: transform .15s; }.ui-barch-year[open] > summary::before { transform: rotate(45deg); }.ui-barch-year > summary:hover .ui-barch-y { color: var(--ui-accent); }.ui-barch-year > summary:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-barch-y { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }.ui-barch-n { margin-left: auto; padding: 1px 9px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px;  font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-barch-months { display: grid; gap: 14px; margin: 0; padding: 2px 0 16px 24px; list-style: none; }.ui-barch .ui-barch-month { display: flex; align-items: baseline; gap: 8px; margin: 0 0 4px; font-size: 14px; font-weight: 650; color: var(--ui-muted);  letter-spacing: .03em; text-transform: uppercase; }.ui-barch-month a { color: inherit; text-decoration: none; border-radius: 3px; }.ui-barch-month a:hover { color: var(--ui-accent); }.ui-barch-month a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-barch-month small { font-size: 12px; font-weight: 500; letter-spacing: 0; text-transform: none; }.ui-barch-posts { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }.ui-barch-posts li { display: grid; grid-template-columns: 4.5em minmax(0, 1fr); gap: 10px; align-items: baseline; }.ui-barch-posts time { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-barch-posts a { color: var(--ui-fg); text-decoration: none; border-radius: 3px; }.ui-barch-posts a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-barch-posts a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-barch-all { display: inline-block; margin: -6px 0 14px 24px; color: var(--ui-accent); font-size: 14px; font-weight: 600; text-decoration: none; border-radius: 3px; }.ui-barch-all:hover { text-decoration: underline; }.ui-barch-all:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-barch-compact .ui-barch-months { gap: 2px; padding-bottom: 10px; }.ui-barch-compact .ui-barch-month { margin: 0; justify-content: space-between; font-weight: 500; text-transform: none; letter-spacing: 0;  color: var(--ui-fg); font-size: 14px; }.ui-barch-compact .ui-barch-month small { color: var(--ui-muted); font-variant-numeric: tabular-nums; }@media (prefers-reduced-motion: reduce) { .ui-barch-year > summary::before { transition: none; } }`// "/blog/{year}/{month}" with the year and the month (two digits) put in.fn post_archive_link(pattern: str?, year: str, month: str) =    pattern != null ? replace(replace(pattern, "{year}", year), "{month}", month) : null// "1 post", "12 posts".fn post_archive_count(n: int, locale: str) -> str = `${format_number(n, locale)} ${n == 1 ? "post" : "posts"}`view PostArchive(posts: list, open: list? = null, show_posts: bool = true, month_href: str? = null, year_href: str? = null,                 locale: str = "en-US", title: str? = null, label: str = "Archive", id: str? = null, class: str? = null) {    style global post_archive_css    let dated = posts | filter((p) => p.date != null && date.valid(str(p.date))) | sort_by((p) => slice(str(p.date), 0, 10)) | reverse    let years = dated | map((p) => slice(str(p.date), 0, 4)) | unique    let opened = open != null ? open | map((y) => str(y)) : take(years, 1)    let title_id = id != null ? `${id}-title` : null    <nav id={id} class={ui_cls(["ui-barch", !show_posts && "ui-barch-compact", class])}         aria-label={title != null ? null : label} aria-labelledby={title != null ? title_id : null}>        {title != null && <h2.ui-barch-title id={title_id}>{title}</h2>}        {for y in years => {            let in_year = dated | filter((p) => slice(str(p.date), 0, 4) == y)            let months = in_year | map((p) => slice(str(p.date), 5, 7)) | unique            let y_link = post_archive_link(year_href, y, "01")            <details.ui-barch-year key={y} open={contains(opened, y)} data-year={y}>                <summary>                    <span.ui-barch-y>{y}</span>                    <span.ui-barch-n>{post_archive_count(len(in_year), locale)}</span>                </summary>                <ul.ui-barch-months>                    {for m in months => {                        let in_month = in_year | filter((p) => slice(str(p.date), 5, 7) == m)                        let name = date.format(`${y}-${m}-01`, { locale, pattern: "MMMM" })                        let link = post_archive_link(month_href, y, m)                        let head = [link != null ? <a href={link}>{name}</a> : <span>{name}</span>,                                    <small>{show_posts ? `· ${post_archive_count(len(in_month), locale)}` : format_number(len(in_month), locale)}                                        {!show_posts && <span.ui-sr>{len(in_month) == 1 ? " post" : " posts"}</span>}</small>]                        <li key={m} data-month={m}>                            {show_posts ? <h3.ui-barch-month>{head}</h3> : <div.ui-barch-month>{head}</div>}                            {show_posts && <ul.ui-barch-posts>                                {for p in in_month => {                                    let d = slice(str(p.date), 0, 10)                                    <li><time datetime={d}>{date.format(d, { locale, pattern: "MMM D" })}</time><a href={p.href}>{p.title}</a></li>                                }}                            </ul>}                        </li>                    }}                </ul>                {y_link != null && <a.ui-barch-all href={y_link}>All of {y}<span aria-hidden="true"> →</span></a>}            </details>        }}    </nav>}
Thread
108Useful? Like it, or add to its thread below.

Thread 0 posts

On PostArchive: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.