(feat) Discord build share

This commit is contained in:
jb
2026-08-02 12:00:30 -04:00
parent d09b6220ee
commit d9924a95b9
19 changed files with 1193 additions and 96 deletions
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { buildShareUrl, copyBuildLink, shareBuild } from './build-sharing';
describe('Build sharing', () => {
it('constructs Build URLs from slugs and preserves absolute URLs', () => {
expect(buildShareUrl('field-medic', 'https://shd.build')).toBe(
'https://shd.build/builds/field-medic',
);
expect(buildShareUrl('build name', 'https://shd.build/ignored')).toBe(
'https://shd.build/builds/build%20name',
);
expect(buildShareUrl('https://stage.shd.build/builds/test')).toBe(
'https://stage.shd.build/builds/test',
);
});
it('uses the native share API', async () => {
const share = vi.fn().mockResolvedValue(undefined);
await expect(
shareBuild(
{ share },
{ title: 'Field Medic', url: 'https://shd.build/builds/field-medic' },
),
).resolves.toEqual({ state: 'shared' });
expect(share).toHaveBeenCalledWith({
title: 'Field Medic',
text: 'View Field Medic on SHD.build.',
url: 'https://shd.build/builds/field-medic',
});
});
it('treats AbortError as cancellation', async () => {
const share = vi
.fn()
.mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
await expect(
shareBuild(
{ share },
{ title: 'Build', url: 'https://shd.build/builds/a' },
),
).resolves.toEqual({ state: 'cancelled' });
});
it('reports unsupported and failed native sharing', async () => {
await expect(
shareBuild({}, { title: 'Build', url: 'https://shd.build/builds/a' }),
).resolves.toEqual({ state: 'unsupported' });
const error = new Error('Share failed');
await expect(
shareBuild(
{ share: vi.fn().mockRejectedValue(error) },
{ title: 'Build', url: 'https://shd.build/builds/a' },
),
).resolves.toEqual({ state: 'failed', error });
});
it('copies the Build URL to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
await expect(
copyBuildLink({ clipboard: { writeText } }, 'https://shd.build/builds/a'),
).resolves.toEqual({ state: 'copied' });
expect(writeText).toHaveBeenCalledWith('https://shd.build/builds/a');
});
it('reports unsupported and failed clipboard access', async () => {
await expect(
copyBuildLink({}, 'https://shd.build/builds/a'),
).resolves.toEqual({
state: 'unsupported',
});
const error = new Error('Clipboard denied');
await expect(
copyBuildLink(
{ clipboard: { writeText: vi.fn().mockRejectedValue(error) } },
'https://shd.build/builds/a',
),
).resolves.toEqual({ state: 'failed', error });
});
});
+72
View File
@@ -0,0 +1,72 @@
export type BuildShareNavigator = {
share?: (data: ShareData) => Promise<void>;
clipboard?: {
writeText: (text: string) => Promise<void>;
};
};
export type BuildShareResult =
| { state: 'shared' }
| { state: 'cancelled' }
| { state: 'unsupported' }
| { state: 'failed'; error: unknown };
export type BuildCopyResult =
| { state: 'copied' }
| { state: 'unsupported' }
| { state: 'failed'; error: unknown };
export function buildShareUrl(slugOrUrl: string, origin?: string): string {
const value = slugOrUrl.trim();
if (!value) throw new Error('A Build slug or URL is required.');
if (/^https?:\/\//i.test(value)) return new URL(value).toString();
const base = origin ?? globalThis.location?.origin;
if (!base) throw new Error('An origin is required to construct a Build URL.');
const slug = value.replace(/^\/+|\/+$/g, '');
return new URL(`/builds/${encodeURIComponent(slug)}`, base).toString();
}
export async function shareBuild(
navigatorApi: BuildShareNavigator,
input: { title: string; url: string },
): Promise<BuildShareResult> {
if (typeof navigatorApi.share !== 'function') return { state: 'unsupported' };
try {
await navigatorApi.share({
title: input.title,
text: `View ${input.title} on SHD.build.`,
url: input.url,
});
return { state: 'shared' };
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError'
) {
return { state: 'cancelled' };
}
return { state: 'failed', error };
}
}
export async function copyBuildLink(
navigatorApi: BuildShareNavigator,
url: string,
): Promise<BuildCopyResult> {
if (typeof navigatorApi.clipboard?.writeText !== 'function') {
return { state: 'unsupported' };
}
try {
await navigatorApi.clipboard.writeText(url);
return { state: 'copied' };
} catch (error) {
return { state: 'failed', error };
}
}
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { buildShareUrl, copyBuildLink, shareBuild } from '$lib/build-sharing';
import { toast } from '$lib/toast';
let {
title,
slugOrUrl,
visibility
}: {
title: string;
slugOrUrl?: string | null;
visibility: 'public' | 'unlisted' | 'private';
} = $props();
let pending = $state<'share' | 'copy' | null>(null);
let status = $state('');
let nativeShareAvailable = $state(false);
const canShare = $derived((visibility === 'public' || visibility === 'unlisted') && Boolean(slugOrUrl?.trim()));
onMount(() => {
nativeShareAvailable = typeof navigator.share === 'function';
});
function shareUrl() {
return buildShareUrl(slugOrUrl ?? '', window.location.origin);
}
async function handleShare() {
if (pending) return;
pending = 'share';
const result = await shareBuild(navigator, { title, url: shareUrl() });
pending = null;
if (result.state === 'shared') {
status = 'Build shared.';
toast.success('BUILD SHARED', 'The system share sheet accepted the Build link.');
} else if (result.state === 'cancelled') {
status = 'Sharing cancelled.';
toast.info('SHARING CANCELLED', 'No link was shared.');
} else if (result.state === 'unsupported') {
status = 'Native sharing is not supported on this device. Use Copy Link instead.';
toast.info('SHARE NOT SUPPORTED', 'Use COPY LINK to copy the Build URL.');
} else {
status = 'The Build could not be shared. Try Copy Link instead.';
toast.error('BUILD NOT SHARED', 'Try COPY LINK or try again.');
}
}
async function handleCopy() {
if (pending) return;
pending = 'copy';
const result = await copyBuildLink(navigator, shareUrl());
pending = null;
if (result.state === 'copied') {
status = 'Build link copied to the clipboard.';
toast.success('LINK COPIED', 'Paste the Build link wherever you want to share it.');
} else if (result.state === 'unsupported') {
status = 'Clipboard copying is not supported in this browser.';
toast.info('COPY NOT SUPPORTED', 'This browser cannot copy the Build link automatically.');
} else {
status = 'The Build link could not be copied.';
toast.error('LINK NOT COPIED', 'Check clipboard permissions and try again.');
}
}
</script>
<section class="build-share-control" aria-label="Share Build">
{#if canShare}
<div class="build-share-heading">
<div>
<span>BUILD UPLINK</span>
<strong>{visibility === 'unlisted' ? 'UNLISTED LINK READY' : 'PUBLIC LINK READY'}</strong>
</div>
<span class="build-share-signal" aria-hidden="true"></span>
</div>
<div class:single={!nativeShareAvailable} class="build-share-actions">
{#if nativeShareAvailable}
<button class="build-share-primary" type="button" onclick={handleShare} disabled={pending !== null} aria-busy={pending === 'share'}>
{pending === 'share' ? 'OPENING SHARE…' : 'SHARE BUILD →'}
</button>
{/if}
<button class:build-share-primary={!nativeShareAvailable} class:build-share-copy={nativeShareAvailable} type="button" onclick={handleCopy} disabled={pending !== null} aria-busy={pending === 'copy'}>
{pending === 'copy' ? 'COPYING…' : 'COPY LINK'}
</button>
</div>
<p class="build-share-discord"><img src="/discord-symbol.svg" alt="" aria-hidden="true" />Discord will render the Build preview when you post the copied link.</p>
{:else}
<div class="build-share-locked">
<span>{slugOrUrl?.trim() ? 'PRIVATE BUILD' : 'UNSAVED BUILD'}</span>
<strong>PUBLIC LINK UNAVAILABLE</strong>
<p>{slugOrUrl?.trim() ? 'Change visibility to Public or Unlisted before sharing this Build.' : 'Save this Build before creating a public or unlisted sharing link.'}</p>
</div>
{/if}
<p class="build-share-status" aria-live="polite" aria-atomic="true">{status}</p>
</section>
<style>
.build-share-control{display:grid;border:1px solid var(--shd-border-strong);background:#0d1011;font-family:var(--shd-font-sans)}
.build-share-heading{min-height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}
.build-share-heading>div,.build-share-locked{display:grid;gap:3px}.build-share-heading span,.build-share-locked>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.9px}.build-share-heading strong,.build-share-locked strong{font-size:var(--shd-type-supporting);letter-spacing:.45px}.build-share-signal{width:8px;height:8px;border:1px solid var(--shd-orange);background:var(--shd-orange);box-shadow:0 0 0 3px #f6a8211c}
.build-share-actions{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.45fr);gap:8px;padding:10px}.build-share-actions button{min-height:44px;padding:0 13px;border-radius:0;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer;transition:var(--shd-transition)}.build-share-primary{border:1px solid var(--shd-orange);background:var(--shd-orange);color:#111415}.build-share-copy{border:1px solid var(--shd-border-strong);background:var(--shd-surface);color:var(--shd-text)}.build-share-primary:hover{background:var(--shd-orange-hover);border-color:var(--shd-orange-hover)}.build-share-copy:hover{border-color:var(--shd-text-muted);background:var(--shd-surface-active)}.build-share-actions button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}.build-share-actions button:active:not(:disabled){transform:translateY(1px);filter:brightness(.86)}.build-share-actions button:disabled{cursor:not-allowed;opacity:.48}
.build-share-actions.single{grid-template-columns:1fr}
.build-share-discord{display:flex;align-items:center;gap:7px;margin:0;padding:0 11px 10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.build-share-discord img{width:16px;height:13px;object-fit:contain;opacity:.72}.build-share-locked{padding:14px}.build-share-locked>span{color:var(--shd-danger)}.build-share-locked p{margin:2px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.build-share-status{min-height:0;margin:0;padding:0 11px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.build-share-status:not(:empty){min-height:32px;padding-top:7px;padding-bottom:7px;border-top:1px solid var(--shd-border-soft)}
@media(max-width:560px){.build-share-actions{grid-template-columns:1fr}.build-share-actions button{width:100%;min-height:44px}}
@media(prefers-reduced-motion:reduce){.build-share-actions button{transition:none}.build-share-actions button:active:not(:disabled){transform:none}}
</style>
+7 -1
View File
@@ -75,7 +75,11 @@
}
function paste(event: ClipboardEvent) {
const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
const file =
Array.from(event.clipboardData?.items ?? [])
.find((item) => item.kind === 'file' && item.type.startsWith('image/'))
?.getAsFile() ??
Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
if (file) {
event.preventDefault();
setFile(file);
@@ -107,10 +111,12 @@
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- focus is required as a scoped native paste target -->
<div
class="image-drop-input"
class:dragging
role="group"
tabindex="0"
aria-label="Drop an image here or paste one from the clipboard"
ondragenter={(event) => { event.preventDefault(); dragging = true; }}
ondragover={(event) => event.preventDefault()}
+105
View File
@@ -0,0 +1,105 @@
import sharp from 'sharp';
import { describe, expect, it } from 'vitest';
import {
BUILD_SOCIAL_CARD_HEIGHT,
BUILD_SOCIAL_CARD_WIDTH,
buildBuildSocialCardSvg,
renderBuildSocialCardPng,
type BuildSocialCardInput,
} from './build-social-card';
const input: BuildSocialCardInput = {
title: 'Vanguard Counter-Offensive',
author: 'Agent Kelso',
primaryWeapon: 'Police M4',
secondaryWeapon: 'Super 90',
firepower: 1280,
toughness: 940,
engineering: 615,
supportingAttributes: [
{ label: 'Critical Hit Chance', value: '18.5%' },
{ label: 'Headshot Damage', value: '42%' },
{ label: 'Armor', value: '12,400' },
],
visibility: 'public',
directUrl: 'shd.build/builds/vanguard-counter-offensive',
};
describe('build social card', () => {
it('escapes XML injection and keeps inputs out of markup and URLs', () => {
const attack = `</text><script href="https://attacker.invalid">alert('x') & more</script>`;
const svg = buildBuildSocialCardSvg({
...input,
title: attack,
author: `A\u0000gent <&> "one" 'two'`,
directUrl: `javascript:alert(1)\n${attack}`,
supportingAttributes: [{ label: attack, value: attack }],
});
expect(svg).not.toContain('<script');
expect(svg).not.toContain('href="https://attacker.invalid"');
expect(svg).not.toContain('\u0000');
expect(svg).toContain(
'&lt;/text&gt;&lt;script href=&quot;https://attacker.invalid&quot;&gt;',
);
expect(svg).toContain(
'A gent &lt;&amp;&gt; &quot;one&quot; &apos;two&apos;',
);
expect(svg).not.toMatch(/<[^>]+\s(?:href|xlink:href)\s*=/gu);
});
it('wraps and truncates long Unicode text without splitting graphemes', () => {
const family = '👨‍👩‍👧‍👦';
const svg = buildBuildSocialCardSvg({
...input,
title: `${family.repeat(31)} ${family.repeat(31)} ${family.repeat(31)}`,
author: `Zoë ${'e\u0301'.repeat(60)}`,
directUrl: `shd.build/builds/${'路'.repeat(130)}`,
});
const titleBlock =
svg.match(/<text class="title">(.*?)<\/text>/u)?.[1] ?? '';
expect(titleBlock.match(/<tspan/gu)).toHaveLength(2);
expect(titleBlock).toContain('…');
expect(titleBlock).not.toMatch(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/gu,
);
expect(svg).toContain('é');
expect(svg).not.toContain('é');
expect(svg).toContain(`${'路'.repeat(20)}`);
expect(svg).toContain('…');
});
it('builds deterministic SVG with bounded supporting facts', () => {
const withExtraFacts = {
...input,
supportingAttributes: [
...input.supportingAttributes!,
{ label: 'Must not render', value: '999' },
],
};
const first = buildBuildSocialCardSvg(withExtraFacts);
const second = buildBuildSocialCardSvg(withExtraFacts);
expect(first).toBe(second);
expect(first).toContain('width="1200" height="630"');
expect(first).toContain('SHD</text><text');
expect(first).toContain('.build');
expect(first).toContain('font-family:"DejaVu Sans Condensed",sans-serif');
expect(first).toContain('SUPPORTING ATTRIBUTES / 03');
expect(first).not.toContain('Must not render');
});
it('renders a valid 1200x630 PNG through Sharp', async () => {
const png = await renderBuildSocialCardPng(input);
const metadata = await sharp(png).metadata();
expect(png.subarray(0, 8)).toEqual(
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
);
expect(metadata.format).toBe('png');
expect(metadata.width).toBe(BUILD_SOCIAL_CARD_WIDTH);
expect(metadata.height).toBe(BUILD_SOCIAL_CARD_HEIGHT);
expect(png.byteLength).toBeLessThan(1_000_000);
});
});
+170
View File
@@ -0,0 +1,170 @@
import sharp from 'sharp';
export const BUILD_SOCIAL_CARD_WIDTH = 1200;
export const BUILD_SOCIAL_CARD_HEIGHT = 630;
export type BuildVisibility = 'public' | 'unlisted' | 'private';
export interface BuildSocialCardFact {
label: string;
value: string;
}
export interface BuildSocialCardInput {
title: string;
author: string;
primaryWeapon: string;
secondaryWeapon: string;
firepower: string | number;
toughness: string | number;
engineering: string | number;
supportingAttributes?: readonly BuildSocialCardFact[];
visibility: BuildVisibility;
directUrl: string;
}
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function graphemes(value: string): string[] {
return Array.from(segmenter.segment(value), ({ segment }) => segment);
}
function normalizeText(value: string | number, maximum: number): string {
const normalized = String(value)
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ')
.replace(/\s+/gu, ' ')
.trim();
const characters = graphemes(normalized);
if (characters.length <= maximum) return normalized;
return `${characters
.slice(0, Math.max(0, maximum - 1))
.join('')
.trimEnd()}…`;
}
function escapeXml(value: string): string {
return value
.replace(/&/gu, '&amp;')
.replace(/</gu, '&lt;')
.replace(/>/gu, '&gt;')
.replace(/"/gu, '&quot;')
.replace(/'/gu, '&apos;');
}
function wrapText(
value: string,
width: number,
maximumLines: number,
): string[] {
const characters = graphemes(value);
const lines: string[] = [];
let offset = 0;
while (offset < characters.length && lines.length < maximumLines) {
const remaining = characters.slice(offset);
if (remaining.length <= width) {
lines.push(remaining.join('').trim());
offset = characters.length;
break;
}
const candidate = remaining.slice(0, width + 1);
let splitAt = -1;
for (
let index = Math.min(width, candidate.length - 1);
index > 0;
index--
) {
if (/\s/u.test(candidate[index])) {
splitAt = index;
break;
}
}
if (splitAt < Math.floor(width * 0.55)) splitAt = width;
lines.push(remaining.slice(0, splitAt).join('').trim());
offset += splitAt;
while (offset < characters.length && /\s/u.test(characters[offset]))
offset++;
}
if (offset < characters.length && lines.length > 0) {
const last = graphemes(lines.at(-1) ?? '');
lines[lines.length - 1] = `${last
.slice(0, Math.max(0, width - 1))
.join('')
.trimEnd()}…`;
}
return lines.length > 0 ? lines : ['UNTITLED BUILD'];
}
function textLines(
lines: readonly string[],
x: number,
y: number,
lineHeight: number,
): string {
return lines
.map(
(line, index) =>
`<tspan x="${x}" y="${y + index * lineHeight}">${escapeXml(line)}</tspan>`,
)
.join('');
}
function visibilityLabel(visibility: BuildVisibility): string {
if (visibility === 'public') return 'PUBLIC';
if (visibility === 'unlisted') return 'UNLISTED';
return 'PRIVATE';
}
/** Builds the complete, text-only SVG supplied to Sharp. */
export function buildBuildSocialCardSvg(input: BuildSocialCardInput): string {
const title = wrapText(normalizeText(input.title, 112), 31, 2);
const author = normalizeText(input.author, 42) || 'UNKNOWN AGENT';
const primaryWeapon = normalizeText(input.primaryWeapon, 52) || 'UNSPECIFIED';
const secondaryWeapon =
normalizeText(input.secondaryWeapon, 52) || 'UNSPECIFIED';
const firepower = normalizeText(input.firepower, 18) || '—';
const toughness = normalizeText(input.toughness, 18) || '—';
const engineering = normalizeText(input.engineering, 18) || '—';
const directUrl = normalizeText(input.directUrl, 92) || 'shd.build';
const facts = (input.supportingAttributes ?? []).slice(0, 3).map((fact) => ({
label: normalizeText(fact.label.toUpperCase(), 28) || 'ATTRIBUTE',
value: normalizeText(fact.value, 30) || '—',
}));
const factMarkup = facts
.map((fact, index) => {
const y = 421 + index * 48;
return `<g><line x1="746" y1="${y + 13}" x2="764" y2="${y + 13}" class="signal"/><text x="778" y="${y}" class="fact-label">${escapeXml(fact.label)}</text><text x="778" y="${y + 22}" class="fact-value">${escapeXml(fact.value)}</text></g>`;
})
.join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="SHD.build build card">
<style>
text{font-family:"DejaVu Sans Condensed",sans-serif}.telemetry{font-size:13px;font-weight:700;letter-spacing:2px;fill:#858b8d}.title{font-size:54px;font-weight:900;letter-spacing:-1px;fill:#ecebe5}.weapon{font-size:25px;font-weight:800;fill:#ecebe5}.stat-label,.fact-label{font-size:12px;font-weight:800;letter-spacing:1.5px;fill:#858b8d}.stat-value{font-size:31px;font-weight:900;fill:#ecebe5}.fact-value{font-size:19px;font-weight:700;fill:#ecebe5}.signal{stroke:#f6a821;stroke-width:2}.grid{stroke:#272c2d;stroke-width:1}.panel{fill:#111415;stroke:#303536;stroke-width:1}
</style>
<rect width="1200" height="630" fill="#0a0c0d"/><path d="M0 96H1200M0 594H1200M710 96V594" class="grid"/><path d="M28 28H76M28 28V76M1172 28H1124M1172 28V76M28 602H76M28 602V554M1172 602H1124M1172 602V554" class="signal"/>
<g transform="translate(52 45)"><text x="0" y="0" font-size="34" font-weight="900" fill="#ecebe5">SHD</text><text x="67" y="0" font-size="34" font-weight="900" fill="#f6a821">.build</text><text x="191" y="-2" class="telemetry">BUILD DOSSIER / SOCIAL TRANSMISSION</text></g>
<text x="52" y="139" class="telemetry">AGENT LOADOUT</text><text class="title">${textLines(title, 52, 201, 61)}</text><text x="52" y="294" class="telemetry">AUTHORED BY</text><text x="164" y="294" font-size="18" font-weight="800" fill="#ecebe5">${escapeXml(author)}</text>
<g><rect x="52" y="328" width="622" height="88" class="panel"/><rect x="52" y="328" width="4" height="88" fill="#f6a821"/><text x="76" y="356" class="stat-label">PRIMARY WEAPON</text><text x="76" y="391" class="weapon">${escapeXml(primaryWeapon)}</text></g>
<g><rect x="52" y="430" width="622" height="88" class="panel"/><text x="76" y="458" class="stat-label">SECONDARY WEAPON</text><text x="76" y="493" class="weapon">${escapeXml(secondaryWeapon)}</text></g>
<g transform="translate(746 132)"><text x="0" y="0" class="telemetry">CORE ATTRIBUTES</text><rect x="0" y="24" width="402" height="174" class="panel"/><line x1="134" y1="24" x2="134" y2="198" class="grid"/><line x1="268" y1="24" x2="268" y2="198" class="grid"/><text x="18" y="66" class="stat-label">FIREPOWER</text><text x="18" y="112" class="stat-value">${escapeXml(firepower)}</text><text x="152" y="66" class="stat-label">TOUGHNESS</text><text x="152" y="112" class="stat-value">${escapeXml(toughness)}</text><text x="286" y="66" class="stat-label">ENGINEERING</text><text x="286" y="112" class="stat-value">${escapeXml(engineering)}</text><path d="M18 166H116M152 166H250M286 166H384" class="signal"/></g>
<text x="746" y="374" class="telemetry">SUPPORTING ATTRIBUTES / ${String(facts.length).padStart(2, '0')}</text>${factMarkup}
<g><text x="52" y="566" class="telemetry">DIRECT LINK</text><text x="157" y="566" font-size="15" font-weight="700" fill="#ecebe5">${escapeXml(directUrl)}</text><rect x="972" y="541" width="176" height="36" fill="#15191a" stroke="#f6a821"/><text x="1060" y="564" text-anchor="middle" font-size="13" font-weight="900" letter-spacing="2" fill="#f6a821">${visibilityLabel(input.visibility)}</text></g>
</svg>`;
}
/** Renders a deterministic 1200x630 PNG without loading external resources. */
export async function renderBuildSocialCardPng(
input: BuildSocialCardInput,
): Promise<Buffer> {
return sharp(Buffer.from(buildBuildSocialCardSvg(input)), {
density: 72,
failOn: 'error',
unlimited: false,
})
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
.toBuffer();
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { publicOrigin } from './public-origin';
describe('publicOrigin', () => {
it('uses the configured public origin instead of a request host', () => {
expect(
publicOrigin('https://host-header.invalid', 'https://shd.build/app/path'),
).toBe('https://shd.build');
});
it('uses the request origin when APP_ORIGIN is not configured', () => {
expect(publicOrigin('http://localhost:5173', '')).toBe(
'http://localhost:5173',
);
});
it('rejects non-HTTP origins', () => {
expect(() =>
publicOrigin('https://shd.build', 'javascript:alert(1)'),
).toThrow('APP_ORIGIN must use HTTP or HTTPS.');
});
});
+13
View File
@@ -0,0 +1,13 @@
import { env } from '$env/dynamic/private';
export function publicOrigin(
fallback: string,
configuredOrigin: string | undefined = env.APP_ORIGIN,
): string {
const value = configuredOrigin?.trim() || fallback;
const url = new URL(value);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('APP_ORIGIN must use HTTP or HTTPS.');
}
return url.origin;
}
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
loadCatalog: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
getDatabase: vi.fn(async () => ({
prepare: vi.fn(async (sql: string) => ({
get: vi.fn(async () => (sql.includes('FROM builds') ? mocks.row : null)),
all: vi.fn(async () => []),
})),
})),
}));
vi.mock('$lib/server/shared-build-summary', () => ({
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
}));
import { loadSharedBuildProjection } from './shared-build';
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
return {
id: 'build-id',
owner_id: 'owner-id',
title: 'Field Medic',
slug: 'field-medic-12345678',
visibility,
author: 'Aewyn',
profile_is_public: 1,
loadout_json: JSON.stringify(createDraft()),
updated_at: '2026-08-02T00:00:00.000Z',
};
}
describe('shared Build access policy', () => {
beforeEach(() => {
mocks.row = null;
mocks.loadCatalog.mockReset().mockResolvedValue({
attributes: [],
gear: [],
gearAugments: [],
brands: [],
weapons: [],
talents: [],
chipSets: [],
protocols: [],
specializations: [],
});
});
it.each(['public', 'unlisted'] as const)(
'permits %s Builds in public previews',
async (visibility) => {
mocks.row = buildRow(visibility);
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
});
expect(result?.build.visibility).toBe(visibility);
expect(result?.build.owner).toBe(false);
},
);
it('does not expose private Builds to public previews or other viewers', async () => {
mocks.row = buildRow('private');
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
}),
).resolves.toBeNull();
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'viewer-page',
viewerId: 'another-agent',
}),
).resolves.toBeNull();
expect(mocks.loadCatalog).not.toHaveBeenCalled();
});
it('permits the owner to view a private Build page', async () => {
mocks.row = buildRow('private');
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'viewer-page',
viewerId: 'owner-id',
});
expect(result?.build.owner).toBe(true);
expect(result?.build.visibility).toBe('private');
});
it('returns null for malformed loadout snapshots', async () => {
mocks.row = { ...buildRow('public'), loadout_json: '{invalid' };
await expect(
loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
}),
).resolves.toBeNull();
expect(mocks.loadCatalog).not.toHaveBeenCalled();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { avatarUrl } from '$lib/avatar';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import { getDatabase } from '$lib/server/database';
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
export type SharedBuildAccessPolicy =
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
type BuildVisibility = 'public' | 'unlisted' | 'private';
export async function loadSharedBuildProjection(
slug: string,
access: SharedBuildAccessPolicy,
) {
const database = await getDatabase();
const build = await (
await database.prepare(`
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
user_profiles.public_slug,
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
user_avatars.source AS avatar_source,user_avatars.value AS avatar_value,
user_avatars.updated_at AS avatar_updated_at
FROM builds
JOIN users ON users.id=builds.owner_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
WHERE builds.slug=?
`)
).get(slug);
if (!build) return null;
const ownerId = String(build.owner_id);
const visibility = String(build.visibility) as BuildVisibility;
const viewerId = access.kind === 'viewer-page' ? access.viewerId : null;
const accessible =
access.kind === 'public-preview'
? visibility === 'public' || visibility === 'unlisted'
: visibility === 'public' ||
visibility === 'unlisted' ||
(visibility === 'private' && ownerId === viewerId);
if (!accessible) return null;
let loadout;
try {
loadout = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
} catch {
return null;
}
if (!loadout) return null;
const [gear, weapons, chips, protocols, brands, summaryCatalog] =
await Promise.all([
(
await database.prepare(
'SELECT id,name,slot,tier,base_armor_min FROM gear',
)
).all(),
(
await database.prepare(
'SELECT id,name,category,is_exotic,rpm,magazine,reload_speed,damage_type FROM weapons',
)
).all(),
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
(
await database.prepare('SELECT id,name,quality FROM os_protocols')
).all(),
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
loadSharedBuildSummaryCatalog(database),
]);
const summary = summarizeLoadout(loadout, summaryCatalog);
return {
build: {
id: String(build.id),
title: String(build.title),
slug: String(build.slug),
visibility,
author: String(build.author),
ownerId,
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
profileAvailable:
Boolean(build.profile_is_public) && !build.deleted_user_id,
authorAvatarUrl: avatarUrl(
build.avatar_source,
build.avatar_value,
ownerId,
build.avatar_updated_at,
),
owner: ownerId === viewerId,
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
};
}