News

HeadlineAnalyzer

Scores headlines for length, word balance, numbers, power and weak words, shows where search and phones cut them, and compares variants side by side.

#smart#interactive#headline#seo#news#compare

Demo

Preview

Compared 3 headlines. The best scores 90.

90StrongBest

5 Simple Ways to Write Better Headlines Today

  • Length: 8 words, 45 characters.30/30
  • Word balance: 1 common, 7 uncommon: aim for a fifth to a half common.10/20
  • Number: Has 5.10/10
  • Power words: simple, better, today20/20
  • Weak words: None.15/15
  • Shape: Reads as a headline.5/5
In a search result5 Simple Ways to Write Better Headlines Today
On a phone5 Simple Ways to Write Better HeadlinesToday

55Okay

Some Things You Might Want To Know

  • Length: 7 words, 34 characters.30/30
  • Word balance: 3 common, 4 uncommon: aim for a fifth to a half common.20/20
  • Number: A number catches the eye.0/10
  • Power words: None: try a stronger word.0/20
  • Weak words: some, things, might0/15
  • Shape: Reads as a headline.5/5
In a search resultSome Things You Might Want To Know
On a phoneSome Things You Might Want To Know

60Okay

Why the City Council Finally Approved the New Bicycle Lanes on Main Street After Years of Arguing Over Parking

  • Length: 110 characters: aim for 60 at most.0/30
  • Word balance: 7 common, 12 uncommon: aim for a fifth to a half common.20/20
  • Number: A number catches the eye.0/10
  • Power words: new20/20
  • Weak words: None.15/15
  • Shape: Reads as a headline.5/5
In a search result: cut offWhy the City Council Finally Approved the New Bicycle Lanes on...
On a phone: cut offWhy the City Council Finally Approvedthe New Bicycle Lanes on Main Street...

40 out of 100: weak.

40Weak

Tea cools fast

  • Length: 3 words: aim for 6 to 12.0/30
  • Word balance: 0 common, 3 uncommon: aim for a fifth to a half common.0/20
  • Number: A number catches the eye.0/10
  • Power words: fast20/20
  • Weak words: None.15/15
  • Shape: Reads as a headline.5/5
In a search resultTea cools fast
On a phoneTea cools fast

Change the headline to see on_change.

Get it

It needs theme.qwe, as every component does, and readability_meter.qwe; 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/readability_meter.qwe$ curl -O https://quillolang.org/components/ui/headline_analyzer.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/headline_analyzer.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/headline_analyzer.qwe
let headline_analyzer_demo_variants = ["5 Simple Ways to Write Better Headlines Today", "Some Things You Might Want To Know", "Why the City Council Finally Approved the New Bicycle Lanes on Main Street After Years of Arguing Over Parking"]component HeadlineAnalyzerDemoLive() {    state best = null    <div.demo-stack>        <HeadlineAnalyzer id="headline-analyzer-demo-live" name="title" value="Tea cools fast" on_change={(r) => { best = r.best }}/>        <p.demo-note>{best == null ? "Change the headline to see on_change." : `on_change: the best is "${best}".`}</p>    </div>}view HeadlineAnalyzerDemo() =    <div.demo-stack>        <HeadlineAnalyzer id="headline-analyzer-demo" variants={headline_analyzer_demo_variants}/>        <HeadlineAnalyzerDemoLive/>    </div>

Source

