From 205abbcd3302edf0fd8fdbf8dba5f70030db1397 Mon Sep 17 00:00:00 2001 From: Muhammad Ali Anjum Date: Thu, 1 Oct 2026 08:56:40 +0200 Subject: [PATCH 1/3] Polish SEO preview demo experience --- README.md | 6 +- public/preview-placeholder.svg | 24 + public/styles.css | 1080 ++++++++++++++++++++++++++++++++ server.js | 296 ++++++--- test/app.test.js | 36 ++ 5 files changed, 1353 insertions(+), 89 deletions(-) create mode 100644 public/preview-placeholder.svg create mode 100644 public/styles.css diff --git a/README.md b/README.md index 714e45b..7fb4b9f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SEO Audit & Social Media Preview +# MetaScope โ€” SEO Audit & Social Preview A compact Node.js application that audits a public webpage's SEO metadata and renders Open Graph and Twitter Card previews. It demonstrates server-side HTML parsing, defensive outbound HTTP requests, secure rendering, and automated HTTP testing. @@ -6,7 +6,7 @@ A compact Node.js application that audits a public webpage's SEO metadata and re - Checks title, description, canonical URL, H1, language, robots, and viewport metadata. - Reports completeness of Open Graph and Twitter Card tags. -- Renders website, Facebook, Twitter, and TikTok-style preview cards. +- Renders search, Facebook, X/Twitter, and LinkedIn-style preview cards. - Lists discovered Open Graph and Twitter metadata for inspection. - Resolves relative preview URLs against the audited page. - Returns clear errors for invalid, unreachable, oversized, slow, or non-HTML targets. @@ -31,7 +31,7 @@ These controls are appropriate for a demonstration project, but a public high-tr - Express 5 - Axios - Cheerio -- Bootstrap 5 +- Responsive, dependency-free CSS - Node's built-in test runner and Supertest ## Requirements diff --git a/public/preview-placeholder.svg b/public/preview-placeholder.svg new file mode 100644 index 0000000..4da4a99 --- /dev/null +++ b/public/preview-placeholder.svg @@ -0,0 +1,24 @@ + + No social preview image + A soft violet placeholder with the MetaScope mark. + + + + + + + + + + + + + + + + + + + No social image found + Add an og:image tag to shape your link preview. + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..e5b4757 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,1080 @@ +:root { + color-scheme: light; + --ink: #17162c; + --ink-soft: #5d5b70; + --muted: #817f91; + --line: #e7e5ef; + --surface: rgba(255, 255, 255, 0.92); + --violet: #6754e8; + --violet-dark: #5140cb; + --violet-soft: #f0edff; + --green: #15805e; + --green-soft: #e8f7f0; + --amber: #a45c0b; + --amber-soft: #fff4df; + --shadow: 0 22px 60px rgba(40, 32, 78, 0.1); + --shadow-soft: 0 10px 30px rgba(40, 32, 78, 0.07); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-width: 320px; + color: var(--ink); + background: + radial-gradient(circle at 8% 10%, rgba(121, 97, 242, 0.11), transparent 25rem), + radial-gradient(circle at 92% 20%, rgba(79, 189, 179, 0.09), transparent 28rem), + #f8f8fc; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +button, +input { + font: inherit; +} + +a { + color: inherit; +} + +img { + display: block; + max-width: 100%; +} + +.shell { + width: min(1160px, calc(100% - 48px)); + margin-inline: auto; +} + +.surface { + background: var(--surface); + border: 1px solid rgba(226, 223, 237, 0.88); + box-shadow: var(--shadow-soft); +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 80px; +} + +.brand, +.site-footer > div { + display: inline-flex; + align-items: center; + gap: 11px; + color: var(--ink); + font-size: 1.05rem; + font-weight: 780; + letter-spacing: -0.025em; + text-decoration: none; +} + +.brand__mark { + position: relative; + display: inline-grid; + width: 31px; + height: 31px; + place-items: center; + border-radius: 10px; + background: linear-gradient(145deg, #7562f3, #4f3cce); + box-shadow: 0 7px 16px rgba(103, 84, 232, 0.28); +} + +.brand__mark::before, +.brand__mark span { + content: ""; + position: absolute; + width: 13px; + height: 9px; + border: 2px solid white; + border-radius: 3px; +} + +.brand__mark::before { + translate: -3px -3px; +} + +.brand__mark span { + translate: 3px 3px; +} + +.brand__mark--small { + width: 25px; + height: 25px; + border-radius: 8px; +} + +.brand__mark--small::before, +.brand__mark--small span { + width: 10px; + height: 7px; +} + +.project-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + color: #5b5870; + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(218, 214, 231, 0.8); + border-radius: 999px; + font-size: 0.78rem; + font-weight: 680; +} + +.project-badge span { + color: var(--violet); + font-size: 0.66rem; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 445px); + gap: clamp(44px, 8vw, 108px); + align-items: center; + min-height: 510px; + padding-block: 58px 72px; +} + +.eyebrow { + margin: 0 0 13px; + color: var(--violet); + font-size: 0.74rem; + font-weight: 800; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.hero__copy > .eyebrow { + display: flex; + align-items: center; + gap: 9px; +} + +.hero__copy > .eyebrow span { + width: 7px; + height: 7px; + border-radius: 99px; + background: var(--violet); + box-shadow: 0 0 0 5px var(--violet-soft); +} + +.hero h1 { + max-width: 720px; + margin: 0; + font-size: clamp(2.75rem, 5.4vw, 4.6rem); + font-weight: 790; + letter-spacing: -0.062em; + line-height: 0.99; +} + +.hero h1 em { + display: block; + color: var(--violet); + font-style: normal; +} + +.hero__lede { + max-width: 610px; + margin: 24px 0 0; + color: var(--ink-soft); + font-size: 1.08rem; + line-height: 1.7; +} + +.trust-list { + display: flex; + flex-wrap: wrap; + gap: 12px 22px; + margin: 29px 0 0; + padding: 0; + color: #646174; + font-size: 0.78rem; + font-weight: 650; + list-style: none; +} + +.trust-list li { + display: flex; + align-items: center; + gap: 6px; +} + +.trust-list span { + color: var(--green); +} + +.analyzer-panel { + position: relative; + padding: 31px; + overflow: hidden; + border-radius: 21px; + box-shadow: var(--shadow); +} + +.analyzer-panel::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 3px; + background: linear-gradient(90deg, var(--violet), #9c8bff, #52bfb7); +} + +.analyzer-panel__header { + display: flex; + gap: 13px; + align-items: center; + margin-bottom: 27px; +} + +.analyzer-panel__header h2, +.analyzer-panel__header p { + margin: 0; +} + +.analyzer-panel__header h2 { + font-size: 1.12rem; + letter-spacing: -0.025em; +} + +.analyzer-panel__header p { + margin-top: 2px; + color: var(--muted); + font-size: 0.82rem; +} + +.analyzer-panel__icon, +.feature-icon { + display: grid; + flex: 0 0 auto; + width: 40px; + height: 40px; + place-items: center; + color: white; + background: var(--violet); + border-radius: 11px; + font-size: 1.25rem; + font-weight: 800; +} + +.analyzer-panel > label { + display: block; + margin-bottom: 8px; + font-size: 0.78rem; + font-weight: 750; +} + +.url-field { + display: flex; + align-items: center; + min-height: 51px; + padding-inline: 15px; + border: 1px solid #dad7e5; + border-radius: 11px; + background: #fbfbfd; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.url-field:focus-within { + border-color: #8b79ef; + background: white; + box-shadow: 0 0 0 4px rgba(103, 84, 232, 0.12); +} + +.url-field__icon { + margin-right: 10px; + color: #9b98a9; + font-size: 1.2rem; +} + +.url-field input { + width: 100%; + min-width: 0; + padding: 0; + color: var(--ink); + background: transparent; + border: 0; + outline: 0; + font-size: 0.9rem; +} + +.url-field input::placeholder { + color: #aaa7b6; +} + +.primary-button { + display: flex; + width: 100%; + min-height: 51px; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 12px; + color: white; + background: linear-gradient(135deg, #705ceb, #5945d5); + border: 0; + border-radius: 11px; + box-shadow: 0 10px 22px rgba(91, 70, 215, 0.24); + cursor: pointer; + font-size: 0.88rem; + font-weight: 760; + transition: translate 160ms ease, box-shadow 160ms ease; +} + +.primary-button:hover { + box-shadow: 0 13px 27px rgba(91, 70, 215, 0.31); + translate: 0 -1px; +} + +.primary-button:focus-visible, +a:focus-visible, +summary:focus-visible { + outline: 3px solid rgba(103, 84, 232, 0.32); + outline-offset: 3px; +} + +.form-note { + margin: 14px 0 0; + color: #9491a1; + font-size: 0.7rem; + text-align: center; +} + +.form-note span { + margin-right: 4px; + color: var(--green); +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + padding-bottom: 84px; +} + +.feature-grid article { + padding: 28px; + background: rgba(255, 255, 255, 0.52); + border: 1px solid rgba(229, 226, 237, 0.72); + border-radius: 17px; +} + +.feature-icon { + width: 36px; + height: 36px; + margin-bottom: 23px; + font-size: 1rem; +} + +.feature-icon--violet { + color: var(--violet); + background: var(--violet-soft); +} + +.feature-icon--blue { + color: #2774c9; + background: #e7f2ff; +} + +.feature-icon--green { + color: var(--green); + background: var(--green-soft); +} + +.feature-grid h2 { + margin: 0 0 8px; + font-size: 0.98rem; + letter-spacing: -0.02em; +} + +.feature-grid p, +.section-heading > p, +.audit-item p, +.results-summary > div > p:not(.eyebrow) { + color: var(--ink-soft); +} + +.feature-grid p { + margin: 0; + font-size: 0.82rem; + line-height: 1.65; +} + +.results { + padding: 10px 0 88px; +} + +.results-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 32px; + padding: 34px 38px; + border-radius: 20px; +} + +.results-summary h2 { + margin: 0; + font-size: clamp(1.75rem, 4vw, 2.45rem); + letter-spacing: -0.045em; +} + +.results-summary > div > p:not(.eyebrow) { + margin: 8px 0 13px; + font-size: 0.93rem; +} + +.source-link { + display: inline-flex; + max-width: min(100%, 650px); + align-items: center; + gap: 7px; + overflow: hidden; + color: var(--violet-dark); + font-size: 0.78rem; + font-weight: 700; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.score { + display: flex; + flex: 0 0 112px; + height: 112px; + align-items: baseline; + justify-content: center; + padding-top: 25px; + color: var(--violet); + background: var(--violet-soft); + border: 1px solid #ded8ff; + border-radius: 50%; +} + +.score strong { + font-size: 2.35rem; + letter-spacing: -0.06em; +} + +.score span { + margin-left: 3px; + color: #8278b8; + font-size: 0.82rem; + font-weight: 700; +} + +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 32px; + margin: 67px 0 23px; +} + +.section-heading h2 { + margin: 0; + font-size: clamp(1.55rem, 3vw, 2.05rem); + letter-spacing: -0.04em; +} + +.section-heading > p { + max-width: 410px; + margin: 0; + font-size: 0.8rem; + line-height: 1.6; + text-align: right; +} + +.audit-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.audit-item { + min-height: 136px; + padding: 20px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid var(--line); + border-radius: 14px; +} + +.audit-item__topline { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.audit-item h3 { + margin: 2px 0 0; + font-size: 0.88rem; + letter-spacing: -0.015em; +} + +.audit-item p { + margin: 14px 0 0; + font-size: 0.75rem; + line-height: 1.55; +} + +.status { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + padding: 4px 8px; + border-radius: 999px; + font-size: 0.64rem; + font-weight: 760; +} + +.status span { + display: grid; + width: 13px; + height: 13px; + place-items: center; + color: white; + border-radius: 50%; + font-size: 0.55rem; +} + +.status--pass { + color: var(--green); + background: var(--green-soft); +} + +.status--pass span { + background: var(--green); +} + +.status--attention { + color: var(--amber); + background: var(--amber-soft); +} + +.status--attention span { + background: #d2801e; +} + +.preview-layout { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.search-preview, +.social-preview { + overflow: hidden; + border-radius: 17px; +} + +.search-preview { + grid-column: 1 / -1; + padding: 24px; +} + +.preview-label { + display: flex; + align-items: center; + gap: 11px; + padding-bottom: 18px; +} + +.preview-label strong, +.preview-label span { + display: block; +} + +.preview-label strong { + font-size: 0.8rem; +} + +.preview-label div > span { + color: var(--muted); + font-size: 0.66rem; +} + +.preview-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 850; +} + +.preview-icon--search { + color: #3775df; + background: #eef4ff; +} + +.preview-icon--facebook { + color: white; + background: #1877f2; + font-family: Arial, sans-serif; + font-size: 1rem; +} + +.preview-icon--x { + color: white; + background: #111; +} + +.preview-icon--linkedin { + color: white; + background: #0a66c2; + font-family: Arial, sans-serif; + font-size: 0.67rem; +} + +.search-result { + max-width: 720px; + padding: 21px 24px; + background: #fff; + border: 1px solid #eceaf1; + border-radius: 12px; +} + +.search-result__url { + margin: 0 0 4px; + overflow: hidden; + color: #3c4043; + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-result h3 { + margin: 0 0 5px; + color: #1a0dab; + font-family: Arial, sans-serif; + font-size: 1.18rem; + font-weight: 400; +} + +.search-result p:last-child { + margin: 0; + color: #4d5156; + font-family: Arial, sans-serif; + font-size: 0.76rem; + line-height: 1.5; +} + +.social-preview { + padding: 18px; +} + +.social-card { + overflow: hidden; + background: #f1f2f5; + border: 1px solid #d9dce3; + border-radius: 10px; +} + +.social-card > img { + width: 100%; + aspect-ratio: 1.91 / 1; + object-fit: cover; + background: #e7e6ed; +} + +.social-card__body { + min-height: 107px; + padding: 13px 14px; +} + +.social-card__body span { + display: block; + overflow: hidden; + color: #6d7078; + font-size: 0.62rem; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.social-card__body h3 { + display: -webkit-box; + margin: 5px 0 4px; + overflow: hidden; + font-family: Arial, sans-serif; + font-size: 0.84rem; + line-height: 1.3; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.social-card__body p { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: #676971; + font-family: Arial, sans-serif; + font-size: 0.68rem; + line-height: 1.35; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.social-card--dark { + color: white; + background: #171717; + border-color: #333; +} + +.social-card--dark .social-card__body span, +.social-card--dark .social-card__body p { + color: #a9a9a9; +} + +.metadata { + margin-top: 18px; + overflow: hidden; + border-radius: 17px; +} + +.metadata summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 20px 23px; + cursor: pointer; + list-style: none; +} + +.metadata summary::-webkit-details-marker { + display: none; +} + +.metadata summary strong, +.metadata summary small { + display: block; +} + +.metadata summary strong { + font-size: 0.86rem; +} + +.metadata summary small { + margin-top: 2px; + color: var(--muted); + font-size: 0.68rem; +} + +.details-action { + color: var(--violet); + font-size: 0.72rem; + font-weight: 750; +} + +.metadata[open] .details-action span { + display: inline-block; + rotate: 180deg; +} + +.metadata__table-wrap { + overflow-x: auto; + border-top: 1px solid var(--line); +} + +.metadata table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; + text-align: left; +} + +.metadata th, +.metadata td { + padding: 13px 23px; + border-bottom: 1px solid #efedf3; +} + +.metadata thead th { + color: var(--muted); + background: #faf9fc; + font-size: 0.64rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metadata tbody th { + width: 28%; + white-space: nowrap; +} + +.metadata td { + overflow-wrap: anywhere; +} + +.metadata code { + color: var(--violet-dark); + font-size: 0.72rem; +} + +.error-card { + display: flex; + gap: 20px; + align-items: flex-start; + margin: 10px 0 84px; + padding: 31px; + border-radius: 18px; +} + +.error-card__icon { + display: grid; + flex: 0 0 42px; + height: 42px; + place-items: center; + color: #b8503e; + background: #fff0ec; + border-radius: 12px; + font-weight: 850; +} + +.error-card h2 { + margin: 0; + font-size: 1.35rem; + letter-spacing: -0.03em; +} + +.error-card p:not(.eyebrow) { + margin: 7px 0 0; + color: var(--ink-soft); + font-size: 0.85rem; +} + +.error-card .error-card__hint { + color: var(--muted); + font-size: 0.74rem; +} + +.site-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 100px; + color: var(--muted); + border-top: 1px solid rgba(220, 217, 231, 0.8); + font-size: 0.73rem; +} + +.site-footer p { + margin: 0; +} + +@media (max-width: 900px) { + .hero { + grid-template-columns: 1fr; + gap: 42px; + padding-block: 48px 64px; + } + + .hero__copy { + max-width: 760px; + } + + .hero h1 em { + display: inline; + } + + .analyzer-panel { + width: min(100%, 620px); + } + + .audit-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .preview-layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .search-preview { + grid-column: 1 / -1; + } + + .social-preview:last-of-type { + grid-column: 1 / -1; + max-width: calc(50% - 9px); + } +} + +@media (max-width: 720px) { + .shell { + width: min(100% - 30px, 600px); + } + + .site-header { + min-height: 68px; + } + + .project-badge { + padding: 6px 9px; + font-size: 0.68rem; + } + + .hero { + min-height: 0; + padding-block: 40px 56px; + } + + .hero h1 { + font-size: clamp(2.55rem, 13.2vw, 4rem); + line-height: 1.02; + } + + .hero h1 em { + display: block; + } + + .hero__lede { + font-size: 0.98rem; + } + + .trust-list { + display: grid; + gap: 8px; + } + + .analyzer-panel { + padding: 24px 20px; + border-radius: 17px; + } + + .feature-grid, + .audit-grid, + .preview-layout { + grid-template-columns: 1fr; + } + + .feature-grid { + gap: 12px; + padding-bottom: 64px; + } + + .feature-grid article { + padding: 22px; + } + + .feature-icon { + margin-bottom: 17px; + } + + .results { + padding-top: 0; + } + + .results-summary { + align-items: flex-start; + padding: 26px 22px; + } + + .score { + flex-basis: 78px; + height: 78px; + padding-top: 17px; + } + + .score strong { + font-size: 1.65rem; + } + + .section-heading { + display: block; + margin-top: 52px; + } + + .section-heading > p { + margin-top: 9px; + text-align: left; + } + + .search-preview, + .social-preview { + grid-column: auto; + } + + .social-preview:last-of-type { + grid-column: auto; + max-width: none; + } + + .metadata summary { + padding: 18px; + } + + .metadata th, + .metadata td { + padding: 12px 18px; + } + + .site-footer { + display: grid; + justify-items: start; + padding-block: 24px; + } +} + +@media (max-width: 440px) { + .project-badge { + width: 30px; + height: 30px; + justify-content: center; + overflow: hidden; + padding: 0; + font-size: 0; + white-space: nowrap; + } + + .project-badge span { + font-size: 0.7rem; + } + + .results-summary { + display: block; + } + + .score { + width: 78px; + margin-top: 22px; + } + + .details-action { + font-size: 0; + } + + .details-action span { + font-size: 1rem; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + } +} diff --git a/server.js b/server.js index 10d64fb..be72506 100644 --- a/server.js +++ b/server.js @@ -1,10 +1,11 @@ const cheerio = require("cheerio"); const express = require("express"); +const path = require("node:path"); const rateLimit = require("express-rate-limit"); const helmet = require("helmet"); const { fetchPage } = require("./lib/safe-fetch"); -const PLACEHOLDER_IMAGE = "https://placehold.co/600x315?text=No+Image"; +const PLACEHOLDER_IMAGE = "/preview-placeholder.svg"; function escapeHtml(value) { if (value === undefined || value === null) return ""; @@ -17,6 +18,7 @@ function escapeHtml(value) { } function safeWebUrl(value, baseUrl, fallback = "") { + if (!value) return fallback; try { const parsed = new URL(value, baseUrl); return ["http:", "https:"].includes(parsed.protocol) @@ -143,15 +145,32 @@ function renderAnalysis(html, targetUrl) { const seoAuditRows = seoChecks .map( - (check) => ` - - ${escapeHtml(check.name)} - ${escapeHtml(check.status)} - ${escapeHtml(check.suggestion)} - `, + (check) => { + const isReady = check.suggestion === "Looks good!"; + const displayStatus = + !isReady && check.status.startsWith("Found") + ? "Review" + : check.status; + return ` +
+
+

