Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` 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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
63 changes: 63 additions & 0 deletions lib/report.js
Original file line number Diff line number Diff line change
@@ -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 };
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
87 changes: 87 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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] {
Expand Down
Loading
Loading