Blogging

Blogroll

The blogs a writer recommends, in groups: name, a line about each, its latest post and how long ago, its feed, and the list as OPML.

#blogging#list#links#feeds#no-js

Demo

Preview

Blogroll

  • The Morning Paperby Adrian Colyer

    A computer science paper a day, read and explained.

Programming

Elsewhere

  • Low Tech Magazine

    A website that runs on solar power, and goes offline when it is cloudy.

    LatestHow to keep warm in a cold house

All of them for your feed reader (OPML)

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/blogroll.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/blogroll.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/blogroll.qwe
view BlogrollDemo() =    <div style="max-width: 560px">        <Blogroll id="blogroll-demo" today="2026-10-30" opml="#blogroll-opml" blogs={[            { name: "The Morning Paper", href: "https://blog.acolyer.example", author: "Adrian Colyer", feed: "https://blog.acolyer.example/feed",              description: "A computer science paper a day, read and explained." },            { name: "Julia Evans", href: "https://jvns.example", feed: "https://jvns.example/atom.xml", group: "Programming",              description: "Comics and notes about how computers work.",              latest: { title: "Notes on git's data model", href: "https://jvns.example/blog/git", date: "2026-10-27" } },            { name: "Simon's weblog", href: "https://simon.example", group: "Programming", feed: "https://simon.example/atom/everything/",              latest: { title: "Running a database in the browser", href: "https://simon.example/2026/Oct/5/", date: "2026-10-05" } },            { name: "Low Tech Magazine", href: "https://solar.lowtech.example", group: "Elsewhere",              description: "A website that runs on solar power, and goes offline when it is cloudy.",              latest: { title: "How to keep warm in a cold house", date: "2025-11-20" } },        ]}/>    </div>

Source

