Skip to content
Open
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
6 changes: 6 additions & 0 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ bun run build
bun run start
```

## Environment

`DUALMARK_SHARE_SECRET` signs the share tokens used by `/play` score links and
the `/api/og/score` card. Set it to any long random string. Without it the
playground still works; the share buttons just don't show up.

## Structure

```
Expand Down
26 changes: 26 additions & 0 deletions apps/docs/app/api/og/score/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { renderScoreOgImage } from "@/lib/og";
import { verifySharePayload } from "@/lib/share-token";

export const runtime = "edge";
export const dynamic = "force-dynamic";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get("token");

const payload = token ? await verifySharePayload(token) : null;
if (!payload) {
return new Response("Invalid or missing token", { status: 404 });
}

const response = renderScoreOgImage({
url: payload.u,
score: payload.s,
maxScore: payload.m,
});
response.headers.set(
"cache-control",
"public, max-age=31536000, s-maxage=31536000, immutable",
);
return response;
}
24 changes: 23 additions & 1 deletion apps/docs/app/api/play/score/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type VerifyReport, verifyUrl } from "@dualmark/cli";
import { NextResponse } from "next/server";
import { signSharePayload } from "@/lib/share-token";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -191,6 +192,23 @@ function summarize(
};
}

// Signing needs DUALMARK_SHARE_SECRET. If it's unset (e.g. local dev), skip the
// token so scoring still works; the client just won't show a share link.
async function buildShareToken(
report: VerifyReport,
): Promise<string | undefined> {
try {
return await signSharePayload({
u: report.url,
s: report.score,
m: report.maxScore,
t: Math.floor(Date.now() / 1000),
});
} catch {
return undefined;
}
}

