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(`${escapeHtml(check.suggestion)}
+${escapeHtml(check.message)}
+ ${check.evidence ? `${escapeHtml(check.evidence)}` : ""}Audit results
-${ - opportunityCount - ? `${opportunityCount} ${opportunityCount === 1 ? "opportunity" : "opportunities"} to improve before publishing.` - : "Everything in this quick audit looks ready to share." - }
+${score.errors} errors · ${score.warnings} warnings · ${score.passed} passed
${safeOgUrl}Live preview
@@ -250,9 +213,9 @@ function renderAnalysis(html, targetUrl) {${safeOgUrl}
-${previewDescription}
+${safeOgUrl}
+${previewDescription}