${escapeHtml(check.name)}

+ + + ${escapeHtml(displayStatus)} + +
+

${escapeHtml(check.suggestion)}

+
`; + }, ) .join(""); + const readyCount = seoChecks.filter( + (check) => check.suggestion === "Looks good!", + ).length; + const opportunityCount = seoChecks.length - readyCount; + const metaTags = {}; $("meta").each((_index, element) => { const property = $(element).attr("property"); @@ -170,7 +189,7 @@ function renderAnalysis(html, targetUrl) { .map( ([key, value]) => ` - ${escapeHtml(key)} + ${escapeHtml(key)} ${escapeHtml(value)} `, ) @@ -187,87 +206,155 @@ function renderAnalysis(html, targetUrl) { const safeOgUrl = escapeHtml(ogUrl); return ` -

SEO Audit

-
- - - - - ${seoAuditRows} -
SEO ElementStatusSuggestion
-
- -

Website Preview

-
- OG Image -
-
${previewTitle}
-

${previewDescription}

- ${safeOgUrl} +
+
+
+

Audit results

+

${readyCount} of ${seoChecks.length} checks ready

+

${ + opportunityCount + ? `${opportunityCount} ${opportunityCount === 1 ? "opportunity" : "opportunities"} to improve before publishing.` + : "Everything in this quick audit looks ready to share." + }