export async function POST(request: Request) {
let body: unknown;
try {
Expand Down Expand Up @@ -231,8 +249,12 @@ export async function POST(request: Request) {
}),
detectFramework(parsed.toString()),
]);
const shareToken = await buildShareToken(report);
return NextResponse.json(
summarize(report, detection.framework, detection.signals),
{
...summarize(report, detection.framework, detection.signals),
shareToken,
},
{ headers: { "cache-control": "no-store" } },
);
} catch (err) {
Expand Down
132 changes: 132 additions & 0 deletions apps/docs/app/play/playground-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface ScoreResult {
skippedNegotiation: boolean;
passed: CheckSummary[];
failed: CheckSummary[];
shareToken?: string;
}

const FRAMEWORK_LABEL: Record<Framework, string> = {
Expand Down Expand Up @@ -411,6 +412,8 @@ function ResultPanel({ result }: { result: ScoreResult }) {
</div>
</section>

<ShareBar result={result} />

<CheckList
title="Failed — required"
accent="var(--color-danger)"
Expand Down Expand Up @@ -438,6 +441,90 @@ function ResultPanel({ result }: { result: ScoreResult }) {
);
}

function hostFromUrl(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}

function ShareBar({ result }: { result: ScoreResult }) {
const [copied, setCopied] = useState(false);
const token = result.shareToken;

useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(t);
}, [copied]);

if (!token) return null;

const host = hostFromUrl(result.url);
const shareUrl = () => `${window.location.origin}/play/r/${token}`;
const imageUrl = `/api/og/score?token=${encodeURIComponent(token)}`;

async function copyLink() {
try {
await navigator.clipboard.writeText(shareUrl());
setCopied(true);
} catch {
void 0;
}
}

function postToX() {
const text = `${host} scored ${result.score}/${result.maxScore} (${result.level}) on the AEO Spec v1.0`;
const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
text,
)}&url=${encodeURIComponent(shareUrl())}`;
window.open(intent, "_blank", "noopener,noreferrer");
}

return (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-elev-1)]/40 px-5 py-4">
<div className="flex items-center gap-2">
<ShareIcon className="size-4 text-[var(--color-accent)]" />
<span className="font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--color-fg-subtle)]">
Share this score
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={copyLink}
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-elev-1)] px-3 text-xs font-medium text-[var(--color-fg)] transition-colors hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-bg-elev-2)]"
>
{copied ? (
<CheckIcon className="size-4 text-[var(--color-success)]" />
) : (
<UrlIcon className="size-4" />
)}
{copied ? "Copied!" : "Copy link"}
</button>
<button
type="button"
onClick={postToX}
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-elev-1)] px-3 text-xs font-medium text-[var(--color-fg)] transition-colors hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-bg-elev-2)]"
>
<XGlyph className="size-3.5" />
Post on X
</button>
<a
href={imageUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-elev-1)] px-3 text-xs font-medium text-[var(--color-fg)] transition-colors hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-bg-elev-2)]"
>
<ImageIcon className="size-4" />
Image
</a>
</div>
</section>
);
}

function ScoreGauge({
result,
tone,
Expand Down Expand Up @@ -995,3 +1082,48 @@ function CursorGlyph({ className }: { className?: string }) {
</svg>
);
}
function ShareIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
<path
d="M8.7 13.3 15.3 17M15.3 7 8.7 10.7M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm12 7a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
function XGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.66l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z"
fill="currentColor"
/>
</svg>
);
}
function ImageIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
<rect
x="3"
y="4"
width="18"
height="16"
rx="2"
stroke="currentColor"
strokeWidth="1.6"
/>
<circle cx="8.5" cy="9.5" r="1.5" fill="currentColor" />
<path
d="m4 18 5-5 4 4 3-3 4 4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
143 changes: 143 additions & 0 deletions apps/docs/app/play/r/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { BrandMark } from "../../../_components/brand-mark";
import {
levelFromScore,
type ScoreLevel,
type SharePayload,
verifySharePayload,
} from "@/lib/share-token";

interface PageProps {
params: Promise<{ token: string }>;
}

const LEVEL_TONE: Record<ScoreLevel, { color: string; border: string }> = {
Advanced: {
color: "var(--color-success)",
border: "rgba(74, 222, 128, 0.4)",
},
Standard: {
color: "var(--color-accent-strong)",
border: "rgba(198, 254, 30, 0.45)",
},
Basic: { color: "var(--color-warning)", border: "rgba(251, 191, 36, 0.4)" },
"Below Basic": {
color: "var(--color-danger)",
border: "rgba(248, 113, 113, 0.4)",
},
};

function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}

export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { token } = await params;
const payload = await verifySharePayload(token);
if (!payload) {
return { title: "Score not found" };
}

const host = hostOf(payload.u);
const level = levelFromScore(payload.s, payload.m);
const title = `${host} scored ${payload.s}/${payload.m} — ${level}`;
const description = `AEO Spec v1.0 conformance for ${host}. Score your own site at dualmark.dev/play.`;
const image = `/api/og/score?token=${encodeURIComponent(token)}`;

return {
title,
description,
openGraph: {
title,
description,
url: `/play/r/${token}`,
type: "website",
images: [{ url: image, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [image],
},
};
}

export default async function SharedScorePage({ params }: PageProps) {
const { token } = await params;
const payload = await verifySharePayload(token);
if (!payload) {
notFound();
}

return <SharedScore payload={payload as SharePayload} />;
}

function SharedScore({ payload }: { payload: SharePayload }) {
const host = hostOf(payload.u);
const level = levelFromScore(payload.s, payload.m);
const tone = LEVEL_TONE[level];
const percentage =
payload.m > 0 ? Math.round((payload.s / payload.m) * 100) : 0;
const rescanHref = `/play?url=${encodeURIComponent(payload.u)}`;

return (
<main className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-2xl flex-col items-center justify-center gap-8 px-5 py-16">
<Link href="/play" className="flex items-center gap-2">
<BrandMark size={22} />
<span className="font-mono text-xs uppercase tracking-[0.18em] text-[var(--color-fg-subtle)]">
AEO Spec v1.0
</span>
</Link>

<section
className="flex w-full flex-col items-center gap-5 rounded-2xl border bg-[var(--color-bg-elev-1)] px-8 py-10 text-center shadow-2xl shadow-black/30"
style={{ borderColor: tone.border }}
>
<div className="font-mono text-sm text-[var(--color-fg-muted)]">
{host}
</div>
<div className="flex items-end gap-1">
<span
className="font-mono text-6xl font-semibold tracking-tight"
style={{ color: tone.color }}
>
{payload.s}
</span>
<span className="mb-2 font-mono text-xl text-[var(--color-fg-subtle)]">
/ {payload.m}
</span>
</div>
<span
className="inline-flex items-center rounded-full border px-4 py-1.5 font-mono text-xs uppercase tracking-[0.18em]"
style={{ color: tone.color, borderColor: tone.border }}
>
{level} · {percentage}%
</span>
</section>

<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href={rescanHref}
className="inline-flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-elev-1)] px-4 py-2 text-sm font-medium text-[var(--color-fg)] transition-colors hover:border-[var(--color-border-strong)]"
>
Re-scan this site
</Link>
<Link
href="/play"
className="inline-flex items-center gap-2 rounded-md bg-[var(--color-fg)] px-4 py-2 text-sm font-medium text-[var(--color-bg)] transition-opacity hover:opacity-90"
>
Score your site →
</Link>
</div>
</main>
);
}
Loading
Loading