SeoPreview
A post's title and description for search and sharing, with counts that say short, good or too long, and a live search result and link card.
Demo
The post's title is used. 2 over: it will be cut62 / 60
Short: 120 or more is better81 / 160
Quillo blogblog.example.com › 2026 › islands-resumed
Islands, resumed: how a page wakes up where the server…
The browser takes the server's elements as its own instead of drawing them again.
blog.example.comIslands, resumed: how a page wakes up where the server left itThe browser takes the server's elements as its own instead of drawing them again.
Not changed yet.
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/seo_preview.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/seo_preview.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/seo_preview.qwe
component SeoPreviewDemoLive() { state got = "Not changed yet." <div.demo-stack> <SeoPreview name="post" slug="2026/islands-resumed" site="Quillo blog" base_url="https://blog.example.com/" fallback="Islands, resumed: how a page wakes up where the server left it" description="The browser takes the server's elements as its own instead of drawing them again." on_change={(m) => { got = `${len(m.title)} + ${len(m.description)} characters` }}/> <p.demo-note id="seo-preview-demo-note">{got}</p> </div>}view SeoPreviewDemo() = <SeoPreviewDemoLive/>Source
ui/seo_preview.qwe
// SeoPreview: a post's title and description for search engines and social sites,// with how each will look there as they are typed: the result in a search (cut where// search engines cut it, with "…") or the card a link makes when it is shared. A count// under each box says too short, good or too long. The boxes are real fields named// `seo_title` and `seo_description`, so a plain <form> sends them.//// <SeoPreview title={post.title} description={post.summary} slug="islands-resumed" site="Quillo blog"// base_url="https://blog.example.com/" image="/img/islands.jpg"/>// <SeoPreview title={t} on_change={(m) => { seo = m }}/>//// title, description: to start with. slug: the post's address after base_url// ("https://blog.example.com/"). site: the blog's name, shown over the result and on the// card ("My blog"). image: the picture of the card (none shows a plain one). title_max// (60) and description_max (160): the lengths search engines show, in characters; a// title is good from half of it, a description from three quarters. fallback: the post's// own title, used (and counted) when the title box is empty. preview: "search" (default)// or "social", the preview shown to start with. name: put before the fields' names and// ids ("post" makes post_seo_title). on_change(m): inside one of your components, given// { title, description } as they are typed.import ui/theme.qwe use ui_clslet seo_preview_css = css`.ui-bseo { display: grid; gap: 16px; min-width: 0; max-width: 640px; margin: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-bseo-field { display: grid; gap: 6px; }.ui-bseo-label { font-weight: 600; }.ui-bseo-field input, .ui-bseo-field textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); resize: vertical; }.ui-bseo-field input:focus, .ui-bseo-field textarea:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-bseo-count { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 10px; color: var(--ui-muted); font-size: 12px; --k: var(--ui-muted); }.ui-bseo-count b { color: var(--k); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-bseo-meter { grid-column: 1 / -1; height: 4px; overflow: hidden; border-radius: 2px; background: var(--ui-subtle); }.ui-bseo-meter span { display: block; height: 100%; background: var(--k); transition: width .15s; }.ui-bseo-good { --k: var(--ui-success); } .ui-bseo-long { --k: var(--ui-danger); } .ui-bseo-short { --k: var(--ui-warning); }.ui-bseo-tabs { display: inline-flex; gap: 2px; justify-self: start; padding: 3px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-bseo-tabs button { height: 28px; padding: 0 12px; border: 0; border-radius: calc(var(--ui-radius-sm) - 2px); background: none; color: var(--ui-muted); font: 600 13px var(--ui-font); cursor: pointer; }.ui-bseo-tabs button[aria-selected="true"] { background: var(--ui-surface); color: var(--ui-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .08); }.ui-bseo-tabs button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-bseo-panel { padding: 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-bg); }.ui-bseo-result { display: grid; gap: 3px; max-width: 600px; }.ui-bseo-site { display: flex; align-items: center; gap: 10px; }.ui-bseo-favicon { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--ui-subtle); color: var(--ui-accent); font-size: 12px; font-weight: 700; }.ui-bseo-site span { display: grid; line-height: 1.3; }.ui-bseo-site small { color: var(--ui-muted); font-size: 12px; }.ui-bseo-result-title { margin: 4px 0 0; color: var(--ui-info); font-size: 19px; font-weight: 400; line-height: 1.3; }.ui-bseo-result-text { margin: 0; color: var(--ui-muted); font-size: 13.5px; }.ui-bseo-none { font-style: italic; }.ui-bseo-card { max-width: 500px; overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-bseo-card-pic { display: block; width: 100%; aspect-ratio: 1.91 / 1; object-fit: cover; background: var(--ui-subtle); }.ui-bseo-card-blank { display: grid; place-items: center; color: var(--ui-strong); font-size: 40px; font-weight: 800; background: linear-gradient(135deg, var(--ui-accent-soft), var(--ui-subtle)); }.ui-bseo-card-body { display: grid; gap: 2px; padding: 10px 14px 12px; border-top: 1px solid var(--ui-border); }.ui-bseo-card-body small { color: var(--ui-muted); font-size: 12px; letter-spacing: .03em; text-transform: uppercase; }.ui-bseo-card-body b { overflow: hidden; font-size: 15px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }.ui-bseo-card-body span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--ui-muted); font-size: 13px; }@media (prefers-reduced-motion: reduce) { .ui-bseo-meter span { transition: none; } }`// Text cut to at most n characters as a search engine shows it: at a word, with "…".fn seo_preview_cut(text: str, n: int) -> str { let t = trim(text) if len(t) <= n { return t } let head = slice(t, 0, n - 1) let at_space = re.replace(head, "\\s+\\S*$", "") `${len(at_space) > n / 2 ? at_space : head}…`}// Where a length stands: "short", "good" or "long".fn seo_preview_mood(n: int, least: int, most: int) -> str = n > most ? "long" : (n < least ? "short" : "good")component SeoPreview(title: str = "", description: str = "", slug: str = "", site: str = "My blog", base_url: str = "https://blog.example.com/", image: str? = null, title_max: int = 60, description_max: int = 160, fallback: str? = null, preview: str = "search", name: str? = null, on_change = null, class: str? = null) { style global seo_preview_css let field = (f: str) => name != null ? `${name}_${f}` : f let id = `seo-preview-${name ?? "post"}` state t = title state d = description state tab = preview == "social" ? "social" : "search" let shown_title = trim(t) != "" ? trim(t) : (fallback ?? "") let tn = len(shown_title) let dn = len(trim(d)) let t_mood = seo_preview_mood(tn, floor(title_max / 2), title_max) let d_mood = seo_preview_mood(dn, floor(description_max * 3 / 4), description_max) let say = (mood: str, n: int, most: int, least: int) => match mood { "long" => `${n - most} over: it will be cut` "short" => n == 0 ? "Empty" : `Short: ${least} or more is better` _ => "Good" } let host = re.replace(base_url, "^https?://", "") let crumbs = join(split(re.replace(`${host}${slug}`, "/+$", ""), "/") | filter((p) => p != ""), " › ") let changed = () => { on_change && on_change({ title: t, description: d }) } let pick = (which: str, e) => { tab = which e.target.closest("[role=tablist]").querySelector(`[data-tab="${which}"]`).focus() } let meter = (n: int, most: int) => <span.ui-bseo-meter aria-hidden="true"><span style={`width: ${n >= most ? 100 : floor(n * 100 / most)}%`}></span></span> <div class={ui_cls(["ui-bseo", class])}> <div.ui-bseo-field> <label.ui-bseo-label for={`${id}-title`}>Title for search and sharing</label> <input type="text" id={`${id}-title`} name={field("seo_title")} value={t} placeholder={fallback} aria-describedby={`${id}-title-count`} @input={(e) => { t = e.value; changed() }}/> <div class={ui_cls(["ui-bseo-count", `ui-bseo-${t_mood}`])} id={`${id}-title-count`} data-mood={t_mood}> <span>{trim(t) == "" && fallback != null ? "The post's title is used. " : ""}{say(t_mood, tn, title_max, floor(title_max / 2))}</span> <b>{tn} / {title_max}</b> {meter(tn, title_max)} </div> </div> <div.ui-bseo-field> <label.ui-bseo-label for={`${id}-description`}>Description</label> <textarea id={`${id}-description`} name={field("seo_description")} value={d} rows="3" aria-describedby={`${id}-description-count`} @input={(e) => { d = e.value; changed() }}></textarea> <div class={ui_cls(["ui-bseo-count", `ui-bseo-${d_mood}`])} id={`${id}-description-count`} data-mood={d_mood}> <span>{say(d_mood, dn, description_max, floor(description_max * 3 / 4))}</span> <b>{dn} / {description_max}</b> {meter(dn, description_max)} </div> </div> <div.ui-bseo-tabs role="tablist" aria-label="Preview" @keydown={(e) => { if e.key == "ArrowRight" || e.key == "ArrowLeft" || e.key == "Home" || e.key == "End" { e.preventDefault() pick(e.key == "Home" ? "search" : (e.key == "End" ? "social" : (tab == "search" ? "social" : "search")), e) } }}> <button type="button" role="tab" id={`${id}-tab-search`} data-tab="search" aria-selected={tab == "search" ? "true" : "false"} aria-controls={`${id}-search`} tabindex={tab == "search" ? "0" : "-1"} @click={() => { tab = "search" }}>Search result</button> <button type="button" role="tab" id={`${id}-tab-social`} data-tab="social" aria-selected={tab == "social" ? "true" : "false"} aria-controls={`${id}-social`} tabindex={tab == "social" ? "0" : "-1"} @click={() => { tab = "social" }}>Shared link</button> </div> <div.ui-bseo-panel id={`${id}-search`} role="tabpanel" aria-labelledby={`${id}-tab-search`} hidden={tab != "search"}> <div.ui-bseo-result> <div.ui-bseo-site> <span.ui-bseo-favicon aria-hidden="true">{upper(slice(site, 0, 1))}</span> <span>{site}<small>{crumbs}</small></span> </div> <p.ui-bseo-result-title>{seo_preview_cut(shown_title != "" ? shown_title : "Untitled post", title_max)}</p> {dn > 0 ? <p.ui-bseo-result-text>{seo_preview_cut(d, description_max)}</p> : <p.ui-bseo-result-text.ui-bseo-none>With no description, search engines pick a passage of the post.</p>} </div> </div> <div.ui-bseo-panel id={`${id}-social`} role="tabpanel" aria-labelledby={`${id}-tab-social`} hidden={tab != "social"}> <div.ui-bseo-card> {image != null ? <img.ui-bseo-card-pic src={image} alt=""/> : <span.ui-bseo-card-pic.ui-bseo-card-blank aria-hidden="true">{upper(slice(site, 0, 1))}</span>} <div.ui-bseo-card-body> <small>{re.replace(host, "/.*$", "")}</small> <b>{shown_title != "" ? shown_title : "Untitled post"}</b> {dn > 0 && <span>{trim(d)}</span>} </div> </div> </div> </div>}Related
PublishPanelThe editor's panel that puts a post out: its status, who may read it, now or scheduled, its address, then Save draft and Publish or Schedule.8 views · 9 downloadsFeedSubscribeFollow this blog: its RSS, Atom or JSON feeds with their addresses to copy, links into feed readers, what a feed is, and an email box.8 views · 7 downloadsSeoAuditAudits a post's title, description, slug and HTML or Markdown body: lengths, headings, alt text, keyword placement and links, with a score and how to fix each failure.3 views · 6 downloadsAuthorBioThe box at the end of a post about its author: picture or initials, name and role, a short bio, links elsewhere, their post count and a follow link.8 views · 9 downloadsBlogrollThe 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.9 views · 8 downloadsClapButtonApplause for a post: each press adds a clap up to a limit per reader, a +3 rises for presses in a row, and a small × takes them back.8 views · 9 downloads
Thread
Thread 0 posts
On SeoPreview: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.