ui/blogroll.qwe
// Blogroll: the blogs a writer reads and recommends, each with a badge of its first// letter (or its icon), its name, a line about it, who writes it, its latest post and// how long ago that was, and a link to its feed; in groups when they have one, with a// link to the whole list as OPML for a feed reader.////   <Blogroll today="2026-10-30" opml="/blogroll.opml" blogs={[//       { name: "Julia Evans", href: "https://jvns.ca", feed: "https://jvns.ca/atom.xml",//         description: "Comics and notes about how computers work.", group: "Programming",//         latest: { title: "Notes on git", href: "https://jvns.ca/blog/git", date: "2026-10-27" } },//       { name: "Low Tech Magazine", href: "https://solar.lowtechmagazine.com", group: "Elsewhere" },//   ]}/>//// blogs: maps of { name, href, description?, author?, icon?, feed?, latest?, group? }: icon a// small picture in place of the letter; feed the address of its RSS or Atom feed; latest// { title, href?, date? } its newest post, date an ISO date. group: a heading the blog is// listed under, groups in the order they first come (blogs without one first, unheaded).// today: an ISO date to count "3 days ago" from (else the date is written out: the// server's day would be cached in the page). title: the heading ("Blogroll"; "" for none).// opml: the address of the list as OPML, offered under it. locale: how dates are written// ("en-US"). Links open in a new tab. id: the <section>'s id ("blogroll").import ui/theme.qwe use ui_clslet blogroll_css = css`.ui-broll { display: grid; gap: 14px; margin: 0; font: 15px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-broll .ui-broll-title { margin: 0; font-size: 20px; font-weight: 700; }.ui-broll .ui-broll-group { margin: 8px 0 0; color: var(--ui-muted); font-size: 12px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; }.ui-broll-list { display: grid; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ui-border); }.ui-broll-blog { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 2px 12px; align-items: start; padding: 12px 0;  border-bottom: 1px solid var(--ui-border); }.ui-broll-badge { grid-row: span 3; display: grid; place-items: center; width: 36px; height: 36px; overflow: hidden; border-radius: var(--ui-radius-sm);  background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 16px; font-weight: 700; }.ui-broll-badge img { width: 100%; height: 100%; object-fit: cover; }.ui-broll-name { min-width: 0; font-weight: 650; }.ui-broll-name a { color: var(--ui-fg); text-decoration: none; border-radius: 3px; }.ui-broll-name a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-broll-name a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-broll-author { margin-left: 6px; color: var(--ui-muted); font-size: 13px; font-weight: 400; }.ui-broll-feed { grid-row: 1; grid-column: 3; display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: var(--ui-radius-sm);  color: var(--ui-muted); }.ui-broll-feed:hover { background: var(--ui-warning-soft); color: var(--ui-warning); }.ui-broll-feed:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-broll-feed svg { width: 16px; height: 16px; }.ui-broll-about { grid-column: 2; margin: 0; color: var(--ui-muted); font-size: 14px; }.ui-broll-latest { grid-column: 2 / -1; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 6px; margin: 2px 0 0; font-size: 13px; }.ui-broll-latest small { color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }.ui-broll-latest a { color: var(--ui-fg); text-decoration: none; border-radius: 3px; }.ui-broll-latest a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-broll-latest a:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-broll-latest time { color: var(--ui-muted); white-space: nowrap; }.ui-broll-latest time::before { content: "·"; margin-right: 6px; }.ui-broll-fresh time { color: var(--ui-success); font-weight: 600; }.ui-broll-opml { display: inline-flex; align-items: center; gap: 6px; justify-self: start; color: var(--ui-accent); font-size: 14px; font-weight: 600;  text-decoration: none; border-radius: 3px; }.ui-broll-opml:hover { text-decoration: underline; }.ui-broll-opml:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-broll-opml svg { width: 15px; height: 15px; }`// How long ago an ISO date was, counted from today: "today", "yesterday", "3 days ago",// "2 weeks ago", "5 months ago", "2 years ago"; null without today.fn blogroll_ago(d: str, today: str?) {    if today == null || !date.valid(d) || !date.valid(today) { return null }    let n = date.days_between(slice(d, 0, 10), slice(today, 0, 10))    if n <= 0 { return "today" }    if n == 1 { return "yesterday" }    if n < 14 { return `${n} days ago` }    if n < 60 { return `${floor(n / 7)} weeks ago` }    if n < 730 { return `${floor(n / 30)} months ago` }    `${floor(n / 365)} years ago`}view Blogroll(blogs: list, title: str = "Blogroll", today: str? = null, opml: str? = null, locale: str = "en-US",              id: str = "blogroll", class: str? = null) {    style global blogroll_css    let named = blogs | map((b) => b.group) | filter((g) => g != null) | unique    let groups = (blogs | any((b) => b.group == null) ? [null] : []) + named    let feed_icon = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">        <path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.4" fill="currentColor"/></svg>    let blog = (b) => {        let latest = b.latest        let when = latest != null && latest.date != null ? str(latest.date) : null        let ago = when != null ? blogroll_ago(when, today) : null        let fresh = when != null && today != null && date.valid(when) && date.valid(today) && date.days_between(slice(when, 0, 10), slice(today, 0, 10)) <= 7        let letter = upper(slice(re.replace(b.name, "^(the|a)\\s+", "", "i"), 0, 1))        <li.ui-broll-blog key={b.href}>            <span.ui-broll-badge aria-hidden="true">{b.icon != null ? <img src={b.icon} alt="" loading="lazy"/> : letter}</span>            <span.ui-broll-name>                <a href={b.href} target="_blank" rel="noopener">{b.name}</a>                {b.author != null && <span.ui-broll-author>by {b.author}</span>}            </span>            {b.feed != null ? <a.ui-broll-feed href={b.feed} aria-label={`Feed of ${b.name}`} title="Feed">{feed_icon}</a> : <span></span>}            {b.description != null && <p.ui-broll-about>{b.description}</p>}            {latest != null && <p class={ui_cls(["ui-broll-latest", fresh && "ui-broll-fresh"])}>                <small>Latest</small>                {latest.href != null ? <a href={latest.href} target="_blank" rel="noopener">{latest.title}</a> : <span>{latest.title}</span>}                {when != null && <time datetime={when} title={date.valid(when) ? date.format(slice(when, 0, 10), { locale, style: "long" }) : null}>                    {ago ?? (date.valid(when) ? date.format(slice(when, 0, 10), { locale, style: "medium" }) : when)}                </time>}            </p>}        </li>    }    <section id={id} class={ui_cls(["ui-broll", class])} aria-labelledby={title != "" ? `${id}-title` : null}             aria-label={title == "" ? "Blogroll" : null}>        {title != "" && <h2.ui-broll-title id={`${id}-title`}>{title}</h2>}        {for g in groups => {            let in_group = blogs | filter((b) => b.group == g)            let gid = g != null ? `${id}-${re.replace(lower(g), "[^a-z0-9]+", "-")}` : null            [g != null && <h3.ui-broll-group id={gid}>{g}</h3>,             <ul.ui-broll-list aria-labelledby={gid}>{for b in in_group => blog(b)}</ul>]        }}        {opml != null && <a.ui-broll-opml href={opml} download>{feed_icon}All of them for your feed reader (OPML)</a>}    </section>}
Thread
98Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.