From f5bf6d17bbcdfa6d29b89e67aab4109e3a1dc278 Mon Sep 17 00:00:00 2001 From: Muhammad Ali Anjum Date: Thu, 1 Oct 2026 17:41:34 +0200 Subject: [PATCH] Implement Release 1 metadata workflow --- README.md | 35 ++- lib/analyze.js | 109 ++++++++ lib/audit.js | 200 +++++++++++++++ lib/image-inspector.js | 106 ++++++++ lib/metadata.js | 76 ++++++ lib/safe-fetch.js | 1 + package.json | 1 + pnpm-lock.yaml | 10 + public/app.js | 91 +++++++ public/styles.css | 231 ++++++++++++++++- public/tag-generator.js | 50 ++++ server.js | 470 +++++++++++++++++++---------------- test/analyze.test.js | 55 ++++ test/app.test.js | 143 ++++++++++- test/audit.test.js | 119 +++++++++ test/image-inspector.test.js | 142 +++++++++++ test/metadata.test.js | 66 +++++ test/tag-generator.test.js | 37 +++ 18 files changed, 1710 insertions(+), 232 deletions(-) create mode 100644 lib/analyze.js create mode 100644 lib/audit.js create mode 100644 lib/image-inspector.js create mode 100644 lib/metadata.js create mode 100644 public/app.js create mode 100644 public/tag-generator.js create mode 100644 test/analyze.test.js create mode 100644 test/audit.test.js create mode 100644 test/image-inspector.test.js create mode 100644 test/metadata.test.js create mode 100644 test/tag-generator.test.js diff --git a/README.md b/README.md index 4f45840..44fbbbf 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # 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. +A compact Node.js application that audits a public webpage or pasted HTML, explains deterministic SEO and social metadata findings, and lets you edit metadata against live previews before copying generated tags. It demonstrates server-side HTML parsing, defensive outbound HTTP requests, secure rendering, and automated HTTP testing. ## Features -- Checks title, description, canonical URL, H1, language, robots, and viewport metadata. -- Reports completeness of Open Graph and Twitter Card tags. +- Accepts a public URL or pasted HTML for localhost, staging, and bot-blocked pages. +- Scores transparent SEO, indexability, Open Graph, and Twitter/X rules from 0–100. - Renders search, Facebook, X/Twitter, and LinkedIn-style preview cards. +- Validates social-image reachability, type, bytes, dimensions, and aspect ratio. +- Edits draft metadata with instant preview updates and generated `` tags. - 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. @@ -18,7 +20,10 @@ Because the application fetches user-provided URLs, outbound requests are treate - Only HTTP and HTTPS URLs without embedded credentials are accepted. - Local, private, reserved, link-local, and non-public IPv4/IPv6 targets are blocked. - DNS results and redirect destinations are checked to reduce SSRF risk. -- Requests have a 10-second timeout, five-redirect limit, and 2 MB response limit. +- Page requests have a 10-second timeout, five-redirect limit, and 2 MB response limit. +- Image requests reuse the SSRF boundary with a 7-second timeout and 5 MB limit. +- Preview images are loaded through a bounded same-origin route instead of directly from metadata-controlled hosts. +- Pasted HTML is processed in memory and capped at 256 KB. - Non-HTML responses are rejected. - Metadata and URL attributes are escaped or sanitized before rendering. - Helmet security headers, a Content Security Policy, and per-IP rate limiting are enabled. @@ -31,6 +36,7 @@ These controls are appropriate for a demonstration project, but a public high-tr - Express 5 - Axios - Cheerio +- image-size - Responsive, dependency-free CSS - Node's built-in test runner and Supertest @@ -49,7 +55,7 @@ pnpm install pnpm start ``` -Open [http://localhost:3001](http://localhost:3001), then enter a complete public URL such as `https://example.com`. +Open [http://localhost:3001](http://localhost:3001), then enter a complete public URL such as `https://example.com` or switch to **HTML** and paste page markup. Supply a public base URL in HTML mode when relative canonical or image URLs need to be resolved. To use another port: @@ -85,6 +91,10 @@ The test suite covers: - HTML and attribute escaping - Unsafe preview URL removal - Successful SEO and social metadata rendering +- URL and pasted-HTML analysis modes +- Deterministic audit rules and scoring +- Bounded social-image inspection and safe same-origin previews +- Generated metadata-tag escaping and unsafe URL omission - HTTP security headers, error responses, and rate limiting ## Project structure @@ -92,9 +102,18 @@ The test suite covers: ```text . ├── .github/workflows/ci.yml # Node 20/22 CI checks -├── lib/safe-fetch.js # URL validation and bounded HTTP fetching +├── lib/ +│ ├── analyze.js # Shared analysis pipeline +│ ├── audit.js # Deterministic rules and scoring +│ ├── image-inspector.js # Bounded social-image validation +│ ├── metadata.js # Normalized metadata parsing +│ └── safe-fetch.js # URL validation and bounded page fetching +├── public/ +│ ├── app.js # Input tabs and live draft previews +│ ├── tag-generator.js # Shared browser/server tag generation +│ └── styles.css ├── test/ # Unit and HTTP integration tests -├── server.js # Express app, metadata parsing, and rendering +├── server.js # Express routes and server rendering ├── package.json └── pnpm-lock.yaml ``` @@ -108,8 +127,10 @@ The previous Heroku demo is no longer linked because it currently returns 404. ## Known limitations - Social previews approximate platform layouts; platforms may apply additional rules and image processing. +- The numerical score is a transparent MetaScope rules score, not a search-engine ranking prediction. - JavaScript-rendered metadata is not executed because the application analyzes the fetched HTML response. - Public websites may block automated requests or return different metadata based on geography or user agent. +- Draft edits update previews and generated tags but do not recalculate the source-page audit. ## License diff --git a/lib/analyze.js b/lib/analyze.js new file mode 100644 index 0000000..106c37f --- /dev/null +++ b/lib/analyze.js @@ -0,0 +1,109 @@ +const { analyzeMetadata, summarizeChecks } = require("./audit"); +const { parseMetadata } = require("./metadata"); + +function analyzeHtml(html, sourceUrl) { + const metadata = parseMetadata(html, sourceUrl); + return { + source: { mode: "html", requestedUrl: sourceUrl || "", finalUrl: sourceUrl || "" }, + metadata, + ...analyzeMetadata(metadata, sourceUrl), + }; +} + +async function analyzeDocument(html, sourceUrl, { imageInspector, mode = "html" } = {}) { + const analysis = analyzeHtml(html, sourceUrl); + analysis.source.mode = mode; + const inspect = async (image) => { + if (!image.url || typeof imageInspector !== "function") return null; + try { + return await imageInspector(image.url); + } catch (error) { + return { + url: image.url, + reachable: false, + error: Number.isInteger(error.statusCode) + ? error.message + : "The image could not be inspected.", + }; + } + }; + + const [openGraph, twitter] = await Promise.all([ + inspect(analysis.metadata.openGraph.image), + analysis.metadata.twitter.image.url === analysis.metadata.openGraph.image.url + ? Promise.resolve(null) + : inspect(analysis.metadata.twitter.image), + ]); + + const imageResult = (id, label, report) => { + if (!report) return null; + const category = id.startsWith("og-") ? "open-graph" : "twitter"; + if (!report.reachable) { + return { + id, + category, + severity: "error", + status: "error", + message: `${label} ${report.error || "could not be inspected."}`, + evidence: report.url || "", + weight: 10, + }; + } + if (!report.https) { + return { + id, + category, + severity: "warning", + status: "warning", + message: `${label} should use HTTPS.`, + evidence: report.url, + weight: 3, + }; + } + if (!report.recommendedDimensions) { + return { + id, + category, + severity: "warning", + status: "warning", + message: `${label} is ${report.width} × ${report.height}; consider 1200 × 630.`, + evidence: `${report.contentType}, ${report.bytes} bytes`, + weight: 3, + }; + } + return { + id, + category, + severity: "pass", + status: "pass", + message: `${label} is reachable and uses the recommended dimensions.`, + evidence: `${report.width} × ${report.height}, ${report.contentType}`, + weight: 0, + }; + }; + + const twitterReport = twitter || ( + analysis.metadata.twitter.image.url && + analysis.metadata.twitter.image.url === analysis.metadata.openGraph.image.url + ? openGraph + : null + ); + const replacements = [ + imageResult("og-image", "Open Graph image", openGraph), + analysis.metadata.twitter.image.raw + ? imageResult("twitter-image", "Twitter image", twitterReport) + : null, + ].filter(Boolean); + for (const replacement of replacements) { + const index = analysis.checks.findIndex((check) => check.id === replacement.id); + if (index >= 0) analysis.checks[index] = replacement; + } + + return { + ...analysis, + score: summarizeChecks(analysis.checks), + images: { openGraph, twitter: twitterReport }, + }; +} + +module.exports = { analyzeDocument, analyzeHtml }; diff --git a/lib/audit.js b/lib/audit.js new file mode 100644 index 0000000..6b372a5 --- /dev/null +++ b/lib/audit.js @@ -0,0 +1,200 @@ +const VALID_TWITTER_CARDS = new Set([ + "app", + "player", + "summary", + "summary_large_image", +]); + +function result(id, category, severity, message, evidence = "") { + const status = severity === "pass" ? "pass" : severity; + return { + id, + category, + severity, + status, + message, + evidence, + weight: severity === "error" ? 10 : severity === "warning" ? 3 : 0, + }; +} + +function lengthCheck(id, label, value, minimum, maximum) { + if (!value) { + return result(id, "seo", "error", `Add a ${label}.`); + } + if (value.length < minimum || value.length > maximum) { + return result( + id, + "seo", + "warning", + `${label[0].toUpperCase()}${label.slice(1)} should be between ${minimum} and ${maximum} characters.`, + `${value.length} characters`, + ); + } + return result(id, "seo", "pass", `${label[0].toUpperCase()}${label.slice(1)} looks good.`, `${value.length} characters`); +} + +function normalizedUrl(value) { + if (!value) return ""; + try { + const url = new URL(value); + url.hash = ""; + return url.toString(); + } catch { + return ""; + } +} + +function textDifference(first, second) { + if (!first || !second) return null; + const words = (value) => new Set( + value.toLowerCase().match(/[a-z0-9]+/g)?.filter((word) => word.length > 2) || [], + ); + const firstWords = words(first); + const secondWords = words(second); + if (!firstWords.size || !secondWords.size) return null; + const overlap = [...firstWords].filter((word) => secondWords.has(word)).length; + return overlap / Math.min(firstWords.size, secondWords.size) < 0.5; +} + +function summarizeChecks(checks) { + const deduction = checks.reduce((total, check) => total + check.weight, 0); + return { + value: Math.max(0, 100 - deduction), + errors: checks.filter((check) => check.status === "error").length, + warnings: checks.filter((check) => check.status === "warning").length, + passed: checks.filter((check) => check.status === "pass").length, + info: checks.filter((check) => check.status === "info").length, + }; +} + +function socialField(id, category, label, explicitValue, fallbackValue) { + if (explicitValue) return result(id, category, "pass", `${label} is explicitly defined.`); + if (fallbackValue) { + return result(id, category, "info", `${label} falls back to Open Graph metadata.`); + } + return result(id, category, "warning", `Add ${label}.`); +} + +function analyzeMetadata(metadata, targetUrl) { + const checks = []; + checks.push(lengthCheck("title", "title", metadata.title, 30, 60)); + checks.push(lengthCheck("description", "meta description", metadata.description, 50, 160)); + + if (!metadata.canonical.raw) { + checks.push(result("canonical", "seo", "warning", "Add a canonical URL.")); + } else if (!metadata.canonical.url) { + checks.push(result("canonical", "seo", "error", "Use a valid HTTP or HTTPS canonical URL.", metadata.canonical.raw)); + } else { + checks.push(result("canonical", "seo", "pass", "Canonical URL is valid.", metadata.canonical.url)); + } + + const canonical = normalizedUrl(metadata.canonical.url); + const target = normalizedUrl(targetUrl); + checks.push( + !canonical || !target + ? result("canonical-target", "seo", "info", "Canonical comparison was not evaluated.") + : canonical !== target + ? result("canonical-target", "seo", "warning", "Canonical URL differs from the analyzed URL.", canonical) + : result("canonical-target", "seo", "pass", "Canonical URL matches the analyzed URL."), + ); + + const h1Count = metadata.headings.h1.length; + checks.push( + h1Count === 0 + ? result("h1", "seo", "error", "Add one primary H1 heading.") + : h1Count > 1 + ? result("h1", "seo", "warning", "Use one primary H1 heading.", `${h1Count} H1 elements`) + : result("h1", "seo", "pass", "One primary H1 heading was found."), + ); + + const lang = metadata.document.lang; + checks.push( + !lang + ? result("language", "seo", "warning", "Add a language attribute to the html element.") + : /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(lang) + ? result("language", "seo", "pass", "Document language is defined.", lang) + : result("language", "seo", "warning", "Use a valid language tag.", lang), + ); + checks.push( + metadata.document.viewport + ? result("viewport", "seo", "pass", "Viewport metadata is defined.") + : result("viewport", "seo", "warning", "Add viewport metadata for responsive layouts."), + ); + + const robots = metadata.document.robots.toLowerCase().split(/\s*,\s*/).filter(Boolean); + checks.push( + robots.includes("noindex") + ? result("robots-noindex", "indexability", "error", "Remove noindex to allow search indexing.") + : result("robots-noindex", "indexability", "pass", "The page is not marked noindex."), + ); + checks.push( + robots.includes("nofollow") + ? result("robots-nofollow", "indexability", "warning", "Remove nofollow if crawlers should follow page links.") + : result("robots-nofollow", "indexability", "pass", "The page is not marked nofollow."), + ); + + for (const [id, label, value] of [ + ["og-title", "og:title", metadata.openGraph.title], + ["og-description", "og:description", metadata.openGraph.description], + ["og-image", "og:image", metadata.openGraph.image.raw], + ]) { + checks.push( + value + ? result(id, "open-graph", "pass", `${label} is defined.`) + : result(id, "open-graph", "error", `Add ${label}.`), + ); + } + + checks.push( + !metadata.openGraph.url.raw + ? result("og-url", "open-graph", "error", "Add og:url.") + : metadata.openGraph.url.url + ? result("og-url", "open-graph", "pass", "og:url is valid.") + : result("og-url", "open-graph", "error", "Use a valid HTTP or HTTPS og:url.", metadata.openGraph.url.raw), + ); + checks.push( + !metadata.openGraph.url.url || !canonical + ? result("og-url-canonical", "open-graph", "info", "Open Graph URL comparison was not evaluated.") + : normalizedUrl(metadata.openGraph.url.url) !== canonical + ? result("og-url-canonical", "open-graph", "warning", "og:url differs from the canonical URL.") + : result("og-url-canonical", "open-graph", "pass", "og:url is consistent with the canonical URL."), + ); + const titleDifference = textDifference(metadata.openGraph.title, metadata.title); + checks.push( + titleDifference === null + ? result("og-title-consistency", "open-graph", "info", "Title consistency was not evaluated.") + : titleDifference + ? result("og-title-consistency", "open-graph", "warning", "og:title differs significantly from the page title.") + : result("og-title-consistency", "open-graph", "pass", "Open Graph and page titles are consistent."), + ); + const descriptionDifference = textDifference( + metadata.openGraph.description, + metadata.description, + ); + checks.push( + descriptionDifference === null + ? result("og-description-consistency", "open-graph", "info", "Description consistency was not evaluated.") + : descriptionDifference + ? result("og-description-consistency", "open-graph", "warning", "og:description differs significantly from the meta description.") + : result("og-description-consistency", "open-graph", "pass", "Open Graph and meta descriptions are consistent."), + ); + + checks.push( + !metadata.twitter.card + ? result("twitter-card", "twitter", "warning", "Add twitter:card.") + : VALID_TWITTER_CARDS.has(metadata.twitter.card) + ? result("twitter-card", "twitter", "pass", "Twitter card type is supported.", metadata.twitter.card) + : result("twitter-card", "twitter", "warning", "Use a supported Twitter card type.", metadata.twitter.card), + ); + checks.push(socialField("twitter-title", "twitter", "twitter:title", metadata.twitter.title, metadata.openGraph.title)); + checks.push(socialField("twitter-description", "twitter", "twitter:description", metadata.twitter.description, metadata.openGraph.description)); + checks.push(socialField("twitter-image", "twitter", "twitter:image", metadata.twitter.image.raw, metadata.openGraph.image.raw)); + + return { + checks, + score: summarizeChecks(checks), + }; +} + +module.exports = { analyzeMetadata, summarizeChecks }; diff --git a/lib/image-inspector.js b/lib/image-inspector.js new file mode 100644 index 0000000..19cb5ea --- /dev/null +++ b/lib/image-inspector.js @@ -0,0 +1,106 @@ +const dns = require("node:dns"); +const axios = require("axios"); +const { imageSize } = require("image-size"); +const { + TargetUrlError, + createSafeLookup, + normalizeTargetUrl, + validateRedirect, +} = require("./safe-fetch"); + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +const IMAGE_TIMEOUT_MS = 7_000; +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); + +class ImageFetchError extends Error { + constructor(message, statusCode = 502) { + super(message); + this.name = "ImageFetchError"; + this.statusCode = statusCode; + } +} + +async function fetchImage(value, { client = axios, lookup = dns.lookup } = {}) { + const url = normalizeTargetUrl(value); + try { + const response = await client.get(url, { + beforeRedirect: validateRedirect, + headers: { + Accept: "image/webp,image/png,image/jpeg,image/gif", + "User-Agent": "MetaScope/2.0 (+https://github.com/alianjum0/ogpreview-node)", + }, + lookup: createSafeLookup(lookup), + maxBodyLength: MAX_IMAGE_BYTES, + maxContentLength: MAX_IMAGE_BYTES, + maxRedirects: 5, + proxy: false, + responseType: "arraybuffer", + timeout: IMAGE_TIMEOUT_MS, + }); + + const contentType = String(response.headers["content-type"] || "") + .split(";", 1)[0] + .trim() + .toLowerCase(); + if (!SUPPORTED_IMAGE_TYPES.has(contentType)) { + throw new ImageFetchError("The image URL did not return a supported image.", 422); + } + + const buffer = Buffer.from(response.data); + if (buffer.length > MAX_IMAGE_BYTES) { + throw new ImageFetchError("The image is larger than 5 MB.", 422); + } + return { buffer, contentType, url }; + } catch (error) { + if (error instanceof TargetUrlError || error instanceof ImageFetchError) throw error; + if (error.cause instanceof TargetUrlError) throw error.cause; + if (error.code === "ECONNABORTED") { + throw new ImageFetchError("The image took too long to respond."); + } + if ( + error.code === "ERR_BAD_RESPONSE" && + String(error.message).includes("maxContentLength") + ) { + throw new ImageFetchError("The image is larger than 5 MB.", 422); + } + throw new ImageFetchError("The image could not be inspected."); + } +} + +async function inspectImage(value, options = {}) { + const { buffer, contentType, url } = await fetchImage(value, options); + let dimensions; + try { + dimensions = imageSize(buffer); + } catch { + throw new ImageFetchError("The image data is invalid or unsupported.", 422); + } + if (!dimensions.width || !dimensions.height) { + throw new ImageFetchError("The image dimensions could not be determined.", 422); + } + + return { + url, + reachable: true, + https: new URL(url).protocol === "https:", + contentType, + bytes: buffer.length, + width: dimensions.width, + height: dimensions.height, + aspectRatio: Math.round((dimensions.width / dimensions.height) * 100) / 100, + recommendedDimensions: dimensions.width === 1200 && dimensions.height === 630, + }; +} + +module.exports = { + IMAGE_TIMEOUT_MS, + MAX_IMAGE_BYTES, + ImageFetchError, + fetchImage, + inspectImage, +}; diff --git a/lib/metadata.js b/lib/metadata.js new file mode 100644 index 0000000..77b5b3c --- /dev/null +++ b/lib/metadata.js @@ -0,0 +1,76 @@ +const cheerio = require("cheerio"); + +function safeWebUrl(value, baseUrl, fallback = "") { + if (!value) return fallback; + try { + const parsed = new URL(value, baseUrl); + return ["http:", "https:"].includes(parsed.protocol) + ? parsed.toString() + : fallback; + } catch { + return fallback; + } +} + +function metaContent($, selector) { + return ($(selector).first().attr("content") || "").trim(); +} + +function urlValue(raw, baseUrl) { + return { raw, url: safeWebUrl(raw, baseUrl) }; +} + +function parseMetadata(html, baseUrl) { + const $ = cheerio.load(html || ""); + const canonicalRaw = ($('link[rel="canonical"]').first().attr("href") || "").trim(); + const ogImageRaw = metaContent($, 'meta[property="og:image"]'); + const ogUrlRaw = metaContent($, 'meta[property="og:url"]'); + const twitterImageRaw = metaContent($, 'meta[name="twitter:image"]'); + const socialTags = {}; + + $("meta").each((_index, element) => { + const property = ($(element).attr("property") || "").trim(); + const name = ($(element).attr("name") || "").trim(); + const value = ($(element).attr("content") || "").trim(); + if (!value) return; + if (property.startsWith("og:") || property.startsWith("twitter:")) { + socialTags[property] = value; + } + if (name.startsWith("og:") || name.startsWith("twitter:")) { + socialTags[name] = value; + } + }); + + return { + sourceUrl: safeWebUrl(baseUrl, undefined), + title: $("title").first().text().trim(), + description: metaContent($, 'meta[name="description"]'), + canonical: urlValue(canonicalRaw, baseUrl), + headings: { + h1: $("h1") + .map((_index, element) => $(element).text().trim()) + .get() + .filter(Boolean), + }, + document: { + lang: ($("html").attr("lang") || "").trim(), + robots: metaContent($, 'meta[name="robots"]'), + viewport: metaContent($, 'meta[name="viewport"]'), + }, + openGraph: { + title: metaContent($, 'meta[property="og:title"]'), + description: metaContent($, 'meta[property="og:description"]'), + image: urlValue(ogImageRaw, baseUrl), + url: urlValue(ogUrlRaw, baseUrl), + }, + twitter: { + card: metaContent($, 'meta[name="twitter:card"]'), + title: metaContent($, 'meta[name="twitter:title"]'), + description: metaContent($, 'meta[name="twitter:description"]'), + image: urlValue(twitterImageRaw, baseUrl), + }, + socialTags, + }; +} + +module.exports = { parseMetadata, safeWebUrl }; diff --git a/lib/safe-fetch.js b/lib/safe-fetch.js index 58c94bb..6b0c60a 100644 --- a/lib/safe-fetch.js +++ b/lib/safe-fetch.js @@ -155,4 +155,5 @@ module.exports = { fetchPage, isBlockedAddress, normalizeTargetUrl, + validateRedirect, }; diff --git a/package.json b/package.json index 94e73e5..ddc3367 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.7.0", "helmet": "^8.3.0", + "image-size": "^2.0.4", "ipaddr.js": "^2.5.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eaa9e6..4f3c68b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: helmet: specifier: ^8.3.0 version: 8.3.0 + image-size: + specifier: ^2.0.4 + version: 2.0.4 ipaddr.js: specifier: ^2.5.0 version: 2.5.0 @@ -302,6 +305,11 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + image-size@2.0.4: + resolution: {integrity: sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==} + engines: {node: '>=18'} + hasBin: true + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -829,6 +837,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + image-size@2.0.4: {} + inherits@2.0.4: {} ip-address@10.7.2: {} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..21cdeaf --- /dev/null +++ b/public/app.js @@ -0,0 +1,91 @@ +(function initializeMetaScope() { + const placeholder = "/preview-placeholder.svg"; + + document.querySelectorAll("[data-input-mode]").forEach((button) => { + button.addEventListener("click", () => { + const mode = button.dataset.inputMode; + document.querySelectorAll("[data-input-mode]").forEach((tab) => { + tab.setAttribute("aria-selected", String(tab === button)); + }); + document.querySelectorAll("[data-input-panel]").forEach((panel) => { + panel.hidden = panel.dataset.inputPanel !== mode; + }); + }); + }); + + const editor = document.querySelector("[data-metadata-editor]"); + if (!editor || !globalThis.MetaScopeTags) return; + + const form = editor.querySelector("form"); + const generatedTags = editor.querySelector("[data-generated-tags]"); + const copyStatus = editor.querySelector("[data-copy-status]"); + let lastOgImage = form.elements.ogImage.value; + let lastTwitterImage = form.elements.twitterImage.value || lastOgImage; + + function draft() { + return Object.fromEntries(new FormData(form).entries()); + } + + function setText(selector, value) { + document.querySelectorAll(selector).forEach((element) => { + element.textContent = value; + }); + } + + function previewImage(value) { + if (!value) return placeholder; + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) return placeholder; + return `/preview-image?url=${encodeURIComponent(url.toString())}`; + } catch { + return placeholder; + } + } + + function updateDraft() { + const values = draft(); + const ogTitle = values.ogTitle || values.title || "No Title"; + const ogDescription = values.ogDescription || values.description || "No Description"; + const displayUrl = values.ogUrl || values.canonical || ""; + const twitterTitle = values.twitterTitle || ogTitle; + const twitterDescription = values.twitterDescription || ogDescription; + + setText('[data-preview="url"]', displayUrl); + setText('[data-preview="search-title"]', values.title || "No Title Found"); + setText('[data-preview="search-description"]', values.description || "No Description Found"); + setText('[data-preview="og-title"]', ogTitle); + setText('[data-preview="og-description"]', ogDescription); + setText('[data-preview="twitter-title"]', twitterTitle); + setText('[data-preview="twitter-description"]', twitterDescription); + + if (values.ogImage !== lastOgImage) { + document.querySelectorAll('[data-preview-image="og"]').forEach((image) => { + image.src = previewImage(values.ogImage); + }); + lastOgImage = values.ogImage; + } + const nextTwitterImage = values.twitterImage || values.ogImage; + if (nextTwitterImage !== lastTwitterImage) { + document.querySelectorAll('[data-preview-image="twitter"]').forEach((image) => { + image.src = previewImage(nextTwitterImage); + }); + lastTwitterImage = nextTwitterImage; + } + generatedTags.value = globalThis.MetaScopeTags.generateTags(values); + copyStatus.textContent = ""; + } + + form.addEventListener("input", updateDraft); + form.addEventListener("reset", () => setTimeout(updateDraft, 0)); + editor.querySelector("[data-copy-tags]").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(generatedTags.value); + copyStatus.textContent = "Tags copied."; + } catch { + generatedTags.focus(); + generatedTags.select(); + copyStatus.textContent = "Select and copy the highlighted tags."; + } + }); +})(); diff --git a/public/styles.css b/public/styles.css index 0e16bc5..734ba3e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -41,10 +41,15 @@ body { } button, -input { +input, +textarea { font: inherit; } +[hidden] { + display: none !important; +} + a { color: inherit; } @@ -575,15 +580,33 @@ summary:focus-visible { background: var(--green); } -.status--attention { +.status--warning { color: var(--amber); background: var(--amber-soft); } -.status--attention span { +.status--warning span { background: #d2801e; } +.status--error { + color: #a33434; + background: #fff0f0; +} + +.status--error span { + background: #bd4141; +} + +.status--info { + color: #3d5d9c; + background: #eef4ff; +} + +.status--info span { + background: #4c70b8; +} + .preview-layout { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1080,3 +1103,205 @@ summary:focus-visible { transition: none !important; } } + +.input-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + margin-bottom: 22px; + padding: 4px; + background: #f0f1f6; + border-radius: 8px; +} + +.input-tabs button { + padding: 9px 12px; + color: var(--ink-soft); + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; + font-family: var(--mono); + font-size: 0.75rem; + font-weight: 750; +} + +.input-tabs button[aria-selected="true"] { + color: var(--violet-dark); + background: white; + box-shadow: 0 2px 8px rgba(23, 28, 48, 0.08); +} + +.analyzer-form { + display: grid; + gap: 12px; +} + +.analyzer-form > label, +.metadata-editor label { + color: var(--ink-soft); + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 700; +} + +.analyzer-form textarea, +.metadata-editor input, +.metadata-editor textarea { + width: 100%; + color: var(--ink); + background: #f8f9fc; + border: 1px solid #dad7e5; + border-radius: 7px; + outline: 0; + font-family: var(--mono); + font-size: 0.78rem; +} + +.analyzer-form textarea, +.metadata-editor textarea { + padding: 13px; + resize: vertical; +} + +.analyzer-form textarea:focus, +.metadata-editor input:focus, +.metadata-editor textarea:focus { + border-color: #8b79ef; + background: white; + box-shadow: 0 0 0 4px rgba(103, 84, 232, 0.12); +} + +.metadata-editor { + margin-top: 28px; + padding: 28px; + border-radius: 12px; +} + +.metadata-editor__heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.metadata-editor__heading h2, +.metadata-editor__heading p { + margin: 0; +} + +.metadata-editor__heading > p { + max-width: 430px; + color: var(--ink-soft); + font-size: 0.78rem; + text-align: right; +} + +.metadata-editor__form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.metadata-editor__form label { + display: grid; + gap: 7px; +} + +.metadata-editor input { + min-height: 44px; + padding: 9px 11px; +} + +.generated-tags, +.metadata-editor__actions { + grid-column: 1 / -1; +} + +.metadata-editor__actions { + display: flex; + align-items: center; + gap: 12px; +} + +.metadata-editor__actions .primary-button, +.secondary-button { + width: auto; + min-height: 42px; + padding: 9px 16px; +} + +.secondary-button { + color: var(--ink-soft); + background: white; + border: 1px solid var(--line); + border-radius: 7px; + cursor: pointer; + font-weight: 700; +} + +[data-copy-status] { + color: var(--green); + font-size: 0.75rem; +} + +.image-reports { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; + padding: 18px; + border-radius: 10px; +} + +.image-report { + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; + font-family: var(--mono); + font-size: 0.7rem; +} + +.image-report strong { + flex-basis: 100%; +} + +.image-report span { + padding: 4px 7px; + color: var(--ink-soft); + background: #f3f4f8; + border-radius: 4px; +} + +.image-report--error span { + color: var(--amber); + background: var(--amber-soft); +} + +@media (max-width: 720px) { + .metadata-editor__heading { + display: block; + } + + .metadata-editor__heading > p { + margin-top: 8px; + text-align: left; + } + + .metadata-editor__form, + .image-reports { + grid-template-columns: 1fr; + } + + .metadata-editor__actions { + align-items: stretch; + flex-direction: column; + } + + .metadata-editor__actions .primary-button, + .secondary-button { + width: 100%; + } +} diff --git a/public/tag-generator.js b/public/tag-generator.js new file mode 100644 index 0000000..3ab9a29 --- /dev/null +++ b/public/tag-generator.js @@ -0,0 +1,50 @@ +(function exposeTagGenerator(root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + else root.MetaScopeTags = api; +})(typeof globalThis === "object" ? globalThis : this, function createTagGenerator() { + function escapeAttribute(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function absoluteWebUrl(value) { + if (!value) return ""; + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) ? url.toString() : ""; + } catch { + return ""; + } + } + + function generateTags(draft = {}) { + const tags = []; + const text = (name) => String(draft[name] || "").trim(); + const addMeta = (attribute, name, value) => { + if (value) tags.push(``); + }; + + if (text("title")) tags.push(`${escapeAttribute(text("title"))}`); + addMeta("name", "description", text("description")); + const canonical = absoluteWebUrl(text("canonical")); + if (canonical) tags.push(``); + + addMeta("property", "og:title", text("ogTitle")); + addMeta("property", "og:description", text("ogDescription")); + addMeta("property", "og:image", absoluteWebUrl(text("ogImage"))); + addMeta("property", "og:url", absoluteWebUrl(text("ogUrl"))); + + addMeta("name", "twitter:card", text("twitterCard")); + addMeta("name", "twitter:title", text("twitterTitle")); + addMeta("name", "twitter:description", text("twitterDescription")); + addMeta("name", "twitter:image", absoluteWebUrl(text("twitterImage"))); + return tags.join("\n"); + } + + return { escapeAttribute, generateTags }; +}); diff --git a/server.js b/server.js index 6838750..09f4852 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,12 @@ -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 { analyzeDocument, analyzeHtml } = require("./lib/analyze"); +const { fetchImage, inspectImage } = require("./lib/image-inspector"); +const { safeWebUrl } = require("./lib/metadata"); +const { fetchPage, normalizeTargetUrl } = require("./lib/safe-fetch"); +const { generateTags } = require("./public/tag-generator"); const PLACEHOLDER_IMAGE = "/preview-placeholder.svg"; @@ -17,173 +20,135 @@ function escapeHtml(value) { .replace(/'/g, "'"); } -function safeWebUrl(value, baseUrl, fallback = "") { - if (!value) return fallback; - try { - const parsed = new URL(value, baseUrl); - return ["http:", "https:"].includes(parsed.protocol) - ? parsed.toString() - : fallback; - } catch { - return fallback; - } +function imagePreviewUrl(report, fallback = PLACEHOLDER_IMAGE) { + return report?.reachable && report.url + ? `/preview-image?url=${encodeURIComponent(report.url)}` + : fallback; } -function metaContent($, selector) { - return ($(selector).attr("content") || "").trim(); +function renderImageReport(label, report) { + if (!report) return ""; + if (!report.reachable) { + return `
${escapeHtml(label)}${escapeHtml(report.error || "Image unavailable")}
`; + } + return `
+ ${escapeHtml(label)} + ${report.width} × ${report.height} + ${Math.round(report.bytes / 1000)} KB + ${escapeHtml(report.contentType)} + ${report.aspectRatio}:1 + ${report.https ? "HTTPS" : "HTTP warning"} + ${report.recommendedDimensions ? "Recommended dimensions" : "Consider 1200 × 630"} +
`; } -function renderAnalysis(html, targetUrl) { - const $ = cheerio.load(html); - const title = $("title").text().trim(); - const metaDescription = metaContent($, 'meta[name="description"]'); - const canonical = ($('link[rel="canonical"]').attr("href") || "").trim(); - const firstH1 = $("h1").first().text().trim(); - const lang = ($("html").attr("lang") || "").trim(); - const robots = metaContent($, 'meta[name="robots"]'); - const viewport = metaContent($, 'meta[name="viewport"]'); - - 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( - rawOgImage, - targetUrl, - PLACEHOLDER_IMAGE, - ); - const ogUrl = safeWebUrl(rawOgUrl, targetUrl, targetUrl); - const twitterCard = metaContent($, 'meta[name="twitter:card"]'); - const twitterTitle = metaContent($, 'meta[name="twitter:title"]'); - const twitterDescription = metaContent( - $, - 'meta[name="twitter:description"]', - ); - const twitterImage = safeWebUrl( - metaContent($, 'meta[name="twitter:image"]'), - targetUrl, - ogImage, - ); +function renderEditor(metadata) { + const draft = { + title: metadata.title, + description: metadata.description, + canonical: metadata.canonical.url || metadata.canonical.raw, + ogTitle: metadata.openGraph.title, + ogDescription: metadata.openGraph.description, + ogImage: metadata.openGraph.image.url || metadata.openGraph.image.raw, + ogUrl: metadata.openGraph.url.url || metadata.openGraph.url.raw, + twitterCard: metadata.twitter.card, + twitterTitle: metadata.twitter.title, + twitterDescription: metadata.twitter.description, + twitterImage: metadata.twitter.image.url || metadata.twitter.image.raw, + }; + const field = (name, label, type = "text") => ` + `; - const seoChecks = [ - { - name: "Title Tag", - status: title ? "Found" : "Missing", - suggestion: title - ? title.length < 30 || title.length > 60 - ? "Title length should be between 30 and 60 characters." - : "Looks good!" - : "Add a tag to the page.", - }, - { - name: "Meta Description", - status: metaDescription ? "Found" : "Missing", - suggestion: metaDescription - ? metaDescription.length < 50 || metaDescription.length > 160 - ? "Meta description should be between 50 and 160 characters." - : "Looks good!" - : "Add a meta description for better SEO.", - }, - { - name: "Canonical Tag", - status: canonical ? "Found" : "Missing", - suggestion: canonical - ? "Looks good!" - : "Add a canonical tag to avoid duplicate content issues.", - }, - { - name: "H1 Tag", - status: firstH1 ? "Found" : "Missing", - suggestion: firstH1 - ? "Looks good!" - : "Add at least one <h1> tag for the main heading.", - }, - { - name: "Language Attribute", - status: lang ? `Found (${lang})` : "Missing", - suggestion: lang - ? "Looks good!" - : 'Specify the language attribute in the <html> tag, e.g., <html lang="en">.', - }, - { - name: "Robots Meta Tag", - status: robots ? "Found" : "Missing", - suggestion: robots - ? "Looks good!" - : 'Add a robots meta tag, e.g., <meta name="robots" content="index,follow">.', - }, - { - name: "Viewport Meta Tag", - status: viewport ? "Found" : "Missing", - suggestion: viewport - ? "Looks good!" - : "Add a viewport meta tag to ensure mobile responsiveness.", - }, - { - name: "Open Graph Tags", - status: - ogTitle && ogDescription && rawOgImage && rawOgUrl - ? "Complete" - : "Incomplete", - suggestion: - ogTitle && ogDescription && rawOgImage && rawOgUrl - ? "Looks good!" - : "Ensure all required OG tags are present: og:title, og:description, og:image, and og:url.", - }, - { - name: "Twitter Card Tags", - status: - twitterCard && twitterTitle && twitterDescription && twitterImage - ? "Complete" - : "Incomplete", - suggestion: - twitterCard && twitterTitle && twitterDescription && twitterImage - ? "Looks good!" - : "Consider adding Twitter Card tags for better social sharing.", - }, - ]; + return `<section class="metadata-editor surface" data-metadata-editor> + <div class="metadata-editor__heading"> + <div><p class="eyebrow">Draft metadata</p><h2>Edit and preview changes</h2></div> + <p>The audit above reflects the fetched source. Draft edits update previews and generated tags only.</p> + </div> + <form class="metadata-editor__form"> + ${field("title", "Title")} + ${field("description", "Meta description")} + ${field("canonical", "Canonical URL", "url")} + ${field("ogTitle", "Open Graph title")} + ${field("ogDescription", "Open Graph description")} + ${field("ogImage", "Open Graph image", "url")} + ${field("ogUrl", "Open Graph URL", "url")} + ${field("twitterCard", "Twitter card")} + ${field("twitterTitle", "Twitter title")} + ${field("twitterDescription", "Twitter description")} + ${field("twitterImage", "Twitter image", "url")} + <div class="metadata-editor__actions"> + <button type="reset" class="secondary-button">Reset detected values</button> + <button type="button" class="primary-button" data-copy-tags>Copy tags</button> + <span role="status" aria-live="polite" data-copy-status></span> + </div> + <label class="generated-tags">Generated <head> tags + <textarea rows="12" readonly data-generated-tags>${escapeHtml(generateTags(draft))}</textarea> + </label> + </form> + </section>`; +} + +function renderAnalysis(htmlOrAnalysis, targetUrl) { + const analysis = typeof htmlOrAnalysis === "string" + ? analyzeHtml(htmlOrAnalysis, targetUrl) + : htmlOrAnalysis; + const { metadata, checks, score } = analysis; + const title = metadata.title; + const metaDescription = metadata.description; + const ogTitle = metadata.openGraph.title; + const ogDescription = metadata.openGraph.description; + const ogImage = imagePreviewUrl(analysis.images?.openGraph); + const ogUrl = metadata.openGraph.url.url || analysis.source.finalUrl || targetUrl || ""; + const twitterTitle = metadata.twitter.title; + const twitterDescription = metadata.twitter.description; + const twitterReport = analysis.images?.twitter || analysis.images?.openGraph; + const twitterImage = imagePreviewUrl(twitterReport, ogImage); + const checkNames = { + title: "Title", + description: "Meta description", + canonical: "Canonical URL", + "canonical-target": "Canonical target", + h1: "Primary heading", + language: "Language attribute", + viewport: "Viewport metadata", + "robots-noindex": "Search indexing", + "robots-nofollow": "Link crawling", + "og-title": "Open Graph title", + "og-description": "Open Graph description", + "og-image": "Open Graph image", + "og-url": "Open Graph URL", + "og-url-canonical": "Open Graph URL consistency", + "og-title-consistency": "Open Graph title consistency", + "og-description-consistency": "Open Graph description consistency", + "twitter-card": "Twitter card", + "twitter-title": "Twitter title", + "twitter-description": "Twitter description", + "twitter-image": "Twitter image", + }; - const seoAuditRows = seoChecks + const seoAuditRows = checks .map( (check) => { - const isReady = check.suggestion === "Looks good!"; - const displayStatus = - !isReady && check.status.startsWith("Found") - ? "Review" - : check.status; + const isReady = check.status === "pass"; + const icon = isReady ? "✓" : check.status === "error" ? "×" : check.status === "info" ? "i" : "!"; return ` <article class="audit-item"> <div class="audit-item__topline"> - <h3>${escapeHtml(check.name)}</h3> - <span class="status status--${isReady ? "pass" : "attention"}"> - <span aria-hidden="true">${isReady ? "✓" : "!"}</span> - ${escapeHtml(displayStatus)} + <h3>${escapeHtml(checkNames[check.id] || check.id)}</h3> + <span class="status status--${escapeHtml(check.status)}"> + <span aria-hidden="true">${icon}</span> + ${escapeHtml(check.status)} </span> </div> - <p>${escapeHtml(check.suggestion)}</p> + <p>${escapeHtml(check.message)}</p> + ${check.evidence ? `<small>${escapeHtml(check.evidence)}</small>` : ""} </article>`; }, ) .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"); - const name = $(element).attr("name"); - const value = $(element).attr("content"); - if (!value) return; - if (property?.startsWith("og:") || property?.startsWith("twitter:")) { - metaTags[property] = value; - } - if (name?.startsWith("og:") || name?.startsWith("twitter:")) { - metaTags[name] = value; - } - }); + const metaTags = metadata.socialTags; const metaRows = Object.entries(metaTags) .map( @@ -210,19 +175,15 @@ function renderAnalysis(html, targetUrl) { <div class="results-summary surface"> <div> <p class="eyebrow">Audit results</p> - <h2 id="results-title">${readyCount} of ${seoChecks.length} checks ready</h2> - <p>${ - opportunityCount - ? `${opportunityCount} ${opportunityCount === 1 ? "opportunity" : "opportunities"} to improve before publishing.` - : "Everything in this quick audit looks ready to share." - }</p> + <h2 id="results-title">${score.value}/100 metadata score</h2> + <p>${score.errors} errors · ${score.warnings} warnings · ${score.passed} passed</p> <a class="source-link" href="${safeOgUrl}" target="_blank" rel="noopener noreferrer"> <span aria-hidden="true">↗</span> ${safeOgUrl} </a> </div> - <div class="score" aria-label="${readyCount} of ${seoChecks.length} checks ready"> - <strong>${readyCount}</strong> - <span>/ ${seoChecks.length}</span> + <div class="score" aria-label="Metadata score ${score.value} out of 100"> + <strong>${score.value}</strong> + <span>/ 100</span> </div> </div> @@ -235,6 +196,8 @@ function renderAnalysis(html, targetUrl) { </div> <div class="audit-grid">${seoAuditRows}</div> + ${renderEditor(metadata)} + <div class="section-heading"> <div> <p class="eyebrow">Live preview</p> @@ -250,9 +213,9 @@ function renderAnalysis(html, targetUrl) { <div><strong>Search result preview</strong><span>Desktop result</span></div> </div> <div class="search-result"> - <p class="search-result__url">${safeOgUrl}</p> - <h3>${previewTitle}</h3> - <p>${previewDescription}</p> + <p class="search-result__url" data-preview="url">${safeOgUrl}</p> + <h3 data-preview="search-title">${previewTitle}</h3> + <p data-preview="search-description">${previewDescription}</p> </div> </article> @@ -262,11 +225,11 @@ function renderAnalysis(html, targetUrl) { <div><strong>Facebook</strong><span>Open Graph preview</span></div> </div> <div class="social-card"> - <img src="${safeOgImage}" alt="Facebook link preview image"> + <img src="${safeOgImage}" data-preview-image="og" alt="Facebook link preview image"> <div class="social-card__body"> - <span>${safeOgUrl}</span> - <h3>${facebookTitle}</h3> - <p>${facebookDescription}</p> + <span data-preview="url">${safeOgUrl}</span> + <h3 data-preview="og-title">${facebookTitle}</h3> + <p data-preview="og-description">${facebookDescription}</p> </div> </div> </article> @@ -277,11 +240,11 @@ function renderAnalysis(html, targetUrl) { <div><strong>X / Twitter</strong><span>Summary card</span></div> </div> <div class="social-card social-card--dark"> - <img src="${safeTwitterImage}" alt="X link preview image"> + <img src="${safeTwitterImage}" data-preview-image="twitter" alt="X link preview image"> <div class="social-card__body"> - <span>${safeOgUrl}</span> - <h3>${escapeHtml(twitterTitle || ogTitle || "No Title")}</h3> - <p>${escapeHtml(twitterDescription || ogDescription || "No Description")}</p> + <span data-preview="url">${safeOgUrl}</span> + <h3 data-preview="twitter-title">${escapeHtml(twitterTitle || ogTitle || "No Title")}</h3> + <p data-preview="twitter-description">${escapeHtml(twitterDescription || ogDescription || "No Description")}</p> </div> </div> </article> @@ -292,15 +255,20 @@ function renderAnalysis(html, targetUrl) { <div><strong>LinkedIn</strong><span>Open Graph preview</span></div> </div> <div class="social-card"> - <img src="${safeOgImage}" alt="LinkedIn link preview image"> + <img src="${safeOgImage}" data-preview-image="og" alt="LinkedIn link preview image"> <div class="social-card__body"> - <h3>${facebookTitle}</h3> - <span>${safeOgUrl}</span> + <h3 data-preview="og-title">${facebookTitle}</h3> + <span data-preview="url">${safeOgUrl}</span> </div> </div> </article> </div> + ${analysis.images ? `<div class="image-reports surface" aria-label="Social image validation"> + ${renderImageReport("Open Graph image", analysis.images.openGraph)} + ${renderImageReport("Twitter image", analysis.images.twitter)} + </div>` : ""} + <details class="metadata surface"> <summary> <span><strong>Raw social metadata</strong><small>${Object.keys(metaTags).length} tags discovered</small></span> @@ -316,6 +284,62 @@ function renderAnalysis(html, targetUrl) { </section>`; } +function renderAnalyzerForm({ + activeMode = "url", + baseUrl = "", + pastedHtml = "", + requestedUrl = "", +} = {}) { + return ` + <div class="analyzer-panel surface" data-active-input="${escapeHtml(activeMode)}"> + <div class="analyzer-panel__header"> + <span class="analyzer-panel__icon" aria-hidden="true">{ }</span> + <div> + <h2>Analyze metadata</h2> + <p>Inspect a public URL or paste HTML directly.</p> + </div> + </div> + <div class="input-tabs" role="tablist" aria-label="Analysis input"> + <button type="button" role="tab" data-input-mode="url" aria-selected="${activeMode === "url"}">URL</button> + <button type="button" role="tab" data-input-mode="html" aria-selected="${activeMode === "html"}">HTML</button> + </div> + <form method="GET" action="/" class="analyzer-form" data-input-panel="url"${activeMode === "url" ? "" : " hidden"}> + <label for="url">Enter a public page URL</label> + <div class="url-field"> + <span class="request-method">GET</span> + <input id="url" type="url" name="url" placeholder="https://example.com" value="${escapeHtml(requestedUrl)}" autocomplete="url" inputmode="url" required> + </div> + <button class="primary-button" type="submit">Run free audit <span aria-hidden="true">→</span></button> + <p class="form-note"><span aria-hidden="true">$</span> public HTML only · 2 MB max · 10s timeout</p> + </form> + <form method="POST" action="/analyze/html" class="analyzer-form" data-input-panel="html"${activeMode === "html" ? "" : " hidden"}> + <label for="base-url">Base URL <span>(optional)</span></label> + <div class="url-field"> + <span class="request-method">BASE</span> + <input id="base-url" type="url" name="baseUrl" placeholder="https://example.com/page" value="${escapeHtml(baseUrl)}" autocomplete="url" inputmode="url"> + </div> + <label for="html">Paste page HTML or a <head> element</label> + <textarea id="html" name="html" rows="8" maxlength="262144" required>${escapeHtml(pastedHtml)}</textarea> + <button class="primary-button" type="submit">Analyze pasted HTML <span aria-hidden="true">→</span></button> + <p class="form-note"><span aria-hidden="true">$</span> processed in memory · 256 KB max · no page fetch</p> + </form> + </div>`; +} + +function renderError(error, fallbackMessage = "The target website could not be analyzed.") { + const isPublicError = Number.isInteger(error.statusCode); + return ` + <section class="error-card surface" role="alert"> + <span class="error-card__icon" aria-hidden="true">!</span> + <div> + <p class="eyebrow">Analysis could not be completed</p> + <h2>We could not inspect that page</h2> + <p>${escapeHtml(isPublicError ? error.message : fallbackMessage)}</p> + <p class="error-card__hint">Check the input and try again.</p> + </div> + </section>`; +} + function renderPage(form, content) { return `<!DOCTYPE html> <html lang="en" data-theme="light"> @@ -326,6 +350,8 @@ function renderPage(form, content) { <meta name="theme-color" content="#f7f7fb"> <title>MetaScope — SEO Audit & Social Preview + +