diff --git a/README.md b/README.md index 44fbbbf..0cba792 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ A compact Node.js application that audits a public webpage or pasted HTML, expla - 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. +- Renders search, Facebook, X/Twitter, LinkedIn, Slack, and WhatsApp-style preview cards. - Validates social-image reachability, type, bytes, dimensions, and aspect ratio. - Edits draft metadata with instant preview updates and generated `
` tags. +- Exposes the same URL analyzer through a versioned JSON response. +- Copies shareable audit links and exports JSON or Markdown reports without storage. - 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. @@ -65,6 +67,24 @@ PORT=4000 pnpm start No database, API key, or environment file is required. +## API and report exports + +Analyze a public page as JSON: + +```http +GET /api/analyze?url=https%3A%2F%2Fexample.com +``` + +The response contains `schemaVersion`, the analyzed URL and source details, the score summary, normalized metadata, deterministic checks, and image-inspection results. API failures return a JSON `error` without exposing unexpected upstream details. + +Download the same URL audit as Markdown: + +```http +GET /report.md?url=https%3A%2F%2Fexample.com +``` + +URL results also include controls to copy the reproducible audit URL, open the JSON export, or download the Markdown report. Pasted HTML stays local to the request and therefore does not expose share/export controls. + If the application runs behind a reverse proxy, set the number of trusted proxy hops so rate limiting uses the client address safely: ```bash @@ -91,7 +111,9 @@ The test suite covers: - HTML and attribute escaping - Unsafe preview URL removal - Successful SEO and social metadata rendering +- Slack and WhatsApp preview rendering - URL and pasted-HTML analysis modes +- Stable JSON analysis and Markdown report exports - Deterministic audit rules and scoring - Bounded social-image inspection and safe same-origin previews - Generated metadata-tag escaping and unsafe URL omission @@ -107,6 +129,7 @@ The test suite covers: │ ├── audit.js # Deterministic rules and scoring │ ├── image-inspector.js # Bounded social-image validation │ ├── metadata.js # Normalized metadata parsing +│ ├── report.js # Public JSON and Markdown serializers │ └── safe-fetch.js # URL validation and bounded page fetching ├── public/ │ ├── app.js # Input tabs and live draft previews @@ -131,6 +154,7 @@ The previous Heroku demo is no longer linked because it currently returns 404. - 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. +- Share links and exports rerun the live URL analysis; results can change when the target page changes. ## License diff --git a/lib/report.js b/lib/report.js new file mode 100644 index 0000000..f0a4870 --- /dev/null +++ b/lib/report.js @@ -0,0 +1,63 @@ +function serializeAnalysis(analysis) { + return { + schemaVersion: 1, + url: analysis.source.finalUrl || analysis.source.requestedUrl || "", + source: analysis.source, + score: analysis.score, + metadata: analysis.metadata, + checks: analysis.checks, + images: analysis.images || { openGraph: null, twitter: null }, + }; +} + +function markdownCell(value) { + const normalized = String(value ?? "") + .replace(/\r?\n/g, " ") + .replace(/\|/g, "\\|") + .trim(); + return normalized || "—"; +} + +function toMarkdownReport(analysis) { + const report = serializeAnalysis(analysis); + const { metadata, score } = report; + const metadataRows = [ + ["Title", metadata.title], + ["Meta description", metadata.description], + ["Canonical URL", metadata.canonical?.url || metadata.canonical?.raw], + ["Open Graph title", metadata.openGraph?.title], + ["Open Graph description", metadata.openGraph?.description], + ["Open Graph image", metadata.openGraph?.image?.url || metadata.openGraph?.image?.raw], + ["Open Graph URL", metadata.openGraph?.url?.url || metadata.openGraph?.url?.raw], + ["Twitter card", metadata.twitter?.card], + ["Twitter title", metadata.twitter?.title], + ["Twitter description", metadata.twitter?.description], + ["Twitter image", metadata.twitter?.image?.url || metadata.twitter?.image?.raw], + ]; + const findings = report.checks.map((check) => + `| ${markdownCell(check.status).toUpperCase()} | ${markdownCell(check.category)} | ${markdownCell(check.id)} | ${markdownCell(check.message)} | ${markdownCell(check.evidence)} |`, + ); + + return [ + `# MetaScope audit: ${markdownCell(report.url || "Pasted HTML")}`, + "", + `Score: **${score.value}/100**`, + "", + `${score.errors} errors · ${score.warnings} warnings · ${score.passed} passed · ${score.info || 0} informational`, + "", + "## Detected metadata", + "", + "| Field | Value |", + "| --- | --- |", + ...metadataRows.map(([label, value]) => `| ${label} | ${markdownCell(value)} |`), + "", + "## Findings", + "", + "| Status | Category | Check | Finding | Evidence |", + "| --- | --- | --- | --- | --- |", + ...findings, + "", + ].join("\n"); +} + +module.exports = { serializeAnalysis, toMarkdownReport }; diff --git a/package.json b/package.json index ddc3367..31f9287 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A Node.js application that performs a basic SEO audit and displays social media previews based on a website's meta tags.", "main": "server.js", "scripts": { - "check:syntax": "node --check server.js && node --check lib/safe-fetch.js", + "check:syntax": "node --check server.js && node --check lib/analyze.js && node --check lib/audit.js && node --check lib/image-inspector.js && node --check lib/metadata.js && node --check lib/report.js && node --check lib/safe-fetch.js && node --check public/app.js && node --check public/tag-generator.js", "start": "node server.js", "test": "node --test", "test:ui": "node --test test/app.test.js", diff --git a/public/app.js b/public/app.js index 21cdeaf..7dd82db 100644 --- a/public/app.js +++ b/public/app.js @@ -13,6 +13,20 @@ }); }); + const copyAuditLink = document.querySelector("[data-copy-audit-link]"); + if (copyAuditLink) { + const shareStatus = document.querySelector("[data-share-status]"); + copyAuditLink.addEventListener("click", async () => { + const auditUrl = new URL(copyAuditLink.dataset.auditPath, window.location.origin).toString(); + try { + await navigator.clipboard.writeText(auditUrl); + shareStatus.textContent = "Audit link copied."; + } catch { + shareStatus.textContent = "Copy the audit URL from your browser address bar."; + } + }); + } + const editor = document.querySelector("[data-metadata-editor]"); if (!editor || !globalThis.MetaScopeTags) return; diff --git a/public/styles.css b/public/styles.css index 734ba3e..1281429 100644 --- a/public/styles.css +++ b/public/styles.css @@ -468,6 +468,19 @@ summary:focus-visible { white-space: nowrap; } +.result-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 18px; +} + +.result-actions [data-share-status] { + color: var(--green); + font-size: 0.72rem; +} + .score { display: flex; flex: 0 0 112px; @@ -680,6 +693,16 @@ summary:focus-visible { font-size: 0.67rem; } +.preview-icon--slack { + color: white; + background: #611f69; +} + +.preview-icon--whatsapp { + color: white; + background: #1fa855; +} + .search-result { max-width: 720px; padding: 21px 24px; @@ -780,6 +803,66 @@ summary:focus-visible { color: #a9a9a9; } +.message-preview { + overflow: hidden; + border: 1px solid #d9dce3; + border-radius: 10px; +} + +.message-preview__body { + min-width: 0; + padding: 13px 14px; +} + +.message-preview__body > span { + display: block; + overflow: hidden; + color: #6d7078; + font-size: 0.62rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-preview__body h3 { + margin: 5px 0 4px; + font-size: 0.84rem; + line-height: 1.3; +} + +.message-preview__body p { + margin: 0 0 8px; + color: #676971; + font-size: 0.68rem; + line-height: 1.35; +} + +.message-preview__body img, +.message-preview--whatsapp > img { + width: 100%; + aspect-ratio: 1.91 / 1; + object-fit: cover; + background: #e7e6ed; + border-radius: 6px; +} + +.message-preview--slack { + display: grid; + grid-template-columns: 4px minmax(0, 1fr); + background: white; +} + +.message-preview__accent { + background: #b9b9bd; +} + +.message-preview--whatsapp { + background: #f0f2f5; +} + +.message-preview--whatsapp > img { + border-radius: 0; +} + .metadata { margin-top: 18px; overflow: hidden; @@ -1233,12 +1316,16 @@ summary:focus-visible { } .secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; color: var(--ink-soft); background: white; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; font-weight: 700; + text-decoration: none; } [data-copy-status] { diff --git a/server.js b/server.js index 09f4852..7cbb861 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const helmet = require("helmet"); const { analyzeDocument, analyzeHtml } = require("./lib/analyze"); const { fetchImage, inspectImage } = require("./lib/image-inspector"); const { safeWebUrl } = require("./lib/metadata"); +const { serializeAnalysis, toMarkdownReport } = require("./lib/report"); const { fetchPage, normalizeTargetUrl } = require("./lib/safe-fetch"); const { generateTags } = require("./public/tag-generator"); @@ -90,6 +91,21 @@ function renderEditor(metadata) { `; } +function renderShareActions(analysis) { + if (analysis.source.mode !== "url") return ""; + const requestedUrl = analysis.source.requestedUrl || analysis.source.finalUrl; + if (!requestedUrl) return ""; + const query = `url=${encodeURIComponent(requestedUrl)}`; + const auditPath = `/?${query}`; + + return `See how your page can appear across search, Facebook, X, and LinkedIn.
+See how your page can appear across search, Facebook, X, LinkedIn, Slack, and WhatsApp.