SeoAudit
Audits 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.
Demo
17 of 60 characters
25 of 160 characters
Score 45 of 100. 8 of 15 checks passed, 7 to fix.
Read as HTML.
- Words
- 32
- Headings
- 2
- Images
- 2
- Internal links
- 1
- External links
- 1
- Title length17 characters.Say more: aim for 30 to 60 characters.
- Description length25 characters.Write a full sentence or two: 70 to 160 characters.
- SlugIt has capital letters.Use small letters, numbers and hyphens, in 60 characters or fewer.
- Heading order"What you need" is an H3 after an H1.Go down one level at a time: H2 under H1, H3 under H2.
- Image descriptions1 image has no alt text: /img/beans.jpg.Describe each image in its alt text.
- Body length32 words.Write at least 300 words: thin pages rank poorly.
- Keyword share of the text12.5% of the words.Use it less: over 3% reads as stuffing.
Passed (8)
- One main heading1 H1 found.
- Internal links1 internal link.
- External links1 external link.
- Keyword in the titleFound.
- Keyword in the descriptionFound.
- Keyword in the slugFound.
- Keyword in the first paragraphFound.
- Keyword in a headingFound.
23 of 60 characters
0 of 160 characters
Score 59 of 100. 9 of 15 checks passed, 6 to fix.
Read as Markdown.
- Words
- 17
- Headings
- 2
- Images
- 1
- Internal links
- 1
- External links
- 0
- Title length23 characters.Say more: aim for 30 to 60 characters.
- Description length0 characters.Write a full sentence or two: 70 to 160 characters.
- External links0 external links.Link to a source that backs up what you say.
- Body length17 words.Write at least 300 words: thin pages rank poorly.
- Keyword in the descriptionNot found.Say "tea" in the description.
- Keyword share of the text11.8% of the words.Use it less: over 3% reads as stuffing.
Passed (9)
- SlugShort, in small letters, with hyphens.
- One main heading1 H1 found.
- Heading orderNo level is skipped.
- Image descriptionsAll 1 image has alt text.
- Internal links1 internal link.
- Keyword in the titleFound.
- Keyword in the slugFound.
- Keyword in the first paragraphFound.
- Keyword in a headingFound.
Change a field 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/seo_audit.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/seo_audit.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/seo_audit.qwe
let seo_audit_demo_body = "<h1>Cold brew at home</h1>\n<p>Cold brew is easier than it looks, and it keeps for a week.</p>\n<img src=\"/img/jar.jpg\" alt=\"A jar of cold brew\">\n<img src=\"/img/beans.jpg\">\n<h3>What you need</h3>\n<p>Coarse beans, a jar and <a href=\"/guides/grind\">the right grind</a>. See the <a href=\"https://example.org/brew\">brewing study</a>.</p>"let seo_audit_demo_markdown = "# Tea at home\n\nTea at home takes three minutes.\n\n## Steps\n\n\n\nBoil the water, then read [our guide](/guides/tea)."component SeoAuditDemoLive() { state score = null <div.demo-stack> <SeoAudit id="seo-audit-demo-live" name="post" title="How to make tea at home" slug="tea-at-home" keyword="tea" body={seo_audit_demo_markdown} rows={6} on_change={(r) => { score = r.score }}/> <p.demo-note>{score == null ? "Change a field to see on_change." : `on_change: the score is ${score}.`}</p> </div>}view SeoAuditDemo() = <div.demo-stack> <SeoAudit id="seo-audit-demo" title="Cold brew at home" description="Cold brew in three steps." slug="Cold_Brew" keyword="cold brew" body={seo_audit_demo_body} base_url="https://blog.example.com/"/> <SeoAuditDemoLive/> </div>Source
ui/seo_audit.qwe
// SeoAudit: a post checked against what search engines look for, as it is typed. Give it// the title, description, slug and body (HTML or Markdown, worked out from the text) and// a focus keyword: it reads the headings, images and links out of the body and gives a// score out of 100, each failed check with what is wrong and how to fix it, and the// counts it found. A keyword is matched by whole words, in any case; without one its// checks are left out of the score, not failed. Headings and images inside comments,// scripts and code blocks do not count.//// <SeoAudit title={post.title} description={post.summary} slug={post.slug} body={post.html} keyword="cold brew"// base_url="https://blog.example.com/"/>// <SeoAudit on_change={(r) => { score = r.score }}/>//// title, description, slug, body, keyword: what the fields start with. They are real// fields named those, so a plain <form> sends them; name: a prefix for the names ("post"// names them post_title, ...). base_url: the site's address, so that a link to it// counts as internal; left out, only relative links do. format: "auto" (the default),// "html" or "markdown". rows: the body's height in lines (8). id: a prefix for its ids// ("seo-audit"), unique on the page. limit: the most characters of the body read// (100000); past it, it reads the start and says so. on_change(result): inside one of// your components, given the map below as it is typed. class: more classes for its// outer element.//// seo_audit_of(fields, base_url, format, limit) is the audit, for a program's own use,// fields being a map of the five: { score, passed, failed, checks, doc, capped }, where// score is a whole number, passed and failed list { id, label, ok, points, detail,// fix } and doc is what was read: { format, headings, images, links, words, intro }.// seo_audit_read(body, format) gives the doc alone. The limits, in characters: title 30// to 60, description 70 to 160, slug 60; 300 words, and a keyword 0.5% to 3% of them.import ui/theme.qwe use ui_clsimport ui/readability_meter.qwe use readability_meter_one, readability_meter_wordslet seo_audit_css = css`.ui-seo-audit { display: grid; gap: 14px; min-width: 0; max-width: 760px; font: 14px/1.5 var(--ui-font); color: var(--ui-fg); }.ui-seo-audit-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 14px; min-width: 0; }.ui-seo-audit-field { display: grid; align-content: start; gap: 4px; min-width: 0; }.ui-seo-audit-wide { grid-column: 1 / -1; }.ui-seo-audit-field label { font-size: 13px; font-weight: 500; }.ui-seo-audit-field input, .ui-seo-audit-field textarea { box-sizing: border-box; display: block; width: 100%; min-width: 0; 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-seo-audit-field textarea { font: 13px/1.5 var(--ui-mono); }.ui-seo-audit-field input:hover, .ui-seo-audit-field textarea:hover { border-color: var(--ui-strong); }.ui-seo-audit-field input:focus, .ui-seo-audit-field textarea:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-seo-audit-count { color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-seo-audit-result { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; padding: 12px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); --k: var(--ui-muted); }.ui-seo-audit-good { --k: var(--ui-success); }.ui-seo-audit-fair { --k: var(--ui-warning); }.ui-seo-audit-poor { --k: var(--ui-danger); }.ui-seo-audit-score { font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--k); font-variant-numeric: tabular-nums; }.ui-seo-audit-said { flex: 1 1 200px; margin: 0; font-weight: 600; }.ui-seo-audit-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-seo-audit-counts { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 10px; margin: 0; }.ui-seo-audit-counts dt { color: var(--ui-muted); font-size: 12px; }.ui-seo-audit-counts dd { margin: 0; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-seo-audit-checks { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-seo-audit-checks li { display: grid; gap: 1px; padding: 8px 10px; border-left: 3px solid var(--ui-danger); border-radius: var(--ui-radius-sm); background: var(--ui-surface); overflow-wrap: anywhere; }.ui-seo-audit-passed .ui-seo-audit-checks li { border-left-color: var(--ui-success); }.ui-seo-audit-checks b { font-weight: 600; }.ui-seo-audit-checks small { color: var(--ui-muted); font-size: 13px; }.ui-seo-audit-passed summary { cursor: pointer; font-weight: 600; }.ui-seo-audit-passed summary:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: var(--ui-radius-sm); }.ui-seo-audit-passed[open] summary { margin-bottom: 6px; }`// An attribute's value out of a tag's attributes, null when it has none.fn seo_audit_attr(attrs: str, name: str) { let m = re.find(attrs, `(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i") if m == null { return null } m.groups[0] ?? m.groups[1] ?? m.groups[2] ?? ""}// Text out of markup: tags gone, the usual entities back, spaces closed up.fn seo_audit_plain(html: str) -> str { let t = re.replace(html, "<[^<>]*>", " ") let named = [[" ", " "], ["<", "<"], [">", ">"], [""", "\""], ["'", "'"], ["'", "'"], ["&", "&"]] trim(re.replace(reduce(named, t, (s, e) => replace(s, e[0], e[1])), "\\s+", " "))}// Images and links written as tags, in HTML or inside Markdown. Quoted values may hold// a `>`; the counts keep a body of open quotes from taking the browser's patterns minutes.fn seo_audit_tags(html: str) { let quoted = "((?:[^<>\"']|\"[^\"]{0,1000}\"|'[^']{0,1000}')*)" { images: re.find_all(html, `<img\\b${quoted}>`, "i") | map((m) => { src: seo_audit_attr(m.groups[0], "src") ?? "", alt: seo_audit_attr(m.groups[0], "alt") }), links: re.find_all(html, `<a\\b${quoted}>`, "i") | map((m) => seo_audit_attr(m.groups[0], "href")) | filter((h) => h != null) }}// HTML without what is not on the page: comments, scripts, styles and code. Done by a walk// over their tags, as an open one a pattern looks for the end of costs the rest of the// text each time. One left open hides the rest, as it does in a browser.fn seo_audit_shown(html: str, chars: list) -> str { let marks = re.find_all(html, "<!--|-->|</?(?:script|style|noscript|template|pre|code)\\b[^<>]*>", "i") let inside = "" let from = 0 let pieces = for m in marks { let name = re.replace(lower(m.text), "^</?|[\\s>/][\\s\\S]*$", "") if inside == "" && (m.text == "<!--" || !starts_with(m.text, "</") && m.text != "-->") { inside = m.text == "<!--" ? "-->" : `</${name}` let piece = join(slice(chars, from, m.start), "") from = len(chars) piece } else if inside != "" && (inside == "-->" ? m.text == "-->" : starts_with(lower(m.text), inside)) { inside = "" from = m.end " " } else { "" } } join(pieces, " ") + (from < len(chars) ? join(slice(chars, from, len(chars)), "") : "")}// The headings of HTML: each opening tag with the next closing one.fn seo_audit_html_headings(html: str, chars: list) -> list { let tags = re.find_all(html, "</?h[1-6](?:\\s[^<>]*)?>", "i") let pairs = zip(tags, drop(tags, 1)) | filter((p) => !starts_with(p[0].text, "</") && starts_with(p[1].text, "</")) pairs | map((p) => { level: int(p[0].text[2]), text: seo_audit_plain(join(slice(chars, p[0].end, p[1].start), "")) })}// What matters in an HTML body.fn seo_audit_read_html(body: str) { let html = seo_audit_shown(body, split(body, "")) let chars = split(html, "") let parts = seo_audit_tags(html) let para = re.find_all(html, "</?p(?:\\s[^<>]*)?>", "i") let text = seo_audit_plain(html) let intro = len(para) == 0 ? text : seo_audit_plain(join(slice(chars, para[0].end, len(para) > 1 ? para[1].start : len(chars)), "")) { format: "html", headings: seo_audit_html_headings(html, chars), images: parts.images, links: parts.links, text, intro }}// A Markdown heading's text, without the #s that may close it.fn seo_audit_unhash(t: str) -> str { let chars = split(trim(t), "") let n = len(chars) while n > 0 && chars[n - 1] == "#" { n -= 1 } trim(join(take(chars, n), ""))}// What matters in a Markdown body, line by line, as headings are a matter of lines// and of what is fenced off around them.fn seo_audit_read_markdown(body: str) { let lines = split(replace(body, "\r\n", "\n"), "\n") let fence = "" let kept = for line in lines { let t = trim(line) if fence != "" { if starts_with(t, fence) { fence = "" } "" } else if starts_with(t, "```") || starts_with(t, "~~~") { fence = slice(t, 0, 3) "" } else { line } } let n = len(kept) let found = for i in range(0, n) { let line = kept[i] let atx = re.find(line, "^ {0,3}(#{1,6})[ \\t]+(.*)$") let next = i + 1 < n ? kept[i + 1] : "" if atx != null { { level: len(atx.groups[0]), text: seo_audit_unhash(atx.groups[1]), at: i } } else if trim(line) != "" && re.test(next, "^ {0,3}=+[ \\t]*$") { { level: 1, text: trim(line), at: i } } else if trim(line) != "" && re.test(next, "^ {0,3}-{2,}[ \\t]*$") && !re.test(line, "^ {0,3}[-*>]") { { level: 2, text: trim(line), at: i } } else { null } } let headings = found | filter((h) => h != null) let heading_lines = reduce(headings, {}, (m, h) => set(m, h.at, true)) let prose = join(range(0, n) | filter((i) => !has(heading_lines, i)) | map((i) => kept[i]), "\n") // Code spans go first, so that a link written in one is not counted. The counts are // as in seo_audit_tags: a body of brackets is not to take minutes. let clean = re.replace(prose, "`[^`\\n]*`", " ") let target = "\\(\\s{0,9}<?([^)\\s>]{0,1000})>?(?:\\s[^)\\n]{0,300})?\\)" let label = "\\[([^\\]\\n]{0,300})\\]" // Markdown cannot say an image is only decoration, so an empty description is none. let images = re.find_all(clean, `!${label}${target}`) | map((m) => { src: m.groups[1], alt: filled(trim(m.groups[0])) }) let no_images = re.replace(clean, `!${label}${target}`, " ") let links = re.find_all(no_images, `${label}${target}`) | map((m) => m.groups[1]) let words_of = re.replace(no_images, `${label}${target}`, "$1") let bare = re.find_all(re.replace(no_images, `${label}${target}`, " "), "<(https?://[^>\\s]+)>|(?:^|\\s)(https?://[^\\s<>)]+)") | map((m) => re.replace(m.groups[0] ?? m.groups[1], "[.,;:!?]{1,9}$", "")) let tags = seo_audit_tags(clean) let titles = headings | map((h) => h.text) | join(" ") let text = seo_audit_plain(titles + " " + re.replace(words_of, "^[ \\t]{0,40}(?:[-*+>=]+|[0-9]+\\.)|[*_~]{1,3}", " ", "m")) let paragraphs = re.split(trim(words_of), "\\n\\s*\\n") { format: "markdown", headings: headings | map((h) => { level: h.level, text: re.replace(h.text, "[*_`]", "") }), images: images + tags.images, links: links + bare + tags.links, text, intro: seo_audit_plain(paragraphs[0] ?? "") }}// What a body holds: { format, headings, images, links, words, intro, text }. A body with// tags in it is HTML, and "auto" looks for them.fn seo_audit_read(body: str, format: str = "auto") { let as_html = format == "html" || (format != "markdown" && re.test(body, "<(?:!doctype|html|body|article|section|div|p|h[1-6]|ul|ol|table)\\b[^<>]*>", "i")) let doc = as_html ? seo_audit_read_html(body) : seo_audit_read_markdown(body) { ...doc, words: len(readability_meter_words(doc.text)) }}// The host of an absolute address, without "www.", in small letters; null for any other.fn seo_audit_host(url: str) { let m = re.find(trim(url), "^(?:https?:)?//([^/:?#]+)", "i") m == null ? null : re.replace(lower(m.groups[0]), "^www\\.", "")}// What a link is: "internal", "external", or "other" (a place on the page, mail, a phone).fn seo_audit_link_kind(href: str, base_host) -> str { let h = trim(href) if h == "" || starts_with(h, "#") || re.test(h, "^[a-z][a-z0-9+.-]*:", "i") && !re.test(h, "^https?:", "i") { return "other" } let host = seo_audit_host(h) if host == null { return "internal" } base_host != null && host == base_host ? "internal" : "external"}// A text's words in small letters between spaces, so a keyword is found by whole words.fn seo_audit_said(text: str) -> str = ` ${readability_meter_words(replace(text, "’", "'")) | map((w) => lower(w.text)) | join(" ")} `fn seo_audit_says(text: str, keyword: str) -> bool = contains(seo_audit_said(text), seo_audit_said(keyword))// How many times a keyword comes, by whole words. Spaces are doubled so that two// neighbours do not share the one between them.fn seo_audit_times(text: str, keyword: str) -> int = len(split(replace(seo_audit_said(text), " ", " "), ` ${replace(trim(seo_audit_said(keyword)), " ", " ")} `)) - 1// Where a slug falls short, a sentence; null when it is fine.fn seo_audit_slug_fault(slug: str) { let s = re.replace(trim(slug), "^/+|/+$", "") if s == "" { return "There is no slug." } if len(s) > 60 { return `It has ${len(s)} characters, over 60.` } if re.test(s, "[A-Z]") { return "It has capital letters." } if contains(s, "_") { return "It has underscores." } if re.test(s, "\\s") { return "It has spaces." } if !re.test(s, "^[a-z0-9/-]+$") { return "It has characters other than letters, numbers and hyphens." } if contains(s, "--") || re.test(s, "^-|-$") { return "It has stray hyphens." } null}// The first place the headings skip a level, a sentence; null when none does.fn seo_audit_order_fault(headings: list) { let pairs = zip(headings, drop(headings, 1)) | filter((p) => p[1].level > p[0].level + 1) if len(pairs) == 0 { return null } let p = pairs[0] `"${p[1].text}" is an H${p[1].level} after an H${p[0].level}.`}fn seo_audit_count(n: int, one: str, many: str) -> str = n == 1 ? `1 ${one}` : `${n} ${many}`// The audit (see the top of the file).fn seo_audit_of(fields, base_url: str? = null, format: str = "auto", limit: int = 100000) { let title = trim(fields.title ?? "") let description = trim(fields.description ?? "") let slug = trim(fields.slug ?? "") let keyword = trim(fields.keyword ?? "") let raw = fields.body ?? "" let capped = len(raw) > limit let doc = seo_audit_read(capped ? slice(raw, 0, limit) : raw, format) let base_host = base_url == null ? null : seo_audit_host(base_url) let kinds = doc.links | map((h) => seo_audit_link_kind(h, base_host)) let internal = count(kinds, (k) => k == "internal") let external = count(kinds, (k) => k == "external") let h1s = count(doc.headings, (h) => h.level == 1) let no_alt = doc.images | filter((i) => i.alt == null) let order = seo_audit_order_fault(doc.headings) let slug_fault = seo_audit_slug_fault(slug) let tl = len(title) let dl = len(description) let check = (id: str, label: str, points: int, ok: bool, detail: str, fix: str) => { id, label, ok, points, detail, fix } let base = [ check("title", "Title length", 15, tl >= 30 && tl <= 60, `${tl} characters.`, tl < 30 ? "Say more: aim for 30 to 60 characters." : "Shorten it to 60 characters or fewer, or search engines will cut it."), check("description", "Description length", 10, dl >= 70 && dl <= 160, `${dl} characters.`, dl < 70 ? "Write a full sentence or two: 70 to 160 characters." : "Shorten it to 160 characters or fewer, or search engines will cut it."), check("slug", "Slug", 5, slug_fault == null, slug_fault ?? "Short, in small letters, with hyphens.", "Use small letters, numbers and hyphens, in 60 characters or fewer."), check("h1", "One main heading", 10, h1s == 1, seo_audit_count(h1s, "H1", "H1s") + " found.", h1s == 0 ? "Add one H1 to say what the page is about." : "Keep one H1 and make the others H2."), check("order", "Heading order", 10, order == null, order ?? "No level is skipped.", "Go down one level at a time: H2 under H1, H3 under H2."), check("alt", "Image descriptions", 10, len(no_alt) == 0, len(doc.images) == 0 ? "No images." : len(no_alt) == 0 ? `All ${seo_audit_count(len(doc.images), "image has", "images have")} alt text.` : `${seo_audit_count(len(no_alt), "image has", "images have")} no alt text: ${join(take(no_alt, 3) | map((i) => i.src == "" ? "(no address)" : i.src), ", ")}.`, "Describe each image in its alt text."), check("internal", "Internal links", 5, internal > 0, seo_audit_count(internal, "internal link", "internal links") + ".", "Link to a related page of your own site."), check("external", "External links", 5, external > 0, seo_audit_count(external, "external link", "external links") + ".", "Link to a source that backs up what you say."), check("length", "Body length", 5, doc.words >= 300, seo_audit_count(doc.words, "word", "words") + ".", "Write at least 300 words: thin pages rank poorly."), ] let density = doc.words == 0 ? 0 : seo_audit_times(doc.text, keyword) * len(readability_meter_words(keyword)) * 100 / doc.words // A slug's hyphens stand for spaces, where a hyphen in a word holds it together. let in_slug = seo_audit_says(replace(replace(slug, "-", " "), "/", " "), keyword) let keyed = keyword == "" ? [] : [ check("keyword-title", "Keyword in the title", 10, seo_audit_says(title, keyword), seo_audit_says(title, keyword) ? "Found." : "Not found.", `Put "${keyword}" in the title, near the start.`), check("keyword-description", "Keyword in the description", 5, seo_audit_says(description, keyword), seo_audit_says(description, keyword) ? "Found." : "Not found.", `Say "${keyword}" in the description.`), check("keyword-slug", "Keyword in the slug", 5, in_slug, in_slug ? "Found." : "Not found.", `Put "${keyword}" in the slug, with hyphens.`), check("keyword-intro", "Keyword in the first paragraph", 5, seo_audit_says(doc.intro, keyword), seo_audit_says(doc.intro, keyword) ? "Found." : "Not found.", `Say "${keyword}" in the first paragraph.`), check("keyword-heading", "Keyword in a heading", 5, any(doc.headings, (h) => seo_audit_says(h.text, keyword)), any(doc.headings, (h) => seo_audit_says(h.text, keyword)) ? "Found." : "Not found.", `Use "${keyword}" in a heading.`), check("keyword-density", "Keyword share of the text", 5, density >= 0.5 && density <= 3, `${readability_meter_one(density)}% of the words.`, density > 3 ? "Use it less: over 3% reads as stuffing." : "Use it a little more: aim for 0.5% to 3%."), ] let checks = base + keyed let possible = checks | map((c) => c.points) | sum() let earned = checks | filter((c) => c.ok) | map((c) => c.points) | sum() { score: round(earned * 100 / possible), passed: checks | filter((c) => c.ok), failed: checks | filter((c) => !c.ok), checks, doc, capped, internal, external, keyworded: keyword != "" }}component SeoAudit(title: str = "", description: str = "", slug: str = "", body: str = "", keyword: str = "", name: str? = null, base_url: str? = null, format: str = "auto", rows: int = 8, id: str = "seo-audit", limit: int = 100000, on_change = null, class: str? = null) { style global seo_audit_css state t = title state d = description state s = slug state b = body state k = keyword let named = (n: str) => name != null ? `${name}_${n}` : n let fields = (at_t: str, at_d: str, at_s: str, at_b: str, at_k: str) => { title: at_t, description: at_d, slug: at_s, body: at_b, keyword: at_k } let r = seo_audit_of(fields(t, d, s, b, k), base_url, format, limit) let blank = trim(t + d + s + b + k) == "" let tone = r.score >= 80 ? "good" : r.score >= 50 ? "fair" : "poor" let said = blank ? "Fill in the fields to audit the page." : len(r.failed) == 0 ? "Every check passed." : `${len(r.passed)} of ${len(r.checks)} checks passed, ${seo_audit_count(len(r.failed), "to fix", "to fix")}.` let tell = () => { on_change && on_change(seo_audit_of(fields(t, d, s, b, k), base_url, format, limit)) } <section class={ui_cls(["ui-seo-audit", class])} aria-labelledby={`${id}-said`}> <div.ui-seo-audit-fields> <div.ui-seo-audit-field.ui-seo-audit-wide> <label for={`${id}-title`}>Title</label> <input type="text" id={`${id}-title`} name={named("title")} value={t} autocomplete="off" @input={(e) => { t = e.value; tell() }}/> <span.ui-seo-audit-count>{len(trim(t))} of 60 characters</span> </div> <div.ui-seo-audit-field.ui-seo-audit-wide> <label for={`${id}-description`}>Description</label> <textarea id={`${id}-description`} name={named("description")} rows="2" value={d} @input={(e) => { d = e.value; tell() }}></textarea> <span.ui-seo-audit-count>{len(trim(d))} of 160 characters</span> </div> <div.ui-seo-audit-field> <label for={`${id}-slug`}>Slug</label> <input type="text" id={`${id}-slug`} name={named("slug")} value={s} autocomplete="off" @input={(e) => { s = e.value; tell() }}/> </div> <div.ui-seo-audit-field> <label for={`${id}-keyword`}>Focus keyword</label> <input type="text" id={`${id}-keyword`} name={named("keyword")} value={k} autocomplete="off" @input={(e) => { k = e.value; tell() }}/> </div> <div.ui-seo-audit-field.ui-seo-audit-wide> <label for={`${id}-body`}>Body, in HTML or Markdown</label> <textarea id={`${id}-body`} name={named("body")} rows={rows} value={b} spellcheck="false" @input={(e) => { b = e.value; tell() }}></textarea> </div> </div> <div class={ui_cls(["ui-seo-audit-result", !blank && `ui-seo-audit-${tone}`])}> {!blank && <span.ui-seo-audit-score aria-hidden="true">{r.score}</span>} <p.ui-seo-audit-said id={`${id}-said`} aria-live="polite">{blank ? said : `Score ${r.score} of 100. ${said}`}</p> </div> {!blank && <p.ui-seo-audit-note>Read as {r.doc.format == "html" ? "HTML" : "Markdown"}.{r.keyworded ? "" : " Give a focus keyword to check where it is used."}</p>} {r.capped && <p.ui-seo-audit-note>Only the first {format_number(limit, "en-US", 0)} characters of the body were read.</p>} {!blank && <dl.ui-seo-audit-counts> <div><dt>Words</dt><dd>{r.doc.words}</dd></div> <div><dt>Headings</dt><dd>{len(r.doc.headings)}</dd></div> <div><dt>Images</dt><dd>{len(r.doc.images)}</dd></div> <div><dt>Internal links</dt><dd>{r.internal}</dd></div> <div><dt>External links</dt><dd>{r.external}</dd></div> </dl>} {!blank && len(r.failed) > 0 && <ul.ui-seo-audit-checks id={`${id}-failed`}> {for c in r.failed => <li key={c.id}><b>{c.label}</b><span>{c.detail}</span><small>{c.fix}</small></li>} </ul>} {!blank && len(r.passed) > 0 && <details.ui-seo-audit-passed> <summary>Passed ({len(r.passed)})</summary> <ul.ui-seo-audit-checks> {for c in r.passed => <li key={c.id}><b>{c.label}</b><span>{c.detail}</span></li>} </ul> </details>} </section>}Related
KeywordExtractorFinds a text's top words and two-word phrases without each language's common words, warns of stuffing, and copies them as tags.2 views · 4 downloadsSeoPreviewA 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.7 views · 8 downloadsHeadlineAnalyzerScores headlines for length, word balance, numbers, power and weak words, shows where search and phones cut them, and compares variants side by side.4 views · 8 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 · 8 downloads
Thread
Thread 0 posts
On SeoAudit: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.