+ + ${safeOgUrl} + +
+
+ ${readyCount} + / ${seoChecks.length} +
-
-

Social Media Previews

-
-
-
- Facebook Preview -
-
${facebookTitle}
-

${facebookDescription}

- Facebook -
+
+
+

SEO essentials

+

What is working and what to fix

+

Focused checks for the metadata that shapes search visibility and link previews.

-
-
- Twitter Preview -
-
${escapeHtml(twitterTitle || ogTitle || "No Title")}
-

${escapeHtml(twitterDescription || ogDescription || "No Description")}

- Twitter -
+
${seoAuditRows}
+ +
+
+

Live preview

+

See what your visitors will see

+

Previews are approximations. Each platform can crop images and text differently.

-
-
- TikTok Preview -
-
${facebookTitle}
-

${facebookDescription}

- TikTok + +
+
+
+ +
Search result previewDesktop result
-
+
+

${safeOgUrl}

+

${previewTitle}

+

${previewDescription}

+
+ + + + + + +
-
-

All OG/Twitter Meta Tags

-
- - - ${metaRows} -
TagContent
-
`; + + `; } -function renderPage(content) { +function renderPage(form, content) { return ` - + - - SEO Audit & Social Preview - + + + MetaScope โ€” SEO Audit & Social Preview + - -
-
-

SEO Audit & Social Media Preview

-

Inspect the SEO, Open Graph, and Twitter Card metadata of a public webpage.

- ${content} -
+ + +
+
+
+

SEO + social metadata inspector

+

Preview how your page appears before you share it.

+

Audit essential SEO tags and see realistic search and social previews in one focused report.

+
    +
  • No signup. No API key.
  • +
  • Secure public-URL analysis
  • +
  • Results in seconds
  • +
+
+ ${form} +
+
${content}
+
+
MetaScope
+

Built with Node.js, Express, Cheerio, and careful URL handling.

+
`; } @@ -287,11 +374,12 @@ function createApp({ defaultSrc: ["'self'"], imgSrc: ["'self'", "data:", "http:", "https:"], scriptSrc: ["'none'"], - styleSrc: ["'self'", "https://cdn.jsdelivr.net"], + styleSrc: ["'self'"], }, }, }), ); + app.use(express.static(path.join(__dirname, "public"))); const analysisLimiter = rateLimit({ legacyHeaders: false, @@ -308,34 +396,70 @@ function createApp({ req.query.url ? analysisLimiter(req, res, next) : next(), async (req, res) => { const requestedUrl = typeof req.query.url === "string" ? req.query.url : ""; - let content = ` -
- -
- - + const form = ` + +
+ +
+

Analyze a webpage

+

Enter a public page URL to get started.

+
+ +
+ + +
+ +

Only publicly accessible HTML pages can be analyzed.

`; + let content = ` +
+
+ +

Audit SEO essentials

+

Check titles, descriptions, headings, canonical URLs, language, and crawl settings.

+
+
+ +

Preview every share

+

See how your page can appear across search, Facebook, X, and LinkedIn.

+
+
+ +

Spot fixes quickly

+

Scan clear pass states and practical opportunities without digging through source.

+
+
`; + if (requestedUrl) { try { const html = await pageFetcher(requestedUrl); - content += renderAnalysis(html, requestedUrl); + content = renderAnalysis(html, requestedUrl); } catch (error) { const isPublicError = Number.isInteger(error.statusCode); res.status(isPublicError ? error.statusCode : 502); - content += ` - + `; } } - res.type("html").send(renderPage(content)); + res.type("html").send(renderPage(form, content)); }, ); diff --git a/test/app.test.js b/test/app.test.js index 45957ef..763ac85 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -33,6 +33,38 @@ test("renders a successful SEO and social metadata audit", async () => { assert.match(response.text, /SEO Audit/); }); +test("explains the product and next step on the landing page", async () => { + const app = createApp(); + const response = await request(app).get("/").expect(200); + + assert.match(response.text, /Preview how your page appears before you share it/); + assert.match(response.text, /Enter a public page URL/); + assert.match(response.text, /Run free audit/); + assert.match(response.text, /No signup\. No API key\./); +}); + +test("summarizes audit health and exposes semantic result statuses", async () => { + const app = createApp({ pageFetcher: async () => fixtureHtml }); + const response = await request(app) + .get("/") + .query({ url: "https://example.com/source" }) + .expect(200); + + assert.match(response.text, /Audit results/); + assert.match(response.text, /checks ready/); + assert.match(response.text, /class="status status--pass"/); + assert.match(response.text, /class="status status--attention"/); + assert.match(response.text, /Search result preview/); +}); + +test("serves the local responsive stylesheet", async () => { + const app = createApp(); + const response = await request(app).get("/styles.css").expect(200); + + assert.match(response.headers["content-type"], /text\/css/); + assert.match(response.text, /@media \(max-width: 720px\)/); +}); + test("escapes untrusted metadata and rejects unsafe preview URLs", async () => { const maliciousHtml = ` Safe fallback @@ -116,4 +148,8 @@ test("escapes attribute delimiters and allows only web URLs", () => { safeWebUrl("javascript:alert(1)", "https://example.com", "fallback"), "fallback", ); + assert.equal( + safeWebUrl("", "https://example.com", "/preview-placeholder.svg"), + "/preview-placeholder.svg", + ); }); From fb75b6aeff73742b173bc893f95fec566972a8da Mon Sep 17 00:00:00 2001 From: Muhammad Ali Anjum Date: Thu, 1 Oct 2026 14:59:49 +0200 Subject: [PATCH 2/3] Refine developer-focused visual design --- public/styles.css | 158 +++++++++++++++++++++++----------------------- server.js | 28 ++++---- test/app.test.js | 3 + 3 files changed, 97 insertions(+), 92 deletions(-) diff --git a/public/styles.css b/public/styles.css index e5b4757..0e16bc5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,19 +1,20 @@ :root { color-scheme: light; - --ink: #17162c; - --ink-soft: #5d5b70; - --muted: #817f91; - --line: #e7e5ef; - --surface: rgba(255, 255, 255, 0.92); - --violet: #6754e8; - --violet-dark: #5140cb; - --violet-soft: #f0edff; + --ink: #151827; + --ink-soft: #555d70; + --muted: #788195; + --line: #dfe3eb; + --surface: rgba(255, 255, 255, 0.94); + --violet: #635bdb; + --violet-dark: #4f46bd; + --violet-soft: #eeedff; --green: #15805e; --green-soft: #e8f7f0; --amber: #a45c0b; --amber-soft: #fff4df; - --shadow: 0 22px 60px rgba(40, 32, 78, 0.1); - --shadow-soft: 0 10px 30px rgba(40, 32, 78, 0.07); + --shadow: 0 24px 55px rgba(23, 28, 48, 0.11); + --shadow-soft: 0 8px 24px rgba(23, 28, 48, 0.07); + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; } * { @@ -29,9 +30,11 @@ body { min-width: 320px; color: var(--ink); background: - radial-gradient(circle at 8% 10%, rgba(121, 97, 242, 0.11), transparent 25rem), - radial-gradient(circle at 92% 20%, rgba(79, 189, 179, 0.09), transparent 28rem), - #f8f8fc; + linear-gradient(rgba(93, 99, 125, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(93, 99, 125, 0.045) 1px, transparent 1px), + radial-gradient(circle at 12% 8%, rgba(99, 91, 219, 0.1), transparent 26rem), + #f7f8fb; + background-size: 32px 32px, 32px 32px, auto, auto; font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.5; -webkit-font-smoothing: antialiased; @@ -65,8 +68,8 @@ img { .site-header { display: flex; align-items: center; - justify-content: space-between; min-height: 80px; + border-bottom: 1px solid rgba(212, 217, 228, 0.72); } .brand, @@ -87,7 +90,7 @@ img { width: 31px; height: 31px; place-items: center; - border-radius: 10px; + border-radius: 7px; background: linear-gradient(145deg, #7562f3, #4f3cce); box-shadow: 0 7px 16px rgba(103, 84, 232, 0.28); } @@ -122,22 +125,15 @@ img { height: 7px; } -.project-badge { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 7px 12px; - color: #5b5870; - background: rgba(255, 255, 255, 0.68); - border: 1px solid rgba(218, 214, 231, 0.8); - border-radius: 999px; - font-size: 0.78rem; - font-weight: 680; -} - -.project-badge span { - color: var(--violet); - font-size: 0.66rem; +.brand__descriptor { + margin-left: 3px; + padding-left: 14px; + color: var(--muted); + border-left: 1px solid var(--line); + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.02em; } .hero { @@ -156,6 +152,7 @@ img { font-weight: 800; letter-spacing: 0.11em; text-transform: uppercase; + font-family: var(--mono); } .hero__copy > .eyebrow { @@ -165,11 +162,9 @@ img { } .hero__copy > .eyebrow span { - width: 7px; - height: 7px; - border-radius: 99px; - background: var(--violet); - box-shadow: 0 0 0 5px var(--violet-soft); + color: var(--green); + font-size: 0.76rem; + letter-spacing: -0.08em; } .hero h1 { @@ -204,6 +199,7 @@ img { color: #646174; font-size: 0.78rem; font-weight: 650; + font-family: var(--mono); list-style: none; } @@ -215,13 +211,14 @@ img { .trust-list span { color: var(--green); + font-size: 0.66rem; } .analyzer-panel { position: relative; padding: 31px; overflow: hidden; - border-radius: 21px; + border-radius: 12px; box-shadow: var(--shadow); } @@ -229,8 +226,8 @@ img { content: ""; position: absolute; inset: 0 0 auto; - height: 3px; - background: linear-gradient(90deg, var(--violet), #9c8bff, #52bfb7); + height: 2px; + background: linear-gradient(90deg, var(--violet), #52bfb7); } .analyzer-panel__header { @@ -265,8 +262,9 @@ img { place-items: center; color: white; background: var(--violet); - border-radius: 11px; - font-size: 1.25rem; + border-radius: 7px; + font-family: var(--mono); + font-size: 0.78rem; font-weight: 800; } @@ -275,16 +273,17 @@ img { margin-bottom: 8px; font-size: 0.78rem; font-weight: 750; + font-family: var(--mono); } .url-field { display: flex; align-items: center; min-height: 51px; - padding-inline: 15px; + padding-right: 15px; border: 1px solid #dad7e5; - border-radius: 11px; - background: #fbfbfd; + border-radius: 7px; + background: #f8f9fc; transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; } @@ -294,10 +293,18 @@ img { box-shadow: 0 0 0 4px rgba(103, 84, 232, 0.12); } -.url-field__icon { - margin-right: 10px; - color: #9b98a9; - font-size: 1.2rem; +.request-method { + align-self: stretch; + display: grid; + min-width: 54px; + margin-right: 13px; + place-items: center; + color: var(--green); + border-right: 1px solid var(--line); + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.06em; } .url-field input { @@ -309,6 +316,7 @@ img { border: 0; outline: 0; font-size: 0.9rem; + font-family: var(--mono); } .url-field input::placeholder { @@ -326,11 +334,12 @@ img { color: white; background: linear-gradient(135deg, #705ceb, #5945d5); border: 0; - border-radius: 11px; + border-radius: 7px; box-shadow: 0 10px 22px rgba(91, 70, 215, 0.24); cursor: pointer; font-size: 0.88rem; font-weight: 760; + font-family: var(--mono); transition: translate 160ms ease, box-shadow 160ms ease; } @@ -351,6 +360,7 @@ summary:focus-visible { color: #9491a1; font-size: 0.7rem; text-align: center; + font-family: var(--mono); } .form-note span { @@ -367,16 +377,17 @@ summary:focus-visible { .feature-grid article { padding: 28px; - background: rgba(255, 255, 255, 0.52); - border: 1px solid rgba(229, 226, 237, 0.72); - border-radius: 17px; + background: rgba(255, 255, 255, 0.68); + border: 1px solid var(--line); + border-radius: 9px; } .feature-icon { width: 36px; height: 36px; margin-bottom: 23px; - font-size: 1rem; + font-size: 0.68rem; + letter-spacing: 0.05em; } .feature-icon--violet { @@ -423,7 +434,7 @@ summary:focus-visible { justify-content: space-between; gap: 32px; padding: 34px 38px; - border-radius: 20px; + border-radius: 11px; } .results-summary h2 { @@ -446,6 +457,7 @@ summary:focus-visible { color: var(--violet-dark); font-size: 0.78rem; font-weight: 700; + font-family: var(--mono); text-decoration: none; text-overflow: ellipsis; white-space: nowrap; @@ -461,7 +473,8 @@ summary:focus-visible { color: var(--violet); background: var(--violet-soft); border: 1px solid #ded8ff; - border-radius: 50%; + border-radius: 9px; + font-family: var(--mono); } .score strong { @@ -509,7 +522,7 @@ summary:focus-visible { padding: 20px; background: rgba(255, 255, 255, 0.7); border: 1px solid var(--line); - border-radius: 14px; + border-radius: 8px; } .audit-item__topline { @@ -537,7 +550,8 @@ summary:focus-visible { align-items: center; gap: 5px; padding: 4px 8px; - border-radius: 999px; + border-radius: 4px; + font-family: var(--mono); font-size: 0.64rem; font-weight: 760; } @@ -579,7 +593,7 @@ summary:focus-visible { .search-preview, .social-preview { overflow: hidden; - border-radius: 17px; + border-radius: 10px; } .search-preview { @@ -601,6 +615,7 @@ summary:focus-visible { .preview-label strong { font-size: 0.8rem; + font-family: var(--mono); } .preview-label div > span { @@ -745,7 +760,7 @@ summary:focus-visible { .metadata { margin-top: 18px; overflow: hidden; - border-radius: 17px; + border-radius: 10px; } .metadata summary { @@ -769,6 +784,7 @@ summary:focus-visible { .metadata summary strong { font-size: 0.86rem; + font-family: var(--mono); } .metadata summary small { @@ -781,6 +797,7 @@ summary:focus-visible { color: var(--violet); font-size: 0.72rem; font-weight: 750; + font-family: var(--mono); } .metadata[open] .details-action span { @@ -834,7 +851,7 @@ summary:focus-visible { align-items: flex-start; margin: 10px 0 84px; padding: 31px; - border-radius: 18px; + border-radius: 10px; } .error-card__icon { @@ -926,11 +943,6 @@ summary:focus-visible { min-height: 68px; } - .project-badge { - padding: 6px 9px; - font-size: 0.68rem; - } - .hero { min-height: 0; padding-block: 40px 56px; @@ -956,7 +968,7 @@ summary:focus-visible { .analyzer-panel { padding: 24px 20px; - border-radius: 17px; + border-radius: 10px; } .feature-grid, @@ -1034,18 +1046,8 @@ summary:focus-visible { } @media (max-width: 440px) { - .project-badge { - width: 30px; - height: 30px; - justify-content: center; - overflow: hidden; - padding: 0; - font-size: 0; - white-space: nowrap; - } - - .project-badge span { - font-size: 0.7rem; + .brand__descriptor { + display: none; } .results-summary { diff --git a/server.js b/server.js index be72506..056ce70 100644 --- a/server.js +++ b/server.js @@ -325,26 +325,26 @@ function renderPage(form, content) { MetaScope โ€” SEO Audit & Social Preview - +
-

SEO + social metadata inspector

+

SEO + social metadata inspector

Preview how your page appears before you share it.

Audit essential SEO tags and see realistic search and social previews in one focused report.

    -
  • No signup. No API key.
  • -
  • Secure public-URL analysis
  • -
  • Results in seconds
  • +
  • No signup. No API key.
  • +
  • SSRF-protected requests
  • +
  • Server-rendered results
${form} @@ -399,37 +399,37 @@ function createApp({ const form = `
- +

Analyze a webpage

-

Enter a public page URL to get started.

+

Fetch and inspect the rendered metadata.

- + GET
-

Only publicly accessible HTML pages can be analyzed.

+

public HTML only ยท 2 MB max ยท 10s timeout

`; let content = `
- +

Audit SEO essentials

Check titles, descriptions, headings, canonical URLs, language, and crawl settings.

- +

Preview every share

See how your page can appear across search, Facebook, X, and LinkedIn.

- +

Spot fixes quickly

Scan clear pass states and practical opportunities without digging through source.

diff --git a/test/app.test.js b/test/app.test.js index 763ac85..bc39abb 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -41,6 +41,9 @@ test("explains the product and next step on the landing page", async () => { assert.match(response.text, /Enter a public page URL/); assert.match(response.text, /Run free audit/); assert.match(response.text, /No signup\. No API key\./); + assert.match(response.text, /Metadata debugger/); + assert.match(response.text, /request-method">GET/); + assert.doesNotMatch(response.text, /Portfolio project/); }); test("summarizes audit health and exposes semantic result statuses", async () => { From 8abe39d94047020dd0fbb7e67276b54bc5830b56 Mon Sep 17 00:00:00 2001 From: Muhammad Ali Anjum Date: Thu, 1 Oct 2026 15:14:29 +0200 Subject: [PATCH 3/3] Fix Open Graph completeness scoring --- server.js | 16 ++++++++-------- test/app.test.js | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/server.js b/server.js index 056ce70..6838750 100644 --- a/server.js +++ b/server.js @@ -45,16 +45,14 @@ function renderAnalysis(html, targetUrl) { const ogTitle = metaContent($, 'meta[property="og:title"]'); const ogDescription = metaContent($, 'meta[property="og:description"]'); + const rawOgImage = metaContent($, 'meta[property="og:image"]'); + const rawOgUrl = metaContent($, 'meta[property="og:url"]'); const ogImage = safeWebUrl( - metaContent($, 'meta[property="og:image"]'), + rawOgImage, targetUrl, PLACEHOLDER_IMAGE, ); - const ogUrl = safeWebUrl( - metaContent($, 'meta[property="og:url"]'), - targetUrl, - targetUrl, - ); + const ogUrl = safeWebUrl(rawOgUrl, targetUrl, targetUrl); const twitterCard = metaContent($, 'meta[name="twitter:card"]'); const twitterTitle = metaContent($, 'meta[name="twitter:title"]'); const twitterDescription = metaContent( @@ -124,9 +122,11 @@ function renderAnalysis(html, targetUrl) { { name: "Open Graph Tags", status: - ogTitle && ogDescription && ogImage && ogUrl ? "Complete" : "Incomplete", + ogTitle && ogDescription && rawOgImage && rawOgUrl + ? "Complete" + : "Incomplete", suggestion: - ogTitle && ogDescription && ogImage && ogUrl + ogTitle && ogDescription && rawOgImage && rawOgUrl ? "Looks good!" : "Ensure all required OG tags are present: og:title, og:description, og:image, and og:url.", }, diff --git a/test/app.test.js b/test/app.test.js index bc39abb..b179ad7 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -60,6 +60,26 @@ test("summarizes audit health and exposes semantic result statuses", async () => assert.match(response.text, /Search result preview/); }); +test("does not count fallback URLs as complete Open Graph metadata", async () => { + const partialOpenGraphHtml = ` + Partial metadata fixture + + `; + const app = createApp({ pageFetcher: async () => partialOpenGraphHtml }); + const response = await request(app) + .get("/") + .query({ url: "https://example.com/source" }) + .expect(200); + + const openGraphAuditItem = response.text.match( + /
(?:(?!<\/article>)[\s\S])*Open Graph Tags(?:(?!<\/article>)[\s\S])*<\/article>/, + ); + assert.ok(openGraphAuditItem); + assert.match(openGraphAuditItem[0], /status--attention/); + assert.match(openGraphAuditItem[0], /Incomplete/); + assert.match(response.text, /src="\/preview-placeholder\.svg"/); +}); + test("serves the local responsive stylesheet", async () => { const app = createApp(); const response = await request(app).get("/styles.css").expect(200);