ui/headline_analyzer.qwe
// HeadlineAnalyzer: how good a headline is before it goes out. Each one gets a score out// of 100 from its length, the balance of common and uncommon words, numbers, power and// weak words and its shape, with what earned or lost the points, and shows where a search// result and a phone would cut it off. Several can be compared side by side, and the best// is marked. A headline not in the Latin alphabet is judged on length and shape alone,// and it says so, as the word lists are English.////   <HeadlineAnalyzer variants={["5 Simple Ways to Write Better Headlines", "Writing Headlines"]}/>//   <HeadlineAnalyzer value={post.title} name="title" on_change={(r) => { best = r.best }}/>//// value: one headline to start with. variants: several, in place of value (a list of// text). name: the fields' name ("headline"), so a plain <form> sends them. id: a prefix// for its ids ("headline-analyzer"), unique on the page. most: how many it compares (6).// on_change(result): inside one of your components, given { headlines, best } as they// change: headlines lists each one's analysis below, in order, and best is the text of// the highest score (the first of equals), null with none. class: more classes for its// outer element.//// headline_analyzer_of(text) is the analysis, for a program's own use: { text, words,// characters, score, grade, checks, common, uncommon, power, weak, numbers, search, phone,// capped, latin }, where score is a whole number, grade is "Strong", "Okay" or "Weak",// checks lists { id, label, ok, points, most, note }, common and uncommon count words,// power, weak and numbers list them, search is { text, cut, width } and phone// { lines, cut }.//// The points: length 30 (6 to 12 words in 60 characters; half for 4 to 14 in 70), word// balance 20 (a fifth to a half common words; half for a tenth to three fifths), a// number 10, a power word 20, no weak words 15 (5 off for each), and shape 5 (not all// capitals, no full stop at the end, no "!!"). Widths are worked out from Arial's, so// they are close to a browser's and the same on the server: a search result is one// line of 600 pixels at 20 pixels, a phone two lines of 328 at 18.import ui/theme.qwe use ui_clsimport ui/readability_meter.qwe use readability_meter_foreign_script, readability_meter_stop_sets, readability_meter_wordslet headline_analyzer_css = css`.ui-headline-analyzer { display: grid; gap: 14px; min-width: 0; font: 14px/1.5 var(--ui-font); color: var(--ui-fg); }.ui-headline-analyzer-fields { display: grid; gap: 8px; min-width: 0; }.ui-headline-analyzer-row { display: grid; gap: 4px; min-width: 0; }.ui-headline-analyzer-row label { font-size: 13px; font-weight: 500; }.ui-headline-analyzer-entry { display: flex; gap: 6px; min-width: 0; }.ui-headline-analyzer-entry input { box-sizing: border-box; flex: 1; min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); }.ui-headline-analyzer-entry input:hover { border-color: var(--ui-strong); }.ui-headline-analyzer-entry input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-headline-analyzer-btn { flex: none; height: 36px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg);  font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-headline-analyzer-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-headline-analyzer-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-headline-analyzer-add { justify-self: start; }.ui-headline-analyzer-said { margin: 0; font-weight: 600; }.ui-headline-analyzer-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; min-width: 0; }.ui-headline-analyzer-card { display: grid; align-content: start; gap: 10px; min-width: 0; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); --k: var(--ui-muted); }.ui-headline-analyzer-strong { --k: var(--ui-success); }.ui-headline-analyzer-okay { --k: var(--ui-warning); }.ui-headline-analyzer-weak { --k: var(--ui-danger); }.ui-headline-analyzer-best { border-color: var(--ui-success); }.ui-headline-analyzer-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; margin: 0; }.ui-headline-analyzer-score { font-size: 26px; font-weight: 700; line-height: 1.1; color: var(--k); font-variant-numeric: tabular-nums; }.ui-headline-analyzer-grade { font-weight: 600; }.ui-headline-analyzer-flag { padding: 1px 7px; border-radius: 999px; background: var(--ui-success-soft); color: var(--ui-success); font-size: 12px; font-weight: 600; }.ui-headline-analyzer-name { margin: 0; font-weight: 600; overflow-wrap: anywhere; }.ui-headline-analyzer-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-headline-analyzer-checks { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; font-size: 13px; }.ui-headline-analyzer-checks li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }.ui-headline-analyzer-checks b { font-weight: 600; font-variant-numeric: tabular-nums; }.ui-headline-analyzer-checks span { overflow-wrap: anywhere; }.ui-headline-analyzer-lost b { color: var(--ui-danger); }.ui-headline-analyzer-view { display: grid; gap: 2px; padding: 8px 10px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font-size: 13px; }.ui-headline-analyzer-view small { color: var(--ui-muted); font-size: 12px; }.ui-headline-analyzer-cutoff { color: var(--ui-info); overflow-wrap: anywhere; }`// Arial's widths, in thousandths of the type size, by the characters that share one.let headline_analyzer_width_groups = [[191, "'"], [222, "ijl"], [260, "|"], [278, " .,:;!/\\[]tfI"], [333, "-()r{}`"], [355, "\""], [500, "cksvxyzJ"], [584, "+<=>~"],                                      [667, "&ABEKPSVXY"], [722, "CDHNRUw"], [778, "GOQ"], [833, "m"], [889, "%"], [944, "W"], [1015, "@"], [611, "FTZ"]]let headline_analyzer_widths = reduce(headline_analyzer_width_groups, {}, (acc, g) => reduce(split(g[1], ""), acc, (m, ch) => set(m, ch, g[0])))// The width of each character: 556 is Arial's digit and common small letter, 1000 the// square of Chinese, Japanese, Korean and emoji.fn headline_analyzer_char_width(ch: str) -> int {    let w = get(headline_analyzer_widths, ch, null)    if w != null { return w }    re.test(ch, "[ᄀ-ᇿ⺀-힯豈-﫿︰-﹏＀-⦆\u{1F000}-\u{1FAFF}]") ? 1000 : 556}// How wide some text comes out at a size in pixels.fn headline_analyzer_width(text: str, size) = (split(text, "") | map(headline_analyzer_char_width) | sum()) * size / 1000// The most of `text` that fits `room` pixels with "..." after it, by whole words where// there is a space to cut at, as search engines cut: { text, cut }.fn headline_analyzer_fit(text: str, room, size) {    if headline_analyzer_width(text, size) <= room { return { text, cut: false } }    let budget = room - headline_analyzer_width("...", size)    let chars = split(text, "")    let used = 0    let i = 0    while i < len(chars) && used + headline_analyzer_char_width(chars[i]) * size / 1000 <= budget {        used += headline_analyzer_char_width(chars[i]) * size / 1000        i += 1    }    let fits = i    let space = fits    while space > 0 && chars[space] != " " { space -= 1 }    let upto = space > fits / 2 ? space : fits    { text: trim(join(take(chars, upto), "")) + "...", cut: true }}// The text in `most` lines of `room` pixels: { lines, cut }.fn headline_analyzer_wrap(text: str, room, size, most: int) {    let lines = []    let line = ""    let words = split(text, " ")    let i = 0    while i < len(words) {        let next = line == "" ? words[i] : `${line} ${words[i]}`        if headline_analyzer_width(next, size) <= room || line == "" { line = next; i += 1 }        else { lines = push(lines, line); line = "" }    }    lines = line == "" ? lines : push(lines, line)    if len(lines) <= most && all(lines, (l) => headline_analyzer_width(l, size) <= room) { return { lines, cut: false } }    let kept = take(lines, most - 1)    let rest = join(drop(lines, most - 1), " ")    { lines: push(kept, headline_analyzer_fit(rest, room, size).text), cut: true }}let headline_analyzer_power_words = "amazing best better breakthrough complete easy effortless essential exclusive fast free guaranteed instant new powerful proven quick remarkable secret shocking simple surprising today ultimate".split(" ")let headline_analyzer_weak_words = "maybe might nice okay perhaps possibly probably pretty quite really some somewhat stuff thing things very".split(" ")let headline_analyzer_number_words = "one two three four five six seven eight nine ten eleven twelve hundred thousand million billion".split(" ")// The most characters read, as nothing past a tweet's length is a headline.let headline_analyzer_cap = 300// The analysis (see the top of the file).fn headline_analyzer_of(text: str) {    let capped = len(text) > headline_analyzer_cap    let t = trim(capped ? slice(text, 0, headline_analyzer_cap) : text)    let words = readability_meter_words(t) | map((w) => replace(lower(w.text), "’", "'"))    let n = len(words)    let chars = len(t)    let latin = readability_meter_foreign_script(t) == null    let stop = readability_meter_stop_sets.en    let common = count(words, (w) => has(stop, w))    let power = words | filter((w) => contains(headline_analyzer_power_words, w)) | unique()    let weak = words | filter((w) => contains(headline_analyzer_weak_words, w)) | unique()    let numbers = words | filter((w) => re.test(w, "^[0-9]") || contains(headline_analyzer_number_words, w))    let share = n == 0 ? 0 : common / n    let length_points = n >= 6 && n <= 12 && chars <= 60 ? 30 : n >= 4 && n <= 14 && chars <= 70 ? 15 : 0    let length_note = chars > 60 ? `${chars} characters: aim for 60 at most.` : n < 6 ? `${n} ${n == 1 ? "word" : "words"}: aim for 6 to 12.` : n > 12 ? `${n} words: aim for 6 to 12.` : `${n} words, ${chars} characters.`    let balance_points = share >= 0.2 && share <= 0.5 ? 20 : share >= 0.1 && share <= 0.6 ? 10 : 0    let loud = re.test(t, "[A-Z]{2}") && upper(t) == t    let shape_ok = !loud && !ends_with(t, ".") && !contains(t, "!!")    let shape_note = loud ? "All capitals read as shouting." : ends_with(t, ".") ? "A headline ends without a full stop." : contains(t, "!!") ? "One exclamation mark is plenty." : "Reads as a headline."    let weak_points = max([0, 15 - 5 * len(weak)])    let checks = [        { id: "length", label: "Length", ok: length_points == 30, points: length_points, most: 30, note: length_note },        ...(latin ? [            { id: "balance", label: "Word balance", ok: balance_points == 20, points: balance_points, most: 20,              note: `${common} common, ${n - common} uncommon: aim for a fifth to a half common.` },            { id: "number", label: "Number", ok: len(numbers) > 0, points: len(numbers) > 0 ? 10 : 0, most: 10, note: len(numbers) > 0 ? `Has ${join(numbers, ", ")}.` : "A number catches the eye." },            { id: "power", label: "Power words", ok: len(power) > 0, points: len(power) > 0 ? 20 : 0, most: 20, note: len(power) > 0 ? join(power, ", ") : "None: try a stronger word." },            { id: "weak", label: "Weak words", ok: len(weak) == 0, points: weak_points, most: 15, note: len(weak) > 0 ? join(weak, ", ") : "None." },        ] : []),        { id: "shape", label: "Shape", ok: shape_ok, points: shape_ok ? 5 : 0, most: 5, note: shape_note },    ]    let earned = checks | map((c) => c.points) | sum()    let possible = checks | map((c) => c.most) | sum()    let score = round(earned * 100 / possible)    { text: t, words: n, characters: chars, score, grade: score >= 70 ? "Strong" : score >= 45 ? "Okay" : "Weak", checks, common, uncommon: n - common, power, weak, numbers,      search: { ...headline_analyzer_fit(t, 600, 20), width: round(headline_analyzer_width(t, 20)) }, phone: headline_analyzer_wrap(t, 328, 18, 2), capped, latin }}// The highest score of a list of analyses, the first of equals; null for none.fn headline_analyzer_best(results: list) {    if len(results) == 0 { return null }    reduce(results, results[0], (b, r) => r.score > b.score ? r : b).text}component HeadlineAnalyzer(value: str = "", variants: list? = null, name: str = "headline", id: str = "headline-analyzer", most: int = 6, on_change = null, class: str? = null) {    style global headline_analyzer_css    state rows = (variants ?? [value]) | enumerate | map((p) => { key: p[0], text: str(p[1] ?? "") })    state next_key = len(variants ?? [value])    state focus_key = -1    let filled_rows = rows | filter((r) => trim(r.text) != "")    let results = filled_rows | map((r) => { key: r.key, ...headline_analyzer_of(r.text) })    let best = len(results) > 1 ? headline_analyzer_best(results) : null    let said = len(results) == 0 ? "Type a headline to score it."        : len(results) == 1 ? `${results[0].score} out of 100: ${lower(results[0].grade)}.`        : `Compared ${len(results)} headlines. The best scores ${max(results | map((r) => r.score))}.`    let tell = (next: list) => {        if on_change != null {            let done = next | filter((r) => trim(r.text) != "") | map((r) => headline_analyzer_of(r.text))            on_change({ headlines: done, best: headline_analyzer_best(done) })        }    }    let edit = (key: int, text: str) => {        rows = rows | map((r) => r.key == key ? { key, text } : r)        tell(rows)    }    let add = () => {        focus_key = next_key        rows = push(rows, { key: next_key, text: "" })        next_key = next_key + 1    }    let drop_row = (e, key: int) => {        // Its button goes with it, so the focus moves to the field next to it.        let left = rows | filter((r) => r.key != key)        let after = rows | filter((r) => r.key > key)        let next = len(after) > 0 ? first(after) : last(left)        e.target.closest(".ui-headline-analyzer").querySelector(`[id="${id}-${next.key}"]`).focus()        rows = left        tell(rows)    }    <section class={ui_cls(["ui-headline-analyzer", class])} aria-labelledby={`${id}-said`}>        <div.ui-headline-analyzer-fields>            {for [i, r] in enumerate(rows) => <div.ui-headline-analyzer-row key={r.key}>                <label for={`${id}-${r.key}`}>{len(rows) == 1 ? "Headline" : `Headline ${i + 1}`}</label>                <div.ui-headline-analyzer-entry>                    <input type="text" id={`${id}-${r.key}`} name={name} value={r.text} autocomplete="off" @mount={(el) => { if focus_key == r.key { el.focus() } }}                           @input={(e) => { edit(r.key, e.value) }}/>                    {len(rows) > 1 && <button.ui-headline-analyzer-btn type="button" aria-label={`Remove headline ${i + 1}`} @click={(e) => { drop_row(e, r.key) }}>Remove</button>}                </div>            </div>}            {len(rows) < most && <button.ui-headline-analyzer-btn.ui-headline-analyzer-add type="button" @click={() => { add() }}>Add a variant</button>}        </div>        <p.ui-headline-analyzer-said id={`${id}-said`} aria-live="polite">{said}</p>        {len(results) > 0 && <div.ui-headline-analyzer-cards>            {for r in results => <article key={r.key} class={ui_cls(["ui-headline-analyzer-card", `ui-headline-analyzer-${lower(r.grade)}`, best != null && r.text == best && "ui-headline-analyzer-best"])}>                <p.ui-headline-analyzer-top>                    <span.ui-headline-analyzer-score>{r.score}</span>                    <span.ui-headline-analyzer-grade>{r.grade}</span>                    {best != null && r.text == best && <span.ui-headline-analyzer-flag>Best</span>}                </p>                <h4.ui-headline-analyzer-name>{r.text}</h4>                {!r.latin && <p.ui-headline-analyzer-note>The word lists are English, so this is judged on length and shape alone.</p>}                {r.capped && <p.ui-headline-analyzer-note>Only the first {headline_analyzer_cap} characters were read.</p>}                <ul.ui-headline-analyzer-checks>                    {for c in r.checks => <li key={c.id} class={c.ok ? null : "ui-headline-analyzer-lost"}>                        <span>{c.label}: {c.note}</span><b>{c.points}/{c.most}</b>                    </li>}                </ul>                <div.ui-headline-analyzer-view>                    <small>In a search result{r.search.cut ? ": cut off" : ""}</small>                    <span.ui-headline-analyzer-cutoff>{r.search.text}</span>                </div>                <div.ui-headline-analyzer-view>                    <small>On a phone{r.phone.cut ? ": cut off" : ""}</small>                    {for [i, l] in enumerate(r.phone.lines) => <span.ui-headline-analyzer-cutoff key={i}>{l}</span>}                </div>            </article>}        </div>}    </section>}
Thread
